Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09daff487a | |||
| 49bd683401 | |||
| 17330c4c03 | |||
| 2a4ad6fc41 | |||
| ec6999554a | |||
| 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 | |||
| ade2565f49 | |||
| c35d983fef | |||
| 69878167c2 | |||
| eb1f717d35 | |||
| 8e9af5c51b | |||
| afe0c760f6 | |||
| a231cefac0 | |||
| cb4c9d16fc | |||
| 7366f089ba | |||
| 7c3513b9df | |||
| aef3644c93 | |||
| 0cf58adb4c | |||
| 3aab81bc35 | |||
| 3b0fb1562d | |||
| 0488b7b5c6 | |||
| b8d13c98b5 | |||
| 6f82a88b79 | |||
| 022f349dc6 | |||
| c1e2146d78 | |||
| 8091ac6c08 | |||
| bc414ccaaf | |||
| 0cf5f1ac1f | |||
| e9cb7ee77c | |||
| 86c036505b | |||
| 39e7b8cf4f | |||
| e0ece680a6 | |||
| 8dfea49ec1 | |||
| d7f314cda8 | |||
| 6adb45ef5a | |||
| b0eeb8e6e6 | |||
| c3c20b6087 | |||
| 0faec7efb6 | |||
| aff817c667 | |||
| 9a3a3ecb44 | |||
| 81f8039ec7 | |||
| 14759d5b72 | |||
| b997e69ebc | |||
| 4a89aba682 | |||
| 34dcf998bd | |||
| 325d1bec91 | |||
| b917e42694 | |||
| eb9e67e36a | |||
| f14ae52e94 | |||
| f93eb658d6 | |||
| 9bac44ee75 | |||
| 91b4109f67 | |||
| 6e45cb95db | |||
| 5edf82898b | |||
| 60b82e3055 | |||
| 5d6590c200 | |||
| d3bebbc68d | |||
| 4ff8f3b006 | |||
| abbc756887 | |||
| 10e07e434c | |||
| fb582b45db | |||
| 23f21434ff | |||
| 7e9fdf85a0 | |||
| 98c26642d0 | |||
| 2b434db062 | |||
| f919907855 | |||
| bfeb3c7dfd | |||
| 8729ab3c54 | |||
| 37f53bccbf | |||
| c783763307 | |||
| f151f45df8 | |||
| f33880e005 | |||
| 29ed50f6ea | |||
| ce52c18382 | |||
| 7c3e1d8d1b | |||
| 0d15934a96 | |||
| a0069d04f0 | |||
| 3b86944365 | |||
| 4c7b1a2269 | |||
| 60864ace54 | |||
| 4b32c03994 | |||
| 222dbdcc00 | |||
| 1346227d30 | |||
| ecbddd214c | |||
| 4999ca5c82 | |||
| 174638889d | |||
| 480998582e | |||
| 94544e458c | |||
| f664ba9e1e | |||
| 015a60638e | |||
| 7abb80530f | |||
| 5abe9b93e8 | |||
| 59cff0f219 | |||
| 81c17a56fb | |||
| 802b8c50d5 |
@@ -1,5 +1,5 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
|
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
REACT_APP_INCLUDE_GTAG=true
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- lipis
|
||||||
|
assignees:
|
||||||
|
- lipis
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/src/packages/excalidraw/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/src/packages/utils/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docker:
|
build-docker:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name: Cancel
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
cancel:
|
||||||
|
name: "Cancel Previous Runs"
|
||||||
|
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 }}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: "CodeQL"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
schedule:
|
|
||||||
- cron: "18 7 * * 0"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: ["typescript"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v1
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v1
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v1
|
|
||||||
@@ -30,3 +30,18 @@ jobs:
|
|||||||
git commit -am "Auto commit: Calculate translation coverage"
|
git commit -am "Auto commit: Calculate translation coverage"
|
||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
|
- name: Construct comment body
|
||||||
|
id: getCommentBody
|
||||||
|
run: |
|
||||||
|
body=$(npm run locales-coverage:description | grep '^[^>]')
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Update description with coverage
|
||||||
|
uses: kt3k/update-pr-description@v1.0.1
|
||||||
|
with:
|
||||||
|
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||||
|
pr_title: "chore: Update translations from Crowdin"
|
||||||
|
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: "Semantic PR title"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v3.0.0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
|
"proseWrap": "never",
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
1. Run `npm install` to install dependencies
|
1. Run `npm install` to install dependencies
|
||||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||||
|
|
||||||
> To keep `master` branch pointing to remote repository and make
|
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||||
> pull requests from branches on your fork. To do this, run:
|
|
||||||
>
|
>
|
||||||
> ```sh
|
> ```sh
|
||||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||||
@@ -25,3 +24,41 @@
|
|||||||
1. Tap on `Fork Sandbox`
|
1. Tap on `Fork Sandbox`
|
||||||
1. Write your code
|
1. Write your code
|
||||||
1. Commit and PR automatically
|
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 ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm i --no-optional
|
RUN npm i --no-optional
|
||||||
|
|
||||||
ARG REACT_APP_INCLUDE_GTAG=false
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div align="center" style="display:flex;flex-direction:column;">
|
<div align="center" style="display:flex;flex-direction:column;">
|
||||||
<a href="https://excalidraw.com">
|
<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>
|
</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>
|
<p>
|
||||||
<a href="https://twitter.com/Excalidraw">
|
<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">
|
<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">
|
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||||
</a>
|
</a>
|
||||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
|
||||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,13 +17,51 @@
|
|||||||
|
|
||||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
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
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
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
|
### 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
|
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.
|
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.
|
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.
|
- [Create React App](https://github.com/facebook/create-react-app)
|
||||||
|
|
||||||
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)
|
|
||||||
- [Rough.js](https://roughjs.com)
|
- [Rough.js](https://roughjs.com)
|
||||||
- [TypeScript](https://www.typescriptlang.org)
|
- [TypeScript](https://www.typescriptlang.org)
|
||||||
- [Vercel](https://vercel.com)
|
- [Vercel](https://vercel.com)
|
||||||
|
|
||||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
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>
|
|
||||||
|
|||||||
@@ -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` |
|
|
||||||
@@ -19,23 +19,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "5.28.0",
|
"@sentry/browser": "5.30.0",
|
||||||
"@sentry/integrations": "5.28.0",
|
"@sentry/integrations": "5.30.0",
|
||||||
"@testing-library/jest-dom": "5.11.6",
|
"@testing-library/jest-dom": "5.11.9",
|
||||||
"@testing-library/react": "11.2.2",
|
"@testing-library/react": "11.2.3",
|
||||||
"@types/jest": "26.0.16",
|
"@types/jest": "26.0.20",
|
||||||
"@types/nanoid": "2.1.0",
|
|
||||||
"@types/react": "17.0.0",
|
"@types/react": "17.0.0",
|
||||||
"@types/react-dom": "17.0.0",
|
"@types/react-dom": "17.0.0",
|
||||||
"@types/socket.io-client": "1.4.34",
|
"@types/socket.io-client": "1.4.35",
|
||||||
"browser-nativefs": "0.11.1",
|
"browser-nativefs": "0.12.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"firebase": "8.1.2",
|
"firebase": "8.2.3",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "2.1.11",
|
"nanoid": "3.1.20",
|
||||||
"node-sass": "4.14.1",
|
"node-sass": "4.14.1",
|
||||||
"open-color": "1.7.0",
|
"open-color": "1.8.0",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
@@ -47,19 +46,18 @@
|
|||||||
"react-scripts": "4.0.1",
|
"react-scripts": "4.0.1",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.3.1",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.0.5"
|
"typescript": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.1",
|
||||||
"asar": "3.0.3",
|
"eslint-config-prettier": "7.1.0",
|
||||||
"eslint-config-prettier": "7.0.0",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"eslint-plugin-prettier": "3.1.4",
|
"firebase-tools": "9.2.1",
|
||||||
"firebase-tools": "8.17.0",
|
"husky": "4.3.8",
|
||||||
"husky": "4.3.0",
|
|
||||||
"jest-canvas-mock": "2.3.0",
|
"jest-canvas-mock": "2.3.0",
|
||||||
"lint-staged": "10.5.3",
|
"lint-staged": "10.5.3",
|
||||||
"pepjs": "0.5.2",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
@@ -82,15 +80,16 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"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:docker": "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": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||||
"build:zip": "node ./scripts/build-version.js",
|
"build:version": "node ./scripts/build-version.js",
|
||||||
"build": "npm run build:app && npm run build:zip",
|
"build": "npm run build:app && npm run build:version",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix:code": "npm run test:code -- --fix",
|
"fix:code": "npm run test:code -- --fix",
|
||||||
"fix:other": "npm run prettier -- --write",
|
"fix:other": "npm run prettier -- --write",
|
||||||
"fix": "npm run fix:other && npm run fix:code",
|
"fix": "npm run fix:other && npm run fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -55,6 +55,8 @@
|
|||||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
|
<!-- Excalidraw version -->
|
||||||
|
<meta name="version" content="{version}" />
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="FG_Virgil.woff2"
|
href="FG_Virgil.woff2"
|
||||||
@@ -84,10 +86,10 @@
|
|||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<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
|
<script
|
||||||
async
|
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>
|
||||||
<script>
|
<script>
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
dataLayer.push(arguments);
|
dataLayer.push(arguments);
|
||||||
}
|
}
|
||||||
gtag("js", new Date());
|
gtag("js", new Date());
|
||||||
gtag("config", "UA-387204-13");
|
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -25,5 +25,6 @@
|
|||||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
"application/vnd.excalidraw+json": [".excalidraw"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"capture_links": "new_client"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 70 KiB |
@@ -2,36 +2,60 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const asar = require("asar");
|
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 commitHash = () => {
|
||||||
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
|
try {
|
||||||
date.getDate(),
|
return require("child_process")
|
||||||
)}`;
|
.execSync("git rev-parse --short HEAD")
|
||||||
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
|
.toString()
|
||||||
date.getSeconds(),
|
.trim();
|
||||||
)}`;
|
} catch {
|
||||||
return `${date_}-${time}`;
|
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(
|
const data = JSON.stringify(
|
||||||
{
|
{
|
||||||
asar: "excalidraw.asar",
|
version: getFullVersion(),
|
||||||
version: versionDate(now),
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(path.join("build", "version.json"), data);
|
fs.writeFileSync(versionFile, data);
|
||||||
|
|
||||||
(async () => {
|
// https://stackoverflow.com/a/14181136/8418
|
||||||
const src = "build/";
|
fs.readFile(indexFile, "utf8", (error, data) => {
|
||||||
const dest = path.join("build", `excalidraw.asar`);
|
if (error) {
|
||||||
|
return console.error(error);
|
||||||
|
}
|
||||||
|
const result = data.replace(/{version}/g, getFullVersion());
|
||||||
|
|
||||||
await asar.createPackage(src, dest);
|
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||||
})();
|
if (error) {
|
||||||
|
return console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const THRESSHOLD = 85;
|
||||||
|
|
||||||
|
const crowdinMap = {
|
||||||
|
"ar-SA": "en-ar",
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"ro-RO": "en-ro",
|
||||||
|
"ru-RU": "en-ru",
|
||||||
|
"sk-SK": "en-sk",
|
||||||
|
"sv-SE": "en-sv",
|
||||||
|
"tr-TR": "en-tr",
|
||||||
|
"uk-UA": "en-uk",
|
||||||
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-TW": "en-zhtw",
|
||||||
|
};
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
"ar-SA": "🇸🇦",
|
||||||
|
"bg-BG": "🇧🇬",
|
||||||
|
"ca-ES": "🇪🇸",
|
||||||
|
"de-DE": "🇩🇪",
|
||||||
|
"el-GR": "🇬🇷",
|
||||||
|
"es-ES": "🇪🇸",
|
||||||
|
"fa-IR": "🇮🇷",
|
||||||
|
"fi-FI": "🇫🇮",
|
||||||
|
"fr-FR": "🇫🇷",
|
||||||
|
"he-IL": "🇮🇱",
|
||||||
|
"hi-IN": "🇮🇳",
|
||||||
|
"hu-HU": "🇭🇺",
|
||||||
|
"id-ID": "🇮🇩",
|
||||||
|
"it-IT": "🇮🇹",
|
||||||
|
"ja-JP": "🇯🇵",
|
||||||
|
"ko-KR": "🇰🇷",
|
||||||
|
"my-MM": "🇲🇲",
|
||||||
|
"nb-NO": "🇳🇴",
|
||||||
|
"nl-NL": "🇳🇱",
|
||||||
|
"nn-NO": "🇳🇴",
|
||||||
|
"pa-IN": "🇮🇳",
|
||||||
|
"pl-PL": "🇵🇱",
|
||||||
|
"pt-BR": "🇧🇷",
|
||||||
|
"pt-PT": "🇵🇹",
|
||||||
|
"ro-RO": "🇷🇴",
|
||||||
|
"ru-RU": "🇷🇺",
|
||||||
|
"sk-SK": "🇸🇰",
|
||||||
|
"sv-SE": "🇸🇪",
|
||||||
|
"tr-TR": "🇹🇷",
|
||||||
|
"uk-UA": "🇺🇦",
|
||||||
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-TW": "🇹🇼",
|
||||||
|
};
|
||||||
|
|
||||||
|
const languages = {
|
||||||
|
"ar-SA": "العربية",
|
||||||
|
"bg-BG": "Български",
|
||||||
|
"ca-ES": "Català",
|
||||||
|
"de-DE": "Deutsch",
|
||||||
|
"el-GR": "Ελληνικά",
|
||||||
|
"es-ES": "Español",
|
||||||
|
"fa-IR": "فارسی",
|
||||||
|
"fi-FI": "Suomi",
|
||||||
|
"fr-FR": "Français",
|
||||||
|
"he-IL": "עברית",
|
||||||
|
"hi-IN": "हिन्दी",
|
||||||
|
"hu-HU": "Magyar",
|
||||||
|
"id-ID": "Bahasa Indonesia",
|
||||||
|
"it-IT": "Italiano",
|
||||||
|
"ja-JP": "日本語",
|
||||||
|
"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",
|
||||||
|
"ro-RO": "Română",
|
||||||
|
"ru-RU": "Русский",
|
||||||
|
"sk-SK": "Slovenčina",
|
||||||
|
"sv-SE": "Svenska",
|
||||||
|
"tr-TR": "Türkçe",
|
||||||
|
"uk-UA": "Українська",
|
||||||
|
"zh-CN": "简体中文",
|
||||||
|
"zh-TW": "繁體中文",
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentages = fs.readFileSync(
|
||||||
|
`${__dirname}/../src/locales/percentages.json`,
|
||||||
|
);
|
||||||
|
const rowData = JSON.parse(percentages);
|
||||||
|
|
||||||
|
const coverages = Object.entries(rowData)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
|
||||||
|
|
||||||
|
const boldIf = (text, condition) => (condition ? `**${text}**` : text);
|
||||||
|
|
||||||
|
const printHeader = () => {
|
||||||
|
let result = "| | Flag | Locale | % |\n";
|
||||||
|
result += "| :--: | :--: | -- | :--: |";
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printRow = (id, locale, coverage) => {
|
||||||
|
const isOver = coverage >= THRESSHOLD;
|
||||||
|
let result = `| ${isOver ? id : "..."} | `;
|
||||||
|
result += `${locale in flags ? flags[locale] : ""} | `;
|
||||||
|
const language = locale in languages ? languages[locale] : locale;
|
||||||
|
if (locale in crowdinMap && crowdinMap[locale]) {
|
||||||
|
result += `[${boldIf(
|
||||||
|
language,
|
||||||
|
isOver,
|
||||||
|
)}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
|
||||||
|
} else {
|
||||||
|
result += `${boldIf(language, isOver)} | `;
|
||||||
|
}
|
||||||
|
result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
|
||||||
|
);
|
||||||
|
console.info("\n\r");
|
||||||
|
console.info(printHeader());
|
||||||
|
let index = 1;
|
||||||
|
for (const coverage in coverages) {
|
||||||
|
if (coverage === "en") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.info(printRow(index, coverage, coverages[coverage]));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
console.info("\n\r");
|
||||||
|
console.info("\\* Languages in **bold** are going to appear on production.");
|
||||||
@@ -3,7 +3,6 @@ import { getSelectedElements } from "../scene";
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
import { Library } from "../data/library";
|
import { Library } from "../data/library";
|
||||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@@ -16,7 +15,6 @@ export const actionAddToLibrary = register({
|
|||||||
Library.loadLibrary().then((items) => {
|
Library.loadLibrary().then((items) => {
|
||||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_LIBRARY, "add");
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
contextMenuOrder: 6,
|
contextMenuOrder: 6,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { KEYS } from "../keys";
|
import { alignElements, Alignment } from "../align";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
@@ -10,14 +8,15 @@ import {
|
|||||||
CenterHorizontallyIcon,
|
CenterHorizontallyIcon,
|
||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { alignElements, Alignment } from "../align";
|
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -44,7 +43,6 @@ const alignSelectedElements = (
|
|||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "top");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -74,7 +72,6 @@ export const actionAlignTop = register({
|
|||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -104,7 +101,6 @@ export const actionAlignBottom = register({
|
|||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "left");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -134,7 +130,6 @@ export const actionAlignLeft = register({
|
|||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "right");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -164,7 +159,6 @@ export const actionAlignRight = register({
|
|||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -190,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
|||||||
@@ -1,34 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { getNormalizedZoom } from "../scene";
|
|
||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element";
|
import { t } from "../i18n";
|
||||||
import { getNewZoom } from "../scene/zoom";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
import { getNewZoom } from "../scene/zoom";
|
||||||
import colors from "../colors";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
if (value !== appState.viewBackgroundColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"canvas color",
|
|
||||||
colors.canvasBackground.includes(value)
|
|
||||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, viewBackgroundColor: value },
|
appState: { ...appState, viewBackgroundColor: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -51,7 +41,6 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
perform: (elements, appState: AppState) => {
|
||||||
trackEvent(EVENT_ACTION, "clear canvas");
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
@@ -65,6 +54,7 @@ export const actionClearCanvas = register({
|
|||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
|
pasteDialog: appState.pasteDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -93,9 +83,9 @@ export const actionZoomIn = register({
|
|||||||
const zoom = getNewZoom(
|
const zoom = getNewZoom(
|
||||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
);
|
);
|
||||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -126,10 +116,10 @@ export const actionZoomOut = register({
|
|||||||
const zoom = getNewZoom(
|
const zoom = getNewZoom(
|
||||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
);
|
);
|
||||||
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -157,14 +147,18 @@ export const actionZoomOut = register({
|
|||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
|
zoom: getNewZoom(
|
||||||
x: appState.width / 2,
|
1 as NormalizedZoomValue,
|
||||||
y: appState.height / 2,
|
appState.zoom,
|
||||||
}),
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{
|
||||||
|
x: appState.width / 2,
|
||||||
|
y: appState.height / 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -204,38 +198,61 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zoomToFitElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
zoomToSelection: boolean,
|
||||||
|
) => {
|
||||||
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
|
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||||
|
|
||||||
|
const commonBounds =
|
||||||
|
zoomToSelection && selectedElements.length > 0
|
||||||
|
? getCommonBounds(selectedElements)
|
||||||
|
: getCommonBounds(nonDeletedElements);
|
||||||
|
|
||||||
|
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
});
|
||||||
|
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
||||||
|
left: appState.offsetLeft,
|
||||||
|
top: appState.offsetTop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
|
const centerX = (x1 + x2) / 2;
|
||||||
|
const centerY = (y1 + y2) / 2;
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...centerScrollOn({
|
||||||
|
scenePoint: { x: centerX, y: centerY },
|
||||||
|
viewportDimensions: {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
},
|
||||||
|
zoom: newZoom,
|
||||||
|
}),
|
||||||
|
zoom: newZoom,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionZoomToSelected = register({
|
||||||
|
name: "zoomToSelection",
|
||||||
|
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.code === CODES.TWO &&
|
||||||
|
event.shiftKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
});
|
||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||||
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
|
||||||
const commonBounds = getCommonBounds(nonDeletedElements);
|
|
||||||
|
|
||||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
});
|
|
||||||
const newZoom = getNewZoom(zoomValue, appState.zoom);
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
|
||||||
const centerX = (x1 + x2) / 2;
|
|
||||||
const centerY = (y1 + y2) / 2;
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", "fit", newZoom.value * 100);
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...centerScrollOn({
|
|
||||||
scenePoint: { x: centerX, y: centerY },
|
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
},
|
|
||||||
zoom: newZoom,
|
|
||||||
}),
|
|
||||||
zoom: newZoom,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
contextItemLabel: "labels.delete",
|
||||||
contextMenuOrder: 3,
|
contextMenuOrder: 999999,
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { CODES } from "../keys";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
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 { getShortcutKey } from "../utils";
|
||||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -40,7 +39,6 @@ const distributeSelectedElements = (
|
|||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: distributeSelectedElements(elements, appState, {
|
elements: distributeSelectedElements(elements, appState, {
|
||||||
@@ -69,7 +67,6 @@ export const distributeHorizontally = register({
|
|||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: distributeSelectedElements(elements, appState, {
|
elements: distributeSelectedElements(elements, appState, {
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { load, save, saveAs } from "../components/icons";
|
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import "../components/ToolIcon.scss";
|
||||||
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { muteFSAbortError } from "../utils";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "title");
|
trackEvent("change", "title");
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
@@ -54,13 +55,20 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<label title={t("labels.exportEmbedScene_details")}>
|
<label style={{ display: "flex" }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={appState.exportEmbedScene}
|
checked={appState.exportEmbedScene}
|
||||||
onChange={(event) => updateData(event.target.checked)}
|
onChange={(event) => updateData(event.target.checked)}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
|
<Tooltip
|
||||||
|
label={t("labels.exportEmbedScene_details")}
|
||||||
|
position="above"
|
||||||
|
long={true}
|
||||||
|
>
|
||||||
|
<div className="TooltipIcon">{questionCircle}</div>
|
||||||
|
</Tooltip>
|
||||||
</label>
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -90,7 +98,6 @@ export const actionSaveScene = register({
|
|||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
trackEvent(EVENT_IO, "save");
|
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
@@ -121,7 +128,6 @@ export const actionSaveAsScene = register({
|
|||||||
...appState,
|
...appState,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_IO, "save as");
|
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
@@ -149,18 +155,29 @@ export const actionSaveAsScene = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: (
|
perform: async (elements, appState) => {
|
||||||
elements,
|
try {
|
||||||
appState,
|
const {
|
||||||
{ elements: loadedElements, appState: loadedAppState, error },
|
elements: loadedElements,
|
||||||
) => ({
|
appState: loadedAppState,
|
||||||
elements: loadedElements,
|
} = await loadFromJSON(appState);
|
||||||
appState: {
|
return {
|
||||||
...loadedAppState,
|
elements: loadedElements,
|
||||||
errorMessage: error,
|
appState: loadedAppState,
|
||||||
},
|
commitToHistory: true,
|
||||||
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 }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -168,16 +185,7 @@ export const actionLoadScene = register({
|
|||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={updateData}
|
||||||
loadFromJSON(appState)
|
|
||||||
.then(({ elements, appState }) => {
|
|
||||||
updateData({ elements, appState });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
updateData({ error: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,11 +118,14 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked) {
|
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!appState.elementLocked || !multiPointElement) {
|
if (
|
||||||
|
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||||
|
!multiPointElement
|
||||||
|
) {
|
||||||
resetCursor();
|
resetCursor();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -130,7 +133,8 @@ export const actionFinalize = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
elementType:
|
||||||
appState.elementLocked && multiPointElement
|
(appState.elementLocked || appState.elementType === "draw") &&
|
||||||
|
multiPointElement
|
||||||
? appState.elementType
|
? appState.elementType
|
||||||
: "selection",
|
: "selection",
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
@@ -139,7 +143,9 @@ export const actionFinalize = register({
|
|||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement && !appState.elementLocked
|
multiPointElement &&
|
||||||
|
!appState.elementLocked &&
|
||||||
|
appState.elementType !== "draw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { register } from "./register";
|
|||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { HelpIcon } from "../components/HelpIcon";
|
import { HelpIcon } from "../components/HelpIcon";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
@@ -72,17 +71,16 @@ export const actionFullScreen = register({
|
|||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showShortcutsDialog: true,
|
showHelpDialog: true,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
|
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
|
||||||
),
|
),
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "../components/Avatar";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Collaborator } from "../types";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
import { Collaborator } from "../types";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
const point = value as Collaborator["pointer"];
|
const point = value as Collaborator["pointer"];
|
||||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
|
||||||
if (!point) {
|
if (!point) {
|
||||||
return { appState, commitToHistory: false };
|
return { appState, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,53 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getLanguage } from "../i18n";
|
import { AppState } from "../../src/types";
|
||||||
import {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
TextAlign,
|
|
||||||
FontFamily,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
Arrowhead,
|
|
||||||
} from "../element/types";
|
|
||||||
import {
|
|
||||||
getCommonAttributeOfSelectedElements,
|
|
||||||
isSomeElementSelected,
|
|
||||||
getTargetElements,
|
|
||||||
canChangeSharpness,
|
|
||||||
canHaveArrowheads,
|
|
||||||
} from "../scene";
|
|
||||||
import { ButtonSelect } from "../components/ButtonSelect";
|
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
|
import { ButtonSelect } from "../components/ButtonSelect";
|
||||||
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
import {
|
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,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
|
EdgeRoundIcon,
|
||||||
|
EdgeSharpIcon,
|
||||||
|
FillCrossHatchIcon,
|
||||||
|
FillHachureIcon,
|
||||||
|
FillSolidIcon,
|
||||||
|
SloppinessArchitectIcon,
|
||||||
|
SloppinessArtistIcon,
|
||||||
|
SloppinessCartoonistIcon,
|
||||||
|
StrokeStyleDashedIcon,
|
||||||
|
StrokeStyleDottedIcon,
|
||||||
|
StrokeStyleSolidIcon,
|
||||||
|
StrokeWidthIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||||
import colors from "../colors";
|
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 = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -92,15 +89,6 @@ const getFormValue = function <T>(
|
|||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
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 {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -132,16 +120,6 @@ export const actionChangeStrokeColor = register({
|
|||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
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 {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -173,7 +151,6 @@ export const actionChangeBackgroundColor = register({
|
|||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "fill", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -223,7 +200,6 @@ export const actionChangeFillStyle = register({
|
|||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -286,7 +262,6 @@ export const actionChangeStrokeWidth = register({
|
|||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -335,7 +310,6 @@ export const actionChangeSloppiness = register({
|
|||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "style", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -383,7 +357,6 @@ export const actionChangeStrokeStyle = register({
|
|||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -580,7 +553,6 @@ export const actionChangeSharpness = register({
|
|||||||
const shouldUpdateForLinearElements = targetElements.length
|
const shouldUpdateForLinearElements = targetElements.length
|
||||||
? targetElements.every(isLinearElement)
|
? targetElements.every(isLinearElement)
|
||||||
: isLinearElementType(appState.elementType);
|
: isLinearElementType(appState.elementType);
|
||||||
trackEvent(EVENT_CHANGE, "edge", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -642,12 +614,6 @@ export const actionChangeArrowhead = register({
|
|||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isLinearElement(el)) {
|
if (isLinearElement(el)) {
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
`arrowhead ${value.position}`,
|
|
||||||
value.type || "none",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { position, type } = value;
|
const { position, type } = value;
|
||||||
|
|
||||||
if (position === "start") {
|
if (position === "start") {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,10 @@ export const actionCopyStyles = register({
|
|||||||
copiedStyles = JSON.stringify(element);
|
copiedStyles = JSON.stringify(element);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
toastMessage: t("toast.copyStyles"),
|
||||||
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ export type ShortcutName =
|
|||||||
| "copyAsSvg"
|
| "copyAsSvg"
|
||||||
| "group"
|
| "group"
|
||||||
| "ungroup"
|
| "ungroup"
|
||||||
| "toggleGridMode"
|
| "gridMode"
|
||||||
| "toggleStats"
|
| "zenMode"
|
||||||
|
| "stats"
|
||||||
| "addToLibrary";
|
| "addToLibrary";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
@@ -33,7 +34,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
delete: [getShortcutKey("Del")],
|
delete: [getShortcutKey("Del")],
|
||||||
duplicateSelection: [
|
duplicateSelection: [
|
||||||
getShortcutKey("CtrlOrCmd+D"),
|
getShortcutKey("CtrlOrCmd+D"),
|
||||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||||
],
|
],
|
||||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||||
@@ -51,8 +52,9 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
copyAsSvg: [],
|
copyAsSvg: [],
|
||||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||||
toggleGridMode: [getShortcutKey("CtrlOrCmd+'")],
|
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||||
toggleStats: [],
|
zenMode: [getShortcutKey("Alt+Z")],
|
||||||
|
stats: [],
|
||||||
addToLibrary: [],
|
addToLibrary: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export type ActionName =
|
|||||||
| "zoomOut"
|
| "zoomOut"
|
||||||
| "resetZoom"
|
| "resetZoom"
|
||||||
| "zoomToFit"
|
| "zoomToFit"
|
||||||
|
| "zoomToSelection"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
export const EVENT_ACTION = "action";
|
export const trackEvent =
|
||||||
export const EVENT_ALIGN = "align";
|
typeof process !== "undefined" &&
|
||||||
export const EVENT_CHANGE = "change";
|
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||||
export const EVENT_DIALOG = "dialog";
|
typeof window !== "undefined" &&
|
||||||
export const EVENT_EXIT = "exit";
|
window.gtag
|
||||||
export const EVENT_IO = "io";
|
? (category: string, name: string, label?: string, value?: number) => {
|
||||||
export const EVENT_LAYER = "layer";
|
window.gtag("event", name, {
|
||||||
export const EVENT_LIBRARY = "library";
|
event_category: category,
|
||||||
export const EVENT_LOAD = "load";
|
event_label: label,
|
||||||
export const EVENT_SHAPE = "shape";
|
value,
|
||||||
export const EVENT_SHARE = "share";
|
});
|
||||||
export const EVENT_MAGIC = "magic";
|
}
|
||||||
|
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||||
export const trackEvent = window.gtag
|
? (category: string, name: string, label?: string, value?: number) => {}
|
||||||
? (category: string, name: string, label?: string, value?: number) => {
|
: (category: string, name: string, label?: string, value?: number) => {
|
||||||
window.gtag("event", name, {
|
// Uncomment the next line to track locally
|
||||||
event_category: category,
|
// console.info("Track Event", category, name, label, value);
|
||||||
event_label: label,
|
};
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
: (category: string, name: string, label?: string, value?: number) => {
|
|
||||||
console.info("Track Event", category, name, label, value);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
|
||||||
import { getDateTime } from "./utils";
|
|
||||||
import { t } from "./i18n";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
||||||
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -14,66 +14,64 @@ export const getDefaultAppState = (): Omit<
|
|||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
appearance: "light",
|
appearance: "light",
|
||||||
isLoading: false,
|
collaborators: new Map(),
|
||||||
errorMessage: null,
|
currentChartType: "bar",
|
||||||
|
currentItemBackgroundColor: "transparent",
|
||||||
|
currentItemEndArrowhead: "arrow",
|
||||||
|
currentItemFillStyle: "hachure",
|
||||||
|
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||||
|
currentItemLinearStrokeSharpness: "round",
|
||||||
|
currentItemOpacity: 100,
|
||||||
|
currentItemRoughness: 1,
|
||||||
|
currentItemStartArrowhead: null,
|
||||||
|
currentItemStrokeColor: oc.black,
|
||||||
|
currentItemStrokeSharpness: "sharp",
|
||||||
|
currentItemStrokeStyle: "solid",
|
||||||
|
currentItemStrokeWidth: 1,
|
||||||
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
cursorButton: "up",
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
resizingElement: null,
|
|
||||||
multiElement: null,
|
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
startBoundElement: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
elementType: "selection",
|
|
||||||
elementLocked: false,
|
elementLocked: false,
|
||||||
|
elementType: "selection",
|
||||||
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
shouldAddWatermark: false,
|
fileHandle: null,
|
||||||
currentItemStrokeColor: oc.black,
|
gridSize: null,
|
||||||
currentItemBackgroundColor: "transparent",
|
height: window.innerHeight,
|
||||||
currentItemFillStyle: "hachure",
|
|
||||||
currentItemStrokeWidth: 1,
|
|
||||||
currentItemStrokeStyle: "solid",
|
|
||||||
currentItemRoughness: 1,
|
|
||||||
currentItemOpacity: 100,
|
|
||||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
|
||||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
|
||||||
currentItemStrokeSharpness: "sharp",
|
|
||||||
currentItemLinearStrokeSharpness: "round",
|
|
||||||
currentItemStartArrowhead: null,
|
|
||||||
currentItemEndArrowhead: "arrow",
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
scrollX: 0 as FlooredNumber,
|
|
||||||
scrollY: 0 as FlooredNumber,
|
|
||||||
cursorX: 0,
|
|
||||||
cursorY: 0,
|
|
||||||
cursorButton: "up",
|
|
||||||
scrolledOutside: false,
|
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
|
isLibraryOpen: false,
|
||||||
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
selectionElement: null,
|
|
||||||
zoom: {
|
|
||||||
value: 1 as NormalizedZoomValue,
|
|
||||||
translation: { x: 0, y: 0 },
|
|
||||||
},
|
|
||||||
openMenu: null,
|
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
selectedElementIds: {},
|
multiElement: null,
|
||||||
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
|
openMenu: null,
|
||||||
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
shouldCacheIgnoreZoom: false,
|
resizingElement: null,
|
||||||
showShortcutsDialog: false,
|
scrolledOutside: false,
|
||||||
suggestedBindings: [],
|
scrollX: 0 as FlooredNumber,
|
||||||
zenModeEnabled: false,
|
scrollY: 0 as FlooredNumber,
|
||||||
gridSize: null,
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
width: window.innerWidth,
|
selectionElement: null,
|
||||||
height: window.innerHeight,
|
shouldAddWatermark: false,
|
||||||
isLibraryOpen: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
fileHandle: null,
|
showHelpDialog: false,
|
||||||
collaborators: new Map(),
|
|
||||||
showStats: 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 } },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,26 +91,25 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||||
) => config)({
|
) => config)({
|
||||||
appearance: { browser: true, export: false },
|
appearance: { browser: true, export: false },
|
||||||
|
collaborators: { browser: false, export: false },
|
||||||
|
currentChartType: { browser: true, export: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false },
|
||||||
|
currentItemEndArrowhead: { browser: true, export: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false },
|
||||||
currentItemFontSize: { browser: true, export: false },
|
currentItemFontSize: { browser: true, export: false },
|
||||||
|
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||||
currentItemOpacity: { browser: true, export: false },
|
currentItemOpacity: { browser: true, export: false },
|
||||||
currentItemRoughness: { browser: true, export: false },
|
currentItemRoughness: { browser: true, export: false },
|
||||||
|
currentItemStartArrowhead: { browser: true, export: false },
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
currentItemStrokeColor: { browser: true, export: false },
|
||||||
|
currentItemStrokeSharpness: { browser: true, export: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
currentItemStrokeStyle: { browser: true, export: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
currentItemStrokeWidth: { browser: true, export: false },
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
currentItemTextAlign: { browser: true, export: false },
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
|
||||||
cursorButton: { browser: true, export: false },
|
cursorButton: { browser: true, export: false },
|
||||||
cursorX: { browser: true, export: false },
|
|
||||||
cursorY: { browser: true, export: false },
|
|
||||||
draggingElement: { browser: false, export: false },
|
draggingElement: { browser: false, export: false },
|
||||||
editingElement: { browser: false, export: false },
|
editingElement: { browser: false, export: false },
|
||||||
startBoundElement: { browser: false, export: false },
|
|
||||||
editingGroupId: { browser: true, export: false },
|
editingGroupId: { browser: true, export: false },
|
||||||
editingLinearElement: { browser: false, export: false },
|
editingLinearElement: { browser: false, export: false },
|
||||||
elementLocked: { browser: true, export: false },
|
elementLocked: { browser: true, export: false },
|
||||||
@@ -120,6 +117,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
errorMessage: { browser: false, export: false },
|
errorMessage: { browser: false, export: false },
|
||||||
exportBackground: { browser: true, export: false },
|
exportBackground: { browser: true, export: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
exportEmbedScene: { browser: true, export: false },
|
||||||
|
fileHandle: { browser: false, export: false },
|
||||||
gridSize: { browser: true, export: true },
|
gridSize: { browser: true, export: true },
|
||||||
height: { browser: false, export: false },
|
height: { browser: false, export: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
isBindingEnabled: { browser: false, export: false },
|
||||||
@@ -130,7 +128,10 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
lastPointerDownWith: { browser: true, export: false },
|
lastPointerDownWith: { browser: true, export: false },
|
||||||
multiElement: { browser: false, export: false },
|
multiElement: { browser: false, export: false },
|
||||||
name: { browser: true, export: false },
|
name: { browser: true, export: false },
|
||||||
|
offsetLeft: { browser: false, export: false },
|
||||||
|
offsetTop: { browser: false, export: false },
|
||||||
openMenu: { browser: true, export: false },
|
openMenu: { browser: true, export: false },
|
||||||
|
pasteDialog: { browser: false, export: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false },
|
||||||
resizingElement: { browser: false, export: false },
|
resizingElement: { browser: false, export: false },
|
||||||
scrolledOutside: { browser: true, export: false },
|
scrolledOutside: { browser: true, export: false },
|
||||||
@@ -141,17 +142,15 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
selectionElement: { browser: false, export: false },
|
selectionElement: { browser: false, export: false },
|
||||||
shouldAddWatermark: { browser: true, export: false },
|
shouldAddWatermark: { browser: true, export: false },
|
||||||
shouldCacheIgnoreZoom: { 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 },
|
suggestedBindings: { browser: false, export: false },
|
||||||
|
toastMessage: { browser: false, export: false },
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
viewBackgroundColor: { browser: true, export: true },
|
||||||
width: { browser: false, export: false },
|
width: { browser: false, export: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
zenModeEnabled: { browser: true, export: false },
|
||||||
zoom: { browser: true, export: false },
|
zoom: { browser: true, export: false },
|
||||||
offsetTop: { browser: false, export: false },
|
|
||||||
offsetLeft: { browser: false, export: false },
|
|
||||||
fileHandle: { browser: false, export: false },
|
|
||||||
collaborators: { browser: false, export: false },
|
|
||||||
showStats: { browser: true, export: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||||
@@ -166,11 +165,6 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
|||||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (!propConfig) {
|
|
||||||
console.error(
|
|
||||||
`_clearAppStateForStorage: appState key "${key}" config doesn't exist for "${exportType}" export type`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||||
stateForExport[key] = appState[key];
|
stateForExport[key] = appState[key];
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
|
||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||||
import { newElement, newTextElement, newLinearElement } from "./element";
|
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
import { ExcalidrawElement } from "./element/types";
|
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
|
|
||||||
|
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||||
|
|
||||||
const BAR_WIDTH = 32;
|
const BAR_WIDTH = 32;
|
||||||
const BAR_GAP = 12;
|
const BAR_GAP = 12;
|
||||||
const BAR_HEIGHT = 256;
|
const BAR_HEIGHT = 256;
|
||||||
|
const GRID_OPACITY = 50;
|
||||||
|
|
||||||
export interface Spreadsheet {
|
export interface Spreadsheet {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -19,15 +21,15 @@ export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
|||||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||||
|
|
||||||
type ParseSpreadsheetResult =
|
type ParseSpreadsheetResult =
|
||||||
| { type: typeof NOT_SPREADSHEET }
|
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||||
|
|
||||||
const tryParseNumber = (s: string): number | null => {
|
const tryParseNumber = (s: string): number | null => {
|
||||||
const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
|
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parseFloat(match[1]);
|
return parseFloat(match[1].replace(/,/g, ""));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||||
@@ -37,12 +39,12 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
const numCols = cells[0].length;
|
const numCols = cells[0].length;
|
||||||
|
|
||||||
if (numCols > 2) {
|
if (numCols > 2) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (numCols === 1) {
|
if (numCols === 1) {
|
||||||
if (!isNumericColumn(cells, 0)) {
|
if (!isNumericColumn(cells, 0)) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||||
@@ -51,7 +53,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (values.length < 2) {
|
if (values.length < 2) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -67,7 +69,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||||
|
|
||||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||||
@@ -75,7 +77,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
const rows = hasHeader ? cells.slice(1) : cells;
|
||||||
|
|
||||||
if (rows.length < 2) {
|
if (rows.length < 2) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,13 +106,13 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||||
// For now we only accept 2 columns with an optional header
|
// For now we only accept 2 columns with an optional header
|
||||||
|
|
||||||
// Check for tab separeted values
|
// Check for tab separated values
|
||||||
let lines = text
|
let lines = text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim().split("\t"));
|
.map((line) => line.trim().split("\t"));
|
||||||
|
|
||||||
// Check for comma separeted files
|
// Check for comma separated files
|
||||||
if (lines.length && lines[0].length !== 2) {
|
if (lines.length && lines[0].length !== 2) {
|
||||||
lines = text
|
lines = text
|
||||||
.trim()
|
.trim()
|
||||||
@@ -119,14 +121,17 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const numColsFirstLine = lines[0].length;
|
const numColsFirstLine = lines[0].length;
|
||||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||||
|
|
||||||
if (!isSpreadsheet) {
|
if (!isSpreadsheet) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return {
|
||||||
|
type: NOT_SPREADSHEET,
|
||||||
|
reason: "All rows don't have same number of columns",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = tryParseCells(lines);
|
const result = tryParseCells(lines);
|
||||||
@@ -136,111 +141,48 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
return transposedResults;
|
return transposedResults;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
const bgColors = colors.elementBackground.slice(
|
||||||
export const renderSpreadsheet = (
|
2,
|
||||||
|
colors.elementBackground.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put all the common properties here so when the whole chart is selected
|
||||||
|
// the properties dialog shows the correct selected values
|
||||||
|
const commonProps = {
|
||||||
|
fillStyle: "hachure",
|
||||||
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
opacity: 100,
|
||||||
|
roughness: 1,
|
||||||
|
strokeColor: colors.elementStroke[0],
|
||||||
|
strokeSharpness: "sharp",
|
||||||
|
strokeStyle: "solid",
|
||||||
|
strokeWidth: 1,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
|
const chartWidth =
|
||||||
|
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||||
|
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||||
|
return { chartWidth, chartHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartXLabels = (
|
||||||
spreadsheet: Spreadsheet,
|
spreadsheet: Spreadsheet,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): ExcalidrawElement[] => {
|
groupId: string,
|
||||||
const values = spreadsheet.values;
|
backgroundColor: string,
|
||||||
const max = Math.max(...values);
|
): ChartElements => {
|
||||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
return (
|
||||||
const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
|
|
||||||
const maxColors = colors.elementBackground.length;
|
|
||||||
const bgColors = colors.elementBackground.slice(2, maxColors);
|
|
||||||
|
|
||||||
// Put all the common properties here so when the whole chart is selected
|
|
||||||
// the properties dialog shows the correct selected values
|
|
||||||
const commonProps = {
|
|
||||||
backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
|
|
||||||
fillStyle: "hachure",
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
groupIds: [randomId()],
|
|
||||||
opacity: 100,
|
|
||||||
roughness: 1,
|
|
||||||
strokeColor: colors.elementStroke[0],
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
strokeWidth: 1,
|
|
||||||
verticalAlign: "middle",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const minYLabel = newTextElement({
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_GAP,
|
|
||||||
text: "0",
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxYLabel = newTextElement({
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
|
||||||
text: max.toLocaleString(),
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
const xAxisLine = newLinearElement({
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
...commonProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
const yAxisLine = newLinearElement({
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[0, -chartHeight],
|
|
||||||
],
|
|
||||||
...commonProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxValueLine = newLinearElement({
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y: y - BAR_HEIGHT - BAR_GAP,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
...commonProps,
|
|
||||||
strokeStyle: "dotted",
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const bars = values.map((value, index) => {
|
|
||||||
const barHeight = (value / max) * BAR_HEIGHT;
|
|
||||||
return newElement({
|
|
||||||
...commonProps,
|
|
||||||
type: "rectangle",
|
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
|
||||||
y: y - barHeight - BAR_GAP,
|
|
||||||
width: BAR_WIDTH,
|
|
||||||
height: barHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const xLabels =
|
|
||||||
spreadsheet.labels?.map((label, index) => {
|
spreadsheet.labels?.map((label, index) => {
|
||||||
return newTextElement({
|
return newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
...commonProps,
|
...commonProps,
|
||||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||||
@@ -251,29 +193,287 @@ export const renderSpreadsheet = (
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
verticalAlign: "top",
|
verticalAlign: "top",
|
||||||
});
|
});
|
||||||
}) || [];
|
}) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartYLabels = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
const minYLabel = newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
x: x - BAR_GAP,
|
||||||
|
y: y - BAR_GAP,
|
||||||
|
text: "0",
|
||||||
|
textAlign: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxYLabel = newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
x: x - BAR_GAP,
|
||||||
|
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||||
|
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||||
|
textAlign: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
return [minYLabel, maxYLabel];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartLines = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||||
|
const xLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
width: chartWidth,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const yLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: chartHeight,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, -chartHeight],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y: y - BAR_HEIGHT - BAR_GAP,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
strokeStyle: "dotted",
|
||||||
|
width: chartWidth,
|
||||||
|
opacity: GRID_OPACITY,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return [xLine, yLine, maxLine];
|
||||||
|
};
|
||||||
|
|
||||||
|
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||||
|
const chartBaseElements = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
debug?: boolean,
|
||||||
|
): ChartElements => {
|
||||||
|
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||||
|
|
||||||
const title = spreadsheet.title
|
const title = spreadsheet.title
|
||||||
? newTextElement({
|
? newTextElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
...commonProps,
|
...commonProps,
|
||||||
text: spreadsheet.title,
|
text: spreadsheet.title,
|
||||||
x: x + chartWidth / 2,
|
x: x + chartWidth / 2,
|
||||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
|
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
|
const debugRect = debug
|
||||||
|
? newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "rectangle",
|
||||||
|
x,
|
||||||
|
y: y - chartHeight,
|
||||||
|
width: chartWidth,
|
||||||
|
height: chartHeight,
|
||||||
|
strokeColor: colors.elementStroke[0],
|
||||||
|
fillStyle: "solid",
|
||||||
|
opacity: 6,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
title,
|
...(debugRect ? [debugRect] : []),
|
||||||
...bars,
|
...(title ? [title] : []),
|
||||||
...xLabels,
|
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
xAxisLine,
|
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
yAxisLine,
|
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
maxValueLine,
|
];
|
||||||
minYLabel,
|
};
|
||||||
maxYLabel,
|
|
||||||
].filter((element) => element !== null) as ExcalidrawElement[];
|
const chartTypeBar = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
const max = Math.max(...spreadsheet.values);
|
||||||
|
const groupId = randomId();
|
||||||
|
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||||
|
|
||||||
|
const bars = spreadsheet.values.map((value, index) => {
|
||||||
|
const barHeight = (value / max) * BAR_HEIGHT;
|
||||||
|
return newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "rectangle",
|
||||||
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
||||||
|
y: y - barHeight - BAR_GAP,
|
||||||
|
width: BAR_WIDTH,
|
||||||
|
height: barHeight,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...bars,
|
||||||
|
...chartBaseElements(
|
||||||
|
spreadsheet,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
groupId,
|
||||||
|
backgroundColor,
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartTypeLine = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
const max = Math.max(...spreadsheet.values);
|
||||||
|
const groupId = randomId();
|
||||||
|
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const points = [];
|
||||||
|
for (const value of spreadsheet.values) {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP);
|
||||||
|
const cy = -(value / max) * BAR_HEIGHT;
|
||||||
|
points.push([cx, cy]);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxX = Math.max(...points.map((element) => element[0]));
|
||||||
|
const maxY = Math.max(...points.map((element) => element[1]));
|
||||||
|
const minX = Math.min(...points.map((element) => element[0]));
|
||||||
|
const minY = Math.min(...points.map((element) => element[1]));
|
||||||
|
|
||||||
|
const line = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||||
|
y: y - BAR_GAP,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: maxY - minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
strokeWidth: 2,
|
||||||
|
points: points as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dots = spreadsheet.values.map((value, index) => {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||||
|
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
||||||
|
return newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
fillStyle: "solid",
|
||||||
|
strokeWidth: 2,
|
||||||
|
type: "ellipse",
|
||||||
|
x: x + cx + BAR_WIDTH / 2,
|
||||||
|
y: y + cy - BAR_GAP * 2,
|
||||||
|
width: BAR_GAP,
|
||||||
|
height: BAR_GAP,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = spreadsheet.values.map((value, index) => {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||||
|
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
||||||
|
return newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||||
|
y: y - cy,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: cy,
|
||||||
|
strokeStyle: "dotted",
|
||||||
|
opacity: GRID_OPACITY,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, cy],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...chartBaseElements(
|
||||||
|
spreadsheet,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
groupId,
|
||||||
|
backgroundColor,
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||||
|
),
|
||||||
|
line,
|
||||||
|
...lines,
|
||||||
|
...dots,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderSpreadsheet = (
|
||||||
|
chartType: string,
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
if (chartType === "line") {
|
||||||
|
return chartTypeLine(spreadsheet, x, y);
|
||||||
|
}
|
||||||
|
return chartTypeBar(spreadsheet, x, y);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState, Zoom } from "../types";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
import {
|
import {
|
||||||
hasBackground,
|
|
||||||
hasStroke,
|
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
hasText,
|
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
|
hasBackground,
|
||||||
|
hasStroke,
|
||||||
|
hasText,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { AppState, Zoom } from "../types";
|
||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import useIsMobile from "../is-mobile";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -164,9 +163,9 @@ export const ShapesSwitcher = ({
|
|||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const letter = typeof key === "string" ? key : key[0];
|
||||||
const shortcut = `${capitalizeString(letter)} ${t(
|
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||||
"shortcutsDialog.or",
|
index + 1
|
||||||
)} ${index + 1}`;
|
}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
@@ -181,7 +180,6 @@ export const ShapesSwitcher = ({
|
|||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
trackEvent(EVENT_SHAPE, value, "toolbar");
|
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
@@ -203,9 +201,6 @@ export const ShapesSwitcher = ({
|
|||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isLibraryOpen) {
|
|
||||||
trackEvent(EVENT_DIALOG, "library");
|
|
||||||
}
|
|
||||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,181 +1,164 @@
|
|||||||
|
import { Point, simplify } from "points-on-curve";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import rough from "roughjs/bin/rough";
|
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import { simplify, Point } from "points-on-curve";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
|
||||||
newElement,
|
|
||||||
newTextElement,
|
|
||||||
duplicateElement,
|
|
||||||
isInvisiblySmallElement,
|
|
||||||
isTextElement,
|
|
||||||
textWysiwyg,
|
|
||||||
getCommonBounds,
|
|
||||||
getCursorForResizingElement,
|
|
||||||
getPerfectElementSize,
|
|
||||||
getNormalizedDimensions,
|
|
||||||
newLinearElement,
|
|
||||||
transformElements,
|
|
||||||
getElementWithTransformHandleType,
|
|
||||||
getResizeOffsetXY,
|
|
||||||
getResizeArrowDirection,
|
|
||||||
getTransformHandleTypeFromCoords,
|
|
||||||
isNonDeletedElement,
|
|
||||||
updateTextElement,
|
|
||||||
dragSelectedElements,
|
|
||||||
getDragOffsetXY,
|
|
||||||
dragNewElement,
|
|
||||||
hitTest,
|
|
||||||
isHittingElementBoundingBoxWithoutHittingElement,
|
|
||||||
getNonDeletedElements,
|
|
||||||
} from "../element";
|
|
||||||
import {
|
|
||||||
getElementsWithinSelection,
|
|
||||||
isOverScrollBars,
|
|
||||||
getElementsAtPosition,
|
|
||||||
getElementContainingPosition,
|
|
||||||
getNormalizedZoom,
|
|
||||||
getSelectedElements,
|
|
||||||
isSomeElementSelected,
|
|
||||||
calculateScrollCenter,
|
|
||||||
} from "../scene";
|
|
||||||
import { loadFromBlob, exportCanvas } from "../data";
|
|
||||||
|
|
||||||
import { renderScene } from "../renderer";
|
|
||||||
import {
|
|
||||||
AppState,
|
|
||||||
GestureEvent,
|
|
||||||
Gesture,
|
|
||||||
ExcalidrawProps,
|
|
||||||
SceneData,
|
|
||||||
} from "../types";
|
|
||||||
import {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
NonDeleted,
|
|
||||||
ExcalidrawGenericElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawBindableElement,
|
|
||||||
} from "../element/types";
|
|
||||||
|
|
||||||
import { distance2d, isPathALoop, getGridPoint } from "../math";
|
|
||||||
|
|
||||||
import {
|
|
||||||
isWritableElement,
|
|
||||||
isInputLike,
|
|
||||||
isToolIcon,
|
|
||||||
debounce,
|
|
||||||
distance,
|
|
||||||
resetCursor,
|
|
||||||
viewportCoordsToSceneCoords,
|
|
||||||
sceneCoordsToViewportCoords,
|
|
||||||
setCursorForShape,
|
|
||||||
tupleToCoors,
|
|
||||||
ResolvablePromise,
|
|
||||||
resolvablePromise,
|
|
||||||
withBatchedUpdates,
|
|
||||||
} from "../utils";
|
|
||||||
import {
|
|
||||||
KEYS,
|
|
||||||
isArrowKey,
|
|
||||||
getResizeCenterPointKey,
|
|
||||||
getResizeWithSidesSameLengthKey,
|
|
||||||
getRotateWithDiscreteAngleKey,
|
|
||||||
CODES,
|
|
||||||
} from "../keys";
|
|
||||||
|
|
||||||
import { findShapeByKey } from "../shapes";
|
|
||||||
import { createHistory, SceneHistory } from "../history";
|
|
||||||
|
|
||||||
import ContextMenu from "./ContextMenu";
|
|
||||||
|
|
||||||
import { ActionManager } from "../actions/manager";
|
|
||||||
import "../actions";
|
import "../actions";
|
||||||
|
import { actionDeleteSelected, actionFinalize } from "../actions";
|
||||||
|
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
import { actions } from "../actions/register";
|
import { actions } from "../actions/register";
|
||||||
|
|
||||||
import { ActionResult } from "../actions/types";
|
import { ActionResult } from "../actions/types";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { t, getLanguage } from "../i18n";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
probablySupportsClipboardBlob,
|
probablySupportsClipboardBlob,
|
||||||
probablySupportsClipboardWriteText,
|
probablySupportsClipboardWriteText,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import { normalizeScroll } from "../scene";
|
|
||||||
import { getCenter, getDistance } from "../gesture";
|
|
||||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
APP_NAME,
|
||||||
|
CANVAS_ONLY_ACTIONS,
|
||||||
CURSOR_TYPE,
|
CURSOR_TYPE,
|
||||||
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
|
DRAGGING_THRESHOLD,
|
||||||
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
||||||
ELEMENT_TRANSLATE_AMOUNT,
|
ELEMENT_TRANSLATE_AMOUNT,
|
||||||
POINTER_BUTTON,
|
|
||||||
DRAGGING_THRESHOLD,
|
|
||||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
|
||||||
EVENT,
|
|
||||||
ENV,
|
ENV,
|
||||||
CANVAS_ONLY_ACTIONS,
|
EVENT,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
|
LINE_CONFIRM_THRESHOLD,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
|
POINTER_BUTTON,
|
||||||
TAP_TWICE_TIMEOUT,
|
TAP_TWICE_TIMEOUT,
|
||||||
|
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||||
TOUCH_CTX_MENU_TIMEOUT,
|
TOUCH_CTX_MENU_TIMEOUT,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
|
import { exportCanvas, loadFromBlob } from "../data";
|
||||||
import LayerUI from "./LayerUI";
|
|
||||||
import { ScrollBars, SceneState } from "../scene/types";
|
|
||||||
import { mutateElement } from "../element/mutateElement";
|
|
||||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
|
||||||
import {
|
|
||||||
isLinearElement,
|
|
||||||
isLinearElementType,
|
|
||||||
isBindingElement,
|
|
||||||
isBindingElementType,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { actionFinalize, actionDeleteSelected } from "../actions";
|
|
||||||
|
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
|
||||||
import {
|
|
||||||
getSelectedGroupIds,
|
|
||||||
isSelectedViaGroup,
|
|
||||||
selectGroupsForSelectedElements,
|
|
||||||
isElementInGroup,
|
|
||||||
getSelectedGroupIdForElement,
|
|
||||||
getElementsInGroup,
|
|
||||||
editGroupForSelectedElement,
|
|
||||||
} from "../groups";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
import Scene from "../scene/Scene";
|
|
||||||
import {
|
|
||||||
getHoveredElementForBinding,
|
|
||||||
maybeBindLinearElement,
|
|
||||||
getEligibleElementsForBinding,
|
|
||||||
bindOrUnbindSelectedElements,
|
|
||||||
unbindLinearElements,
|
|
||||||
fixBindingsAfterDuplication,
|
|
||||||
fixBindingsAfterDeletion,
|
|
||||||
isLinearElementSimpleAndAlreadyBound,
|
|
||||||
isBindingEnabled,
|
|
||||||
updateBoundElements,
|
|
||||||
shouldEnableBindingForPointerEvent,
|
|
||||||
} from "../element/binding";
|
|
||||||
import { MaybeTransformHandleType } from "../element/transformHandles";
|
|
||||||
import { deepCopyElement } from "../element/newElement";
|
|
||||||
import { renderSpreadsheet } from "../charts";
|
|
||||||
import { isValidLibrary } from "../data/json";
|
import { isValidLibrary } from "../data/json";
|
||||||
import { getNewZoom } from "../scene/zoom";
|
import { Library } from "../data/library";
|
||||||
import { restore } from "../data/restore";
|
import { restore } from "../data/restore";
|
||||||
import {
|
import {
|
||||||
EVENT_DIALOG,
|
dragNewElement,
|
||||||
EVENT_LIBRARY,
|
dragSelectedElements,
|
||||||
EVENT_SHAPE,
|
duplicateElement,
|
||||||
trackEvent,
|
getCommonBounds,
|
||||||
} from "../analytics";
|
getCursorForResizingElement,
|
||||||
|
getDragOffsetXY,
|
||||||
|
getElementWithTransformHandleType,
|
||||||
|
getNonDeletedElements,
|
||||||
|
getNormalizedDimensions,
|
||||||
|
getPerfectElementSize,
|
||||||
|
getResizeArrowDirection,
|
||||||
|
getResizeOffsetXY,
|
||||||
|
getTransformHandleTypeFromCoords,
|
||||||
|
hitTest,
|
||||||
|
isHittingElementBoundingBoxWithoutHittingElement,
|
||||||
|
isInvisiblySmallElement,
|
||||||
|
isNonDeletedElement,
|
||||||
|
isTextElement,
|
||||||
|
newElement,
|
||||||
|
newLinearElement,
|
||||||
|
newTextElement,
|
||||||
|
textWysiwyg,
|
||||||
|
transformElements,
|
||||||
|
updateTextElement,
|
||||||
|
} from "../element";
|
||||||
|
import {
|
||||||
|
bindOrUnbindSelectedElements,
|
||||||
|
fixBindingsAfterDeletion,
|
||||||
|
fixBindingsAfterDuplication,
|
||||||
|
getEligibleElementsForBinding,
|
||||||
|
getHoveredElementForBinding,
|
||||||
|
isBindingEnabled,
|
||||||
|
isLinearElementSimpleAndAlreadyBound,
|
||||||
|
maybeBindLinearElement,
|
||||||
|
shouldEnableBindingForPointerEvent,
|
||||||
|
unbindLinearElements,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "../element/binding";
|
||||||
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { mutateElement } from "../element/mutateElement";
|
||||||
|
import { deepCopyElement } from "../element/newElement";
|
||||||
|
import { MaybeTransformHandleType } from "../element/transformHandles";
|
||||||
|
import {
|
||||||
|
isBindingElement,
|
||||||
|
isBindingElementType,
|
||||||
|
isLinearElement,
|
||||||
|
isLinearElementType,
|
||||||
|
} from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
ExcalidrawBindableElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawGenericElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
NonDeleted,
|
||||||
|
} from "../element/types";
|
||||||
|
import { getCenter, getDistance } from "../gesture";
|
||||||
|
import {
|
||||||
|
editGroupForSelectedElement,
|
||||||
|
getElementsInGroup,
|
||||||
|
getSelectedGroupIdForElement,
|
||||||
|
getSelectedGroupIds,
|
||||||
|
isElementInGroup,
|
||||||
|
isSelectedViaGroup,
|
||||||
|
selectGroupsForSelectedElements,
|
||||||
|
} from "../groups";
|
||||||
|
import { createHistory, SceneHistory } from "../history";
|
||||||
|
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
||||||
|
import {
|
||||||
|
CODES,
|
||||||
|
getResizeCenterPointKey,
|
||||||
|
getResizeWithSidesSameLengthKey,
|
||||||
|
getRotateWithDiscreteAngleKey,
|
||||||
|
isArrowKey,
|
||||||
|
KEYS,
|
||||||
|
} from "../keys";
|
||||||
|
import { distance2d, getGridPoint, isPathALoop } from "../math";
|
||||||
|
import { renderScene } from "../renderer";
|
||||||
|
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||||
|
import {
|
||||||
|
calculateScrollCenter,
|
||||||
|
getElementContainingPosition,
|
||||||
|
getElementsAtPosition,
|
||||||
|
getElementsWithinSelection,
|
||||||
|
getNormalizedZoom,
|
||||||
|
getSelectedElements,
|
||||||
|
isOverScrollBars,
|
||||||
|
isSomeElementSelected,
|
||||||
|
normalizeScroll,
|
||||||
|
} from "../scene";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import { SceneState, ScrollBars } from "../scene/types";
|
||||||
|
import { getNewZoom } from "../scene/zoom";
|
||||||
|
import { findShapeByKey } from "../shapes";
|
||||||
|
import {
|
||||||
|
AppState,
|
||||||
|
ExcalidrawProps,
|
||||||
|
Gesture,
|
||||||
|
GestureEvent,
|
||||||
|
SceneData,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
debounce,
|
||||||
|
distance,
|
||||||
|
isInputLike,
|
||||||
|
isToolIcon,
|
||||||
|
isWritableElement,
|
||||||
|
resetCursor,
|
||||||
|
ResolvablePromise,
|
||||||
|
resolvablePromise,
|
||||||
|
sceneCoordsToViewportCoords,
|
||||||
|
setCursorForShape,
|
||||||
|
tupleToCoors,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
|
withBatchedUpdates,
|
||||||
|
} from "../utils";
|
||||||
|
import ContextMenu from "./ContextMenu";
|
||||||
|
import LayerUI from "./LayerUI";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
|
import { Toast } from "./Toast";
|
||||||
|
|
||||||
const { history } = createHistory();
|
const { history } = createHistory();
|
||||||
|
|
||||||
@@ -345,12 +328,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
offsetLeft,
|
offsetLeft,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const { onCollabButtonClick } = this.props;
|
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
|
||||||
const canvasScale = window.devicePixelRatio;
|
const canvasScale = window.devicePixelRatio;
|
||||||
|
|
||||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||||
const canvasHeight = canvasDOMHeight * canvasScale;
|
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||||
|
|
||||||
|
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
|
||||||
|
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="excalidraw"
|
className="excalidraw"
|
||||||
@@ -370,13 +356,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
elements={this.scene.getElements()}
|
elements={this.scene.getElements()}
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
onLockToggle={this.toggleLock}
|
onLockToggle={this.toggleLock}
|
||||||
onInsertShape={(elements) =>
|
onInsertElements={(elements) =>
|
||||||
this.addElementsFromPasteOrLibrary(elements)
|
this.addElementsFromPasteOrLibrary(
|
||||||
|
elements,
|
||||||
|
DEFAULT_PASTE_X,
|
||||||
|
DEFAULT_PASTE_Y,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
zenModeEnabled={zenModeEnabled}
|
zenModeEnabled={zenModeEnabled}
|
||||||
toggleZenMode={this.toggleZenMode}
|
toggleZenMode={this.toggleZenMode}
|
||||||
lng={getLanguage().lng}
|
langCode={getLanguage().code}
|
||||||
isCollaborating={this.props.isCollaborating || false}
|
isCollaborating={this.props.isCollaborating || false}
|
||||||
|
onExportToBackend={onExportToBackend}
|
||||||
|
renderCustomFooter={renderFooter}
|
||||||
/>
|
/>
|
||||||
{this.state.showStats && (
|
{this.state.showStats && (
|
||||||
<Stats
|
<Stats
|
||||||
@@ -385,6 +377,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
onClose={this.toggleStats}
|
onClose={this.toggleStats}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{this.state.toastMessage !== null && (
|
||||||
|
<Toast
|
||||||
|
message={this.state.toastMessage}
|
||||||
|
clearToast={this.clearToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<main>
|
<main>
|
||||||
<canvas
|
<canvas
|
||||||
id="canvas"
|
id="canvas"
|
||||||
@@ -494,7 +492,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private importLibraryFromUrl = async (url: string) => {
|
private importLibraryFromUrl = async (url: string) => {
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
try {
|
try {
|
||||||
const request = await fetch(url);
|
const request = await fetch(url);
|
||||||
const blob = await request.blob();
|
const blob = await request.blob();
|
||||||
@@ -508,7 +506,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
await Library.importLibrary(blob);
|
await Library.importLibrary(blob);
|
||||||
trackEvent(EVENT_LIBRARY, "import");
|
|
||||||
this.setState({
|
this.setState({
|
||||||
isLibraryOpen: true,
|
isLibraryOpen: true,
|
||||||
});
|
});
|
||||||
@@ -586,6 +583,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
scene.elements,
|
scene.elements,
|
||||||
{
|
{
|
||||||
...scene.appState,
|
...scene.appState,
|
||||||
|
width: this.state.width,
|
||||||
|
height: this.state.height,
|
||||||
offsetTop: this.state.offsetTop,
|
offsetTop: this.state.offsetTop,
|
||||||
offsetLeft: this.state.offsetLeft,
|
offsetLeft: this.state.offsetLeft,
|
||||||
},
|
},
|
||||||
@@ -741,6 +740,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||||
|
if (prevProps.langCode !== this.props.langCode) {
|
||||||
|
this.updateLanguage();
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
prevProps.width !== this.props.width ||
|
prevProps.width !== this.props.width ||
|
||||||
prevProps.height !== this.props.height ||
|
prevProps.height !== this.props.height ||
|
||||||
@@ -864,7 +867,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
history.record(this.state, this.scene.getElementsIncludingDeleted());
|
history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||||
|
|
||||||
this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
|
// Do not notify consumers if we're still loading the scene. Among other
|
||||||
|
// potential issues, this fixes a case where the tab isn't focused during
|
||||||
|
// init, which would trigger onChange with empty elements, which would then
|
||||||
|
// override whatever is in localStorage currently.
|
||||||
|
if (!this.state.isLoading) {
|
||||||
|
this.props.onChange?.(
|
||||||
|
this.scene.getElementsIncludingDeleted(),
|
||||||
|
this.state,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy/paste
|
// Copy/paste
|
||||||
@@ -906,6 +918,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.canvas!,
|
this.canvas!,
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
this.setState({ toastMessage: t("toast.copyToClipboardAsPng") });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
this.setState({ errorMessage: error.message });
|
this.setState({ errorMessage: error.message });
|
||||||
@@ -993,9 +1006,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
if (data.errorMessage) {
|
if (data.errorMessage) {
|
||||||
this.setState({ errorMessage: data.errorMessage });
|
this.setState({ errorMessage: data.errorMessage });
|
||||||
} else if (data.spreadsheet) {
|
} else if (data.spreadsheet) {
|
||||||
this.addElementsFromPasteOrLibrary(
|
this.setState({
|
||||||
renderSpreadsheet(data.spreadsheet, cursorX, cursorY),
|
pasteDialog: {
|
||||||
);
|
data: data.spreadsheet,
|
||||||
|
shown: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else if (data.elements) {
|
} else if (data.elements) {
|
||||||
this.addElementsFromPasteOrLibrary(data.elements);
|
this.addElementsFromPasteOrLibrary(data.elements);
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
@@ -1120,7 +1136,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
toggleLock = () => {
|
toggleLock = () => {
|
||||||
this.setState((prevState) => {
|
this.setState((prevState) => {
|
||||||
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
|
|
||||||
return {
|
return {
|
||||||
elementLocked: !prevState.elementLocked,
|
elementLocked: !prevState.elementLocked,
|
||||||
elementType: prevState.elementLocked
|
elementType: prevState.elementLocked
|
||||||
@@ -1144,7 +1159,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
toggleStats = () => {
|
toggleStats = () => {
|
||||||
if (!this.state.showStats) {
|
if (!this.state.showStats) {
|
||||||
trackEvent(EVENT_DIALOG, "stats");
|
trackEvent("dialog", "stats");
|
||||||
}
|
}
|
||||||
this.setState({
|
this.setState({
|
||||||
showStats: !this.state.showStats,
|
showStats: !this.state.showStats,
|
||||||
@@ -1161,6 +1176,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
clearToast = () => {
|
||||||
|
this.setState({ toastMessage: null });
|
||||||
|
};
|
||||||
|
|
||||||
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
||||||
if (sceneData.commitToHistory) {
|
if (sceneData.commitToHistory) {
|
||||||
history.resumeRecording();
|
history.resumeRecording();
|
||||||
@@ -1230,7 +1249,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
if (event.key === KEYS.QUESTION_MARK) {
|
if (event.key === KEYS.QUESTION_MARK) {
|
||||||
this.setState({
|
this.setState({
|
||||||
showShortcutsDialog: true,
|
showHelpDialog: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,9 +1275,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.code === CODES.NINE) {
|
if (event.code === CODES.NINE) {
|
||||||
if (!this.state.isLibraryOpen) {
|
|
||||||
trackEvent(EVENT_DIALOG, "library");
|
|
||||||
}
|
|
||||||
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,7 +1359,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
) {
|
) {
|
||||||
const shape = findShapeByKey(event.key);
|
const shape = findShapeByKey(event.key);
|
||||||
if (shape) {
|
if (shape) {
|
||||||
trackEvent(EVENT_SHAPE, shape, "shortcut");
|
|
||||||
this.selectShapeTool(shape);
|
this.selectShapeTool(shape);
|
||||||
} else if (event.key === KEYS.Q) {
|
} else if (event.key === KEYS.Q) {
|
||||||
this.toggleLock();
|
this.toggleLock();
|
||||||
@@ -1416,13 +1431,27 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
|
private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.setState(({ zoom }) => ({
|
|
||||||
zoom: getNewZoom(
|
// onGestureChange only has zoom factor but not the center.
|
||||||
getNormalizedZoom(gesture.initialScale! * event.scale),
|
// If we're on iPad or iPhone, then we recognize multi-touch and will
|
||||||
zoom,
|
// zoom in at the right location on the touchMove handler already.
|
||||||
{ x: cursorX, y: cursorY },
|
// On Macbook, we don't have those events so will zoom in at the
|
||||||
),
|
// current location instead.
|
||||||
}));
|
if (gesture.pointers.size === 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialScale = gesture.initialScale;
|
||||||
|
if (initialScale) {
|
||||||
|
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
|
||||||
|
zoom: getNewZoom(
|
||||||
|
getNormalizedZoom(initialScale * event.scale),
|
||||||
|
zoom,
|
||||||
|
{ left: offsetLeft, top: offsetTop },
|
||||||
|
{ x: cursorX, y: cursorY },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
|
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
|
||||||
@@ -1713,7 +1742,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
resetCursor();
|
resetCursor();
|
||||||
|
|
||||||
if (!event[KEYS.CTRL_OR_CMD]) {
|
if (!event[KEYS.CTRL_OR_CMD]) {
|
||||||
trackEvent(EVENT_SHAPE, "text", "double-click");
|
|
||||||
this.startTextEditing({
|
this.startTextEditing({
|
||||||
sceneX,
|
sceneX,
|
||||||
sceneY,
|
sceneY,
|
||||||
@@ -1734,21 +1762,28 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gesture.pointers.size === 2) {
|
const initialScale = gesture.initialScale;
|
||||||
|
if (
|
||||||
|
gesture.pointers.size === 2 &&
|
||||||
|
gesture.lastCenter &&
|
||||||
|
initialScale &&
|
||||||
|
gesture.initialDistance
|
||||||
|
) {
|
||||||
const center = getCenter(gesture.pointers);
|
const center = getCenter(gesture.pointers);
|
||||||
const deltaX = center.x - gesture.lastCenter!.x;
|
const deltaX = center.x - gesture.lastCenter.x;
|
||||||
const deltaY = center.y - gesture.lastCenter!.y;
|
const deltaY = center.y - gesture.lastCenter.y;
|
||||||
gesture.lastCenter = center;
|
gesture.lastCenter = center;
|
||||||
|
|
||||||
const distance = getDistance(Array.from(gesture.pointers.values()));
|
const distance = getDistance(Array.from(gesture.pointers.values()));
|
||||||
const scaleFactor = distance / gesture.initialDistance!;
|
const scaleFactor = distance / gesture.initialDistance;
|
||||||
|
|
||||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
|
||||||
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
|
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
|
||||||
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
|
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
|
||||||
zoom: getNewZoom(
|
zoom: getNewZoom(
|
||||||
getNormalizedZoom(gesture.initialScale! * scaleFactor),
|
getNormalizedZoom(initialScale * scaleFactor),
|
||||||
zoom,
|
zoom,
|
||||||
|
{ left: offsetLeft, top: offsetTop },
|
||||||
center,
|
center,
|
||||||
),
|
),
|
||||||
shouldCacheIgnoreZoom: true,
|
shouldCacheIgnoreZoom: true,
|
||||||
@@ -2430,8 +2465,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
// otherwise, it will trigger selection based on current
|
// otherwise, it will trigger selection based on current
|
||||||
// state of the box
|
// state of the box
|
||||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||||
// if we are currently editing a group, treat all selections outside of the group
|
// if we are currently editing a group, exiting editing mode and deselect the group.
|
||||||
// as exiting editing mode.
|
|
||||||
if (
|
if (
|
||||||
this.state.editingGroupId &&
|
this.state.editingGroupId &&
|
||||||
!isElementInGroup(hitElement, this.state.editingGroupId)
|
!isElementInGroup(hitElement, this.state.editingGroupId)
|
||||||
@@ -2441,7 +2475,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add hit element to selection. At this point if we're not holding
|
// Add hit element to selection. At this point if we're not holding
|
||||||
@@ -2579,9 +2612,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
|
/* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
|
||||||
If so, we want it to be null for start and "arrow" for end. If the linear item is not
|
If so, we want it to be null for start and "arrow" for end. If the linear item is not
|
||||||
an arrow, we want it to be null for both. Otherwise, we want it to use the
|
an arrow, we want it to be null for both. Otherwise, we want it to use the
|
||||||
values from appState. */
|
values from appState. */
|
||||||
|
|
||||||
const { currentItemStartArrowhead, currentItemEndArrowhead } = this.state;
|
const { currentItemStartArrowhead, currentItemEndArrowhead } = this.state;
|
||||||
const [startArrowhead, endArrowhead] =
|
const [startArrowhead, endArrowhead] =
|
||||||
@@ -3108,7 +3141,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||||
if (!elementLocked) {
|
if (!elementLocked && elementType !== "draw") {
|
||||||
resetCursor();
|
resetCursor();
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
@@ -3255,7 +3288,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!elementLocked && draggingElement) {
|
if (!elementLocked && elementType !== "draw" && draggingElement) {
|
||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
selectedElementIds: {
|
selectedElementIds: {
|
||||||
...prevState.selectedElementIds,
|
...prevState.selectedElementIds,
|
||||||
@@ -3279,7 +3312,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!elementLocked) {
|
if (!elementLocked && elementType !== "draw") {
|
||||||
resetCursor();
|
resetCursor();
|
||||||
this.setState({
|
this.setState({
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
@@ -3554,9 +3587,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
transformElements(
|
transformElements(
|
||||||
pointerDownState,
|
pointerDownState,
|
||||||
transformHandleType,
|
transformHandleType,
|
||||||
(newTransformHandle) => {
|
|
||||||
pointerDownState.resize.handleType = newTransformHandle;
|
|
||||||
},
|
|
||||||
selectedElements,
|
selectedElements,
|
||||||
pointerDownState.resize.arrowDirection,
|
pointerDownState.resize.arrowDirection,
|
||||||
getRotateWithDiscreteAngleKey(event),
|
getRotateWithDiscreteAngleKey(event),
|
||||||
@@ -3612,13 +3642,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
CANVAS_ONLY_ACTIONS.includes(action.name),
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
shortcutName: "toggleGridMode",
|
checked: this.state.gridSize !== null,
|
||||||
label: t("labels.toggleGridMode"),
|
shortcutName: "gridMode",
|
||||||
|
label: t("labels.gridMode"),
|
||||||
action: this.toggleGridMode,
|
action: this.toggleGridMode,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shortcutName: "toggleStats",
|
checked: this.state.zenModeEnabled,
|
||||||
label: t("labels.toggleStats"),
|
shortcutName: "zenMode",
|
||||||
|
label: t("buttons.zenMode"),
|
||||||
|
action: this.toggleZenMode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checked: this.state.showStats,
|
||||||
|
shortcutName: "stats",
|
||||||
|
label: t("stats.title"),
|
||||||
action: this.toggleStats,
|
action: this.toggleStats,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3695,11 +3733,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState(({ zoom }) => ({
|
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
|
||||||
zoom: getNewZoom(getNormalizedZoom(zoom.value - delta / 100), zoom, {
|
zoom: getNewZoom(
|
||||||
x: cursorX,
|
getNormalizedZoom(zoom.value - delta / 100),
|
||||||
y: cursorY,
|
zoom,
|
||||||
}),
|
{ left: offsetLeft, top: offsetTop },
|
||||||
|
{
|
||||||
|
x: cursorX,
|
||||||
|
y: cursorY,
|
||||||
|
},
|
||||||
|
),
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
previousSelectedElementIds:
|
previousSelectedElementIds:
|
||||||
Object.keys(selectedElementIds).length !== 0
|
Object.keys(selectedElementIds).length !== 0
|
||||||
@@ -3782,7 +3825,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
|
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
|
||||||
this.setState({ shouldCacheIgnoreZoom: false });
|
if (!this.unmounted) {
|
||||||
|
this.setState({ shouldCacheIgnoreZoom: false });
|
||||||
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
private getCanvasOffsets(offsets?: {
|
private getCanvasOffsets(offsets?: {
|
||||||
@@ -3814,6 +3859,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
offsetTop: typeof offsets?.offsetTop === "number" ? offsets.offsetTop : 0,
|
offsetTop: typeof offsets?.offsetTop === "number" ? offsets.offsetTop : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateLanguage() {
|
||||||
|
const currentLang =
|
||||||
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
|
defaultLang;
|
||||||
|
await setLanguage(currentLang);
|
||||||
|
this.setAppState({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { DarkModeToggle } from "./DarkModeToggle";
|
import { DarkModeToggle } from "./DarkModeToggle";
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
|||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.appearance}
|
value={appState.appearance}
|
||||||
onChange={(appearance) => {
|
onChange={(appearance) => {
|
||||||
// TODO: track the theme on the first load too
|
|
||||||
trackEvent(EVENT_CHANGE, "theme", appearance);
|
|
||||||
setAppState({ appearance });
|
setAppState({ appearance });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import useIsMobile from "../is-mobile";
|
|||||||
import { users } from "./icons";
|
import { users } from "./icons";
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
import "./CollabButton.scss";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const CollabButton = ({
|
const CollabButton = ({
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
@@ -23,10 +22,7 @@ const CollabButton = ({
|
|||||||
className={clsx("CollabButton", {
|
className={clsx("CollabButton", {
|
||||||
"is-collaborating": isCollaborating,
|
"is-collaborating": isCollaborating,
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={onClick}
|
||||||
trackEvent(EVENT_DIALOG, "collaboration");
|
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
icon={users}
|
icon={users}
|
||||||
type="button"
|
type="button"
|
||||||
title={t("buttons.roomDialog")}
|
title={t("buttons.roomDialog")}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@
|
|||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$media-query} {
|
@media #{$is-mobile-query} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.context-menu {
|
.context-menu {
|
||||||
@@ -32,13 +32,29 @@
|
|||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 0.2fr;
|
grid-template-columns: 1fr 0.2fr;
|
||||||
div:nth-child(1) {
|
align-items: center;
|
||||||
|
|
||||||
|
&.checkmark::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
content: "\2713";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
.context-menu-option__label {
|
||||||
|
color: $oc-red-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option__label {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
margin-inline-end: 20px;
|
margin-inline-end: 20px;
|
||||||
}
|
}
|
||||||
div:nth-child(2) {
|
.context-menu-option__shortcut {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,9 +62,30 @@
|
|||||||
.context-menu-option:hover {
|
.context-menu-option:hover {
|
||||||
color: var(--popup-background-color);
|
color: var(--popup-background-color);
|
||||||
background-color: var(--select-highlight-color);
|
background-color: var(--select-highlight-color);
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
.context-menu-option__label {
|
||||||
|
color: var(--popup-background-color);
|
||||||
|
}
|
||||||
|
background-color: $oc-red-6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-option:focus {
|
.context-menu-option:focus {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.context-menu-option {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.context-menu-option__label {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option__shortcut {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../actions/shortcuts";
|
} from "../actions/shortcuts";
|
||||||
|
|
||||||
type ContextMenuOption = {
|
type ContextMenuOption = {
|
||||||
|
checked?: boolean;
|
||||||
shortcutName: ShortcutName;
|
shortcutName: ShortcutName;
|
||||||
label: string;
|
label: string;
|
||||||
action(): void;
|
action(): void;
|
||||||
@@ -26,7 +27,6 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
|||||||
const isDarkTheme = !!document
|
const isDarkTheme = !!document
|
||||||
.querySelector(".excalidraw")
|
.querySelector(".excalidraw")
|
||||||
?.classList.contains("Appearance_dark");
|
?.classList.contains("Appearance_dark");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("excalidraw", {
|
className={clsx("excalidraw", {
|
||||||
@@ -43,15 +43,20 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
|||||||
className="context-menu"
|
className="context-menu"
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{options.map(({ action, shortcutName, label }, idx) => (
|
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
||||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
||||||
<button className="context-menu-option" onClick={action}>
|
<button
|
||||||
<div>{label}</div>
|
className={`context-menu-option
|
||||||
<div>
|
${shortcutName === "delete" ? "dangerous" : ""}
|
||||||
|
${checked ? "checkmark" : ""}`}
|
||||||
|
onClick={action}
|
||||||
|
>
|
||||||
|
<div className="context-menu-option__label">{label}</div>
|
||||||
|
<kbd className="context-menu-option__shortcut">
|
||||||
{shortcutName
|
{shortcutName
|
||||||
? getShortcutFromShortcutName(shortcutName)
|
? getShortcutFromShortcutName(shortcutName)
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
||||||
title={t("buttons.toggleDarkMode")}
|
title={
|
||||||
|
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||||
@@ -23,7 +25,11 @@ export const DarkModeToggle = (props: {
|
|||||||
props.onChange(event.target.checked ? "dark" : "light")
|
props.onChange(event.target.checked ? "dark" : "light")
|
||||||
}
|
}
|
||||||
checked={props.value === "dark"}
|
checked={props.value === "dark"}
|
||||||
aria-label={t("buttons.toggleDarkMode")}
|
aria-label={
|
||||||
|
props.value === "dark"
|
||||||
|
? t("buttons.lightMode")
|
||||||
|
: t("buttons.darkMode")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="ToolIcon__icon">
|
<div className="ToolIcon__icon">
|
||||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.Dialog {
|
||||||
|
user-select: text;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.Dialog__title {
|
.Dialog__title {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||||
grid-gap: var(--metric);
|
grid-gap: var(--metric);
|
||||||
|
padding: calc(var(--space-factor) * 2);
|
||||||
|
text-align: center;
|
||||||
|
font-variant: small-caps;
|
||||||
|
font-size: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Dialog__titleContent {
|
.Dialog__titleContent {
|
||||||
@@ -18,7 +27,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$media-query} {
|
.Dialog__content {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
.Dialog {
|
.Dialog {
|
||||||
--metric: calc(var(--space-factor) * 4);
|
--metric: calc(var(--space-factor) * 4);
|
||||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||||
@@ -30,13 +43,8 @@
|
|||||||
var(--space-factor) * 7
|
var(--space-factor) * 7
|
||||||
);
|
);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(-1 * var(--metric));
|
top: 0;
|
||||||
margin: calc(-1 * var(--inset-right));
|
|
||||||
margin-top: calc(-1 * var(--metric));
|
|
||||||
margin-bottom: var(--metric);
|
|
||||||
padding: calc(var(--space-factor) * 2);
|
padding: calc(var(--space-factor) * 2);
|
||||||
padding-left: var(--inset-left);
|
|
||||||
padding-right: var(--inset-right);
|
|
||||||
background: var(--bg-color-island);
|
background: var(--bg-color-island);
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Modal } from "./Modal";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Island } from "./Island";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { back, close } from "./icons";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
|
import { back, close } from "./icons";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
const useRefState = <T,>() => {
|
const useRefState = <T,>() => {
|
||||||
const [refValue, setRefValue] = useState<T | null>(null);
|
const [refValue, setRefValue] = useState<T | null>(null);
|
||||||
@@ -20,9 +19,10 @@ const useRefState = <T,>() => {
|
|||||||
export const Dialog = (props: {
|
export const Dialog = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxWidth?: number;
|
small?: boolean;
|
||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
autofocus?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export const Dialog = (props: {
|
|||||||
|
|
||||||
const focusableElements = queryFocusableElements(islandNode);
|
const focusableElements = queryFocusableElements(islandNode);
|
||||||
|
|
||||||
if (focusableElements.length > 0) {
|
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||||
// If there's an element other than close, focus it.
|
// If there's an element other than close, focus it.
|
||||||
(focusableElements[1] || focusableElements[0]).focus();
|
(focusableElements[1] || focusableElements[0]).focus();
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ export const Dialog = (props: {
|
|||||||
islandNode.addEventListener("keydown", handleKeyDown);
|
islandNode.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [islandNode]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const queryFocusableElements = (node: HTMLElement) => {
|
const queryFocusableElements = (node: HTMLElement) => {
|
||||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||||
@@ -76,10 +76,10 @@ export const Dialog = (props: {
|
|||||||
<Modal
|
<Modal
|
||||||
className={clsx("Dialog", props.className)}
|
className={clsx("Dialog", props.className)}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={props.maxWidth}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={props.onCloseRequest}
|
onCloseRequest={props.onCloseRequest}
|
||||||
>
|
>
|
||||||
<Island padding={4} ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id="dialog-title" className="Dialog__title">
|
<h2 id="dialog-title" className="Dialog__title">
|
||||||
<span className="Dialog__titleContent">{props.title}</span>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
<button
|
<button
|
||||||
@@ -90,7 +90,7 @@ export const Dialog = (props: {
|
|||||||
{useIsMobile() ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
{props.children}
|
<div className="Dialog__content">{props.children}</div>
|
||||||
</Island>
|
</Island>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,18 @@ export const ErrorDialog = ({
|
|||||||
<>
|
<>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog
|
||||||
maxWidth={500}
|
small
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
title={t("errorDialog.title")}
|
title={t("errorDialog.title")}
|
||||||
>
|
>
|
||||||
<div>{message}</div>
|
<div>
|
||||||
|
{message.split("\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
@media #{$is-mobile-query} {
|
||||||
.ExportDialog {
|
.ExportDialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -51,9 +51,7 @@
|
|||||||
.ExportDialog__actions > * {
|
.ExportDialog__actions > * {
|
||||||
margin-bottom: calc(var(--space-factor) * 3);
|
margin-bottom: calc(var(--space-factor) * 3);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
.ExportDialog__preview canvas {
|
.ExportDialog__preview canvas {
|
||||||
max-height: 30vh;
|
max-height: 30vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import { ActionsManagerInterface } from "../actions/types";
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { canvasToBlob } from "../data/blob";
|
import { canvasToBlob } from "../data/blob";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
@@ -67,7 +66,7 @@ const ExportModal = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend: ExportCB;
|
onExportToBackend?: ExportCB;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
@@ -155,13 +154,15 @@ const ExportModal = ({
|
|||||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ToolButton
|
{onExportToBackend && (
|
||||||
type="button"
|
<ToolButton
|
||||||
icon={link}
|
type="button"
|
||||||
title={t("buttons.getShareableLink")}
|
icon={link}
|
||||||
aria-label={t("buttons.getShareableLink")}
|
title={t("buttons.getShareableLink")}
|
||||||
onClick={() => onExportToBackend(exportedElements)}
|
aria-label={t("buttons.getShareableLink")}
|
||||||
/>
|
onClick={() => onExportToBackend(exportedElements)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<div className="ExportDialog__name">
|
<div className="ExportDialog__name">
|
||||||
{actionManager.renderAction("changeProjectName")}
|
{actionManager.renderAction("changeProjectName")}
|
||||||
@@ -235,7 +236,7 @@ export const ExportDialog = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend: ExportCB;
|
onExportToBackend?: ExportCB;
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||||
@@ -249,7 +250,6 @@ export const ExportDialog = ({
|
|||||||
<>
|
<>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_DIALOG, "export");
|
|
||||||
setModalIsShown(true);
|
setModalIsShown(true);
|
||||||
}}
|
}}
|
||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
@@ -260,11 +260,7 @@ export const ExportDialog = ({
|
|||||||
ref={triggerButton}
|
ref={triggerButton}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||||
maxWidth={800}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
title={t("buttons.export")}
|
|
||||||
>
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
import React from "react";
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(
|
||||||
@@ -17,9 +16,6 @@ export const GitHubCorner = React.memo(
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="GitHub repository"
|
aria-label="GitHub repository"
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "github");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
.excalidraw {
|
.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);
|
border: 1px solid var(--button-gray-2);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShortcutsDialog-island-title {
|
.HelpDialog--island-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShorcutsDialog-shortcut {
|
.HelpDialog--shortcut {
|
||||||
border-top: 1px solid var(--button-gray-2);
|
border-top: 1px solid var(--button-gray-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShorcutsDialog-key {
|
.HelpDialog--key {
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -29,14 +34,23 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShortcutsDialog-footer {
|
.HelpDialog--header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
border-top: 1px solid var(--button-gray-2);
|
margin-bottom: 32px;
|
||||||
margin-top: 8px;
|
padding-bottom: 16px;
|
||||||
padding-top: 16px;
|
}
|
||||||
|
|
||||||
|
.HelpDialog--btn {
|
||||||
|
border: 1px solid var(--link-color);
|
||||||
|
padding: 8px 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.HelpDialog--btn:hover {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { isDarwin } 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+'")]}
|
||||||
|
/>
|
||||||
|
</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={[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 React from "react";
|
||||||
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -7,19 +8,8 @@ type HelpIconProps = {
|
|||||||
onClick?(): void;
|
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) => (
|
export const HelpIcon = (props: HelpIconProps) => (
|
||||||
<label title={`${props.title} — ?`} className="help-icon">
|
<label title={`${props.title} — ?`} className="help-icon">
|
||||||
<div onClick={props.onClick}>{ICON}</div>
|
<div onClick={props.onClick}>{questionCircle}</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
// this is loosely based on the longest hint text
|
||||||
|
$wide-viewport-width: 1000px;
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.HintViewer {
|
.HintViewer {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -16,12 +19,9 @@
|
|||||||
color: $oc-gray-6;
|
color: $oc-gray-6;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media #{$is-mobile-query} {
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
position: static;
|
position: static;
|
||||||
|
padding-right: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
selectedElements.length === 1
|
selectedElements.length === 1
|
||||||
) {
|
) {
|
||||||
const targetElement = selectedElements[0];
|
const targetElement = selectedElements[0];
|
||||||
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return null;
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return t("hints.resize");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,12 +93,16 @@
|
|||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
|
color: var(--keybinding-color);
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
right: 2px;
|
right: 2px;
|
||||||
@@ -107,7 +111,7 @@
|
|||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
@media #{$media-query} {
|
@media #{$is-mobile-query} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { setLanguageFirstTime } from "../i18n";
|
import {
|
||||||
|
defaultLang,
|
||||||
|
Language,
|
||||||
|
languages,
|
||||||
|
setLanguageFirstTime,
|
||||||
|
} from "../i18n";
|
||||||
|
|
||||||
export class InitializeApp extends React.Component<
|
interface Props {
|
||||||
any,
|
langCode: Language["code"];
|
||||||
{ isLoading: boolean }
|
}
|
||||||
> {
|
interface State {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
export class InitializeApp extends React.Component<Props, State> {
|
||||||
public state: { isLoading: boolean } = {
|
public state: { isLoading: boolean } = {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
await setLanguageFirstTime();
|
const currentLang =
|
||||||
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
|
defaultLang;
|
||||||
|
await setLanguageFirstTime(currentLang);
|
||||||
this.setState({
|
this.setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
background-color: var(--bg-color-island);
|
background-color: var(--bg-color-island);
|
||||||
backdrop-filter: saturate(100%) blur(10px);
|
backdrop-filter: saturate(100%) blur(10px);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: var(--border-radius-m);
|
border-radius: 4px;
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
|||||||
@@ -7,11 +7,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.browse-libraries {
|
.layer-ui__library-header {
|
||||||
position: absolute;
|
display: flex;
|
||||||
right: 12px;
|
align-items: center;
|
||||||
top: 16px;
|
width: 100%;
|
||||||
white-space: nowrap;
|
margin: 2px 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: auto;
|
||||||
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
|
padding-right: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,64 +51,6 @@
|
|||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
height: 1.2rem;
|
height: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tooltip .tooltip-text {
|
|
||||||
visibility: hidden;
|
|
||||||
width: 20rem;
|
|
||||||
bottom: calc(50% + 0.8rem + 6px);
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
background-color: $oc-black;
|
|
||||||
color: $oc-white;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 5px;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
--size: 6px;
|
|
||||||
content: "";
|
|
||||||
border: var(--size) solid transparent;
|
|
||||||
border-top-color: $oc-black;
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(-2 * var(--size));
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: calc(5px + var(--size) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: calc(5px + var(--size) / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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-text {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-text:hover {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__github-corner {
|
&__github-corner {
|
||||||
|
|||||||
@@ -1,56 +1,46 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
import React, {
|
import React, {
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
RefObject,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
} from "react";
|
} 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 { ActionManager } from "../actions/manager";
|
||||||
import { Island } from "./Island";
|
import { CLASSES } from "../constants";
|
||||||
import Stack from "./Stack";
|
import { exportCanvas } from "../data";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
import { UserList } from "./UserList";
|
import { Library } from "../data/library";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { showSelectedShapeActions } from "../element";
|
||||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { Language, t } from "../i18n";
|
||||||
import { t, languages, setLanguage } from "../i18n";
|
|
||||||
import { HintViewer } from "./HintViewer";
|
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { Section } from "./Section";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { CLASSES } from "../constants";
|
|
||||||
import { shield, exportFile, load } from "./icons";
|
|
||||||
import { GitHubCorner } from "./GitHubCorner";
|
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 "./LayerUI.scss";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
|
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 { ToolButton } from "./ToolButton";
|
||||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
import {
|
|
||||||
EVENT_ACTION,
|
|
||||||
EVENT_EXIT,
|
|
||||||
EVENT_LIBRARY,
|
|
||||||
trackEvent,
|
|
||||||
} from "../analytics";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -60,11 +50,17 @@ interface LayerUIProps {
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
lng: string;
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
|
onExportToBackend?: (
|
||||||
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
) => void;
|
||||||
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
@@ -118,59 +114,45 @@ const LibraryMenuItems = ({
|
|||||||
let addedPendingElements = false;
|
let addedPendingElements = false;
|
||||||
|
|
||||||
rows.push(
|
rows.push(
|
||||||
<>
|
<div className="layer-ui__library-header">
|
||||||
<a
|
<ToolButton
|
||||||
className="browse-libraries"
|
key="import"
|
||||||
href="https://libraries.excalidraw.com"
|
type="button"
|
||||||
target="_excalidraw_libraries"
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_EXIT, "libraries");
|
importLibraryFromJSON()
|
||||||
|
.then(() => {
|
||||||
|
// Maybe we should close and open the menu so that the items get updated.
|
||||||
|
// But for now we just close the menu.
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportFile}
|
||||||
|
onClick={() => {
|
||||||
|
saveLibraryAsJSON()
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
|
||||||
{t("labels.libraries")}
|
{t("labels.libraries")}
|
||||||
</a>
|
</a>
|
||||||
|
</div>,
|
||||||
<Stack.Row
|
|
||||||
align="center"
|
|
||||||
gap={1}
|
|
||||||
key={"actions"}
|
|
||||||
style={{ padding: "2px" }}
|
|
||||||
>
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={() => {
|
|
||||||
importLibraryFromJSON()
|
|
||||||
.then(() => {
|
|
||||||
// Maybe we should close and open the menu so that the items get updated.
|
|
||||||
// But for now we just close the menu.
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportFile}
|
|
||||||
onClick={() => {
|
|
||||||
saveLibraryAsJSON()
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
</>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let row = 0; row < numRows; row++) {
|
for (let row = 0; row < numRows; row++) {
|
||||||
@@ -269,7 +251,6 @@ const LibraryMenu = ({
|
|||||||
const items = await Library.loadLibrary();
|
const items = await Library.loadLibrary();
|
||||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||||
Library.saveLibrary(nextItems);
|
Library.saveLibrary(nextItems);
|
||||||
trackEvent(EVENT_LIBRARY, "remove");
|
|
||||||
setLibraryItems(nextItems);
|
setLibraryItems(nextItems);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -278,7 +259,6 @@ const LibraryMenu = ({
|
|||||||
const items = await Library.loadLibrary();
|
const items = await Library.loadLibrary();
|
||||||
const nextItems = [...items, elements];
|
const nextItems = [...items, elements];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
trackEvent(EVENT_LIBRARY, "add");
|
|
||||||
Library.saveLibrary(nextItems);
|
Library.saveLibrary(nextItems);
|
||||||
setLibraryItems(nextItems);
|
setLibraryItems(nextItems);
|
||||||
},
|
},
|
||||||
@@ -313,14 +293,15 @@ const LayerUI = ({
|
|||||||
elements,
|
elements,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onInsertShape,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
|
onExportToBackend,
|
||||||
|
renderCustomFooter,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// TODO: Extend tooltip component and use here.
|
|
||||||
const renderEncryptedIcon = () => (
|
const renderEncryptedIcon = () => (
|
||||||
<a
|
<a
|
||||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||||
@@ -329,14 +310,10 @@ const LayerUI = ({
|
|||||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "e2ee shield");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="tooltip-text" dir="auto">
|
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||||
{t("encrypted.tooltip")}
|
{shield}
|
||||||
</span>
|
</Tooltip>
|
||||||
{shield}
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -360,6 +337,7 @@ const LayerUI = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -368,28 +346,14 @@ const LayerUI = ({
|
|||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
onExportToClipboard={createExporter("clipboard")}
|
onExportToClipboard={createExporter("clipboard")}
|
||||||
onExportToBackend={async (exportedElements) => {
|
onExportToBackend={
|
||||||
if (canvas) {
|
onExportToBackend
|
||||||
try {
|
? (elements) => {
|
||||||
await exportCanvas(
|
onExportToBackend &&
|
||||||
"backend",
|
onExportToBackend(elements, appState, canvas);
|
||||||
exportedElements,
|
|
||||||
{
|
|
||||||
...appState,
|
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
|
||||||
canvas,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== "AbortError") {
|
|
||||||
const { width, height } = canvas;
|
|
||||||
console.error(error, { width, height });
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
}
|
}
|
||||||
}
|
: undefined
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -465,7 +429,7 @@ const LayerUI = ({
|
|||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
pendingElements={getSelectedElements(elements, appState)}
|
||||||
onClickOutside={closeLibrary}
|
onClickOutside={closeLibrary}
|
||||||
onInsertShape={onInsertShape}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
@@ -567,14 +531,7 @@ const LayerUI = ({
|
|||||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LanguageList
|
{renderCustomFooter?.(false)}
|
||||||
onChange={async (lng) => {
|
|
||||||
await setLanguage(lng);
|
|
||||||
setAppState({});
|
|
||||||
}}
|
|
||||||
languages={languages}
|
|
||||||
floating
|
|
||||||
/>
|
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -589,7 +546,6 @@ const LayerUI = ({
|
|||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_ACTION, "scroll to content");
|
|
||||||
setAppState({
|
setAppState({
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
});
|
});
|
||||||
@@ -601,21 +557,8 @@ const LayerUI = ({
|
|||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isMobile ? (
|
const dialogs = (
|
||||||
<MobileMenu
|
<>
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
actionManager={actionManager}
|
|
||||||
libraryMenu={libraryMenu}
|
|
||||||
exportButton={renderExportDialog()}
|
|
||||||
setAppState={setAppState}
|
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
|
||||||
onLockToggle={onLockToggle}
|
|
||||||
canvas={canvas}
|
|
||||||
isCollaborating={isCollaborating}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="layer-ui__wrapper">
|
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
@@ -623,11 +566,44 @@ const LayerUI = ({
|
|||||||
onClose={() => setAppState({ errorMessage: null })}
|
onClose={() => setAppState({ errorMessage: null })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.showShortcutsDialog && (
|
{appState.showHelpDialog && (
|
||||||
<ShortcutsDialog
|
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||||
onClose={() => setAppState({ showShortcutsDialog: false })}
|
)}
|
||||||
|
{appState.pasteDialog.shown && (
|
||||||
|
<PasteChartDialog
|
||||||
|
setAppState={setAppState}
|
||||||
|
appState={appState}
|
||||||
|
onInsertChart={onInsertElements}
|
||||||
|
onClose={() =>
|
||||||
|
setAppState({
|
||||||
|
pasteDialog: { shown: false, data: null },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return isMobile ? (
|
||||||
|
<>
|
||||||
|
{dialogs}
|
||||||
|
<MobileMenu
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
actionManager={actionManager}
|
||||||
|
libraryMenu={libraryMenu}
|
||||||
|
exportButton={renderExportDialog()}
|
||||||
|
setAppState={setAppState}
|
||||||
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
|
onLockToggle={onLockToggle}
|
||||||
|
canvas={canvas}
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
renderCustomFooter={renderCustomFooter}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="layer-ui__wrapper">
|
||||||
|
{dialogs}
|
||||||
{renderFixedSideContainer()}
|
{renderFixedSideContainer()}
|
||||||
{renderBottomAppMenu()}
|
{renderBottomAppMenu()}
|
||||||
{
|
{
|
||||||
@@ -650,8 +626,6 @@ const LayerUI = ({
|
|||||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||||
const {
|
const {
|
||||||
cursorX,
|
|
||||||
cursorY,
|
|
||||||
suggestedBindings,
|
suggestedBindings,
|
||||||
startBoundElement: boundElement,
|
startBoundElement: boundElement,
|
||||||
...ret
|
...ret
|
||||||
@@ -662,9 +636,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
const nextAppState = getNecessaryObj(next.appState);
|
const nextAppState = getNecessaryObj(next.appState);
|
||||||
|
|
||||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prev.lng === next.lng &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { exportToSvg } from "../scene/export";
|
import oc from "open-color";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { close } from "../components/icons";
|
import { close } from "../components/icons";
|
||||||
|
import { MIME_TYPES } from "../constants";
|
||||||
import "./LibraryUnit.scss";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { exportToSvg } from "../scene/export";
|
||||||
import { LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
import { MIME_TYPES } from "../constants";
|
import "./LibraryUnit.scss";
|
||||||
|
|
||||||
// fa-plus
|
// fa-plus
|
||||||
const PLUS_ICON = (
|
const PLUS_ICON = (
|
||||||
@@ -38,7 +38,7 @@ export const LibraryUnit = ({
|
|||||||
}
|
}
|
||||||
const svg = exportToSvg(elementsToRender, {
|
const svg = exportToSvg(elementsToRender, {
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: "#fff",
|
viewBackgroundColor: oc.white,
|
||||||
shouldAddWatermark: false,
|
shouldAddWatermark: false,
|
||||||
});
|
});
|
||||||
for (const child of ref.current!.children) {
|
for (const child of ref.current!.children) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { t, setLanguage } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { LanguageList } from "./LanguageList";
|
|
||||||
import { showSelectedShapeActions } from "../element";
|
import { showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
@@ -15,10 +14,8 @@ import { Section } from "./Section";
|
|||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { EVENT_ACTION, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -31,6 +28,7 @@ type MobileMenuProps = {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -44,9 +42,9 @@ export const MobileMenu = ({
|
|||||||
onLockToggle,
|
onLockToggle,
|
||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
|
renderCustomFooter,
|
||||||
}: MobileMenuProps) => (
|
}: MobileMenuProps) => (
|
||||||
<>
|
<>
|
||||||
{appState.isLoading && <LoadingMessage />}
|
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<Section heading="shapes">
|
<Section heading="shapes">
|
||||||
{(heading) => (
|
{(heading) => (
|
||||||
@@ -104,15 +102,7 @@ export const MobileMenu = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
<fieldset>
|
{renderCustomFooter?.(true)}
|
||||||
<legend>{t("labels.language")}</legend>
|
|
||||||
<LanguageList
|
|
||||||
onChange={async (lng) => {
|
|
||||||
await setLanguage(lng);
|
|
||||||
setAppState({});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
<UserList mobile>
|
<UserList mobile>
|
||||||
@@ -158,7 +148,6 @@ export const MobileMenu = ({
|
|||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_ACTION, "scroll to content");
|
|
||||||
setAppState({
|
setAppState({
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,18 +30,26 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--max-width);
|
max-width: var(--max-width);
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
// for modals, reset blurry bg
|
// for modals, reset blurry bg
|
||||||
background: var(--bg-color-island);
|
background: var(--bg-color-island);
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
|
|
||||||
@media #{$media-query} {
|
border: 1px solid var(--dialog-border);
|
||||||
|
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +76,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Modal__close--floating {
|
@media #{$is-mobile-query} {
|
||||||
position: absolute;
|
|
||||||
right: calc(var(--space-factor) * 5);
|
|
||||||
top: calc(var(--space-factor) * 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
.Modal {
|
.Modal {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,7 @@ export const Modal = (props: {
|
|||||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
||||||
<div
|
<div
|
||||||
className="Modal__content"
|
className="Modal__content"
|
||||||
style={{
|
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||||
"--max-width": `${props.maxWidth}px`,
|
|
||||||
maxHeight: "100%",
|
|
||||||
overflowY: "scroll",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.PasteChartDialog {
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.Island {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ChartPreview {
|
||||||
|
margin: 8px;
|
||||||
|
text-align: center;
|
||||||
|
width: 192px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px;
|
||||||
|
border: 1px solid $oc-gray-4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
div {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
max-height: 120px;
|
||||||
|
max-width: 186px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid $oc-blue-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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";
|
||||||
|
import { exportToSvg } from "../scene/export";
|
||||||
|
import { AppState, LibraryItem } from "../types";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import "./PasteChartDialog.scss";
|
||||||
|
|
||||||
|
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
|
||||||
|
|
||||||
|
const ChartPreviewBtn = (props: {
|
||||||
|
spreadsheet: Spreadsheet | null;
|
||||||
|
chartType: ChartType;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: OnInsertChart;
|
||||||
|
}) => {
|
||||||
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [chartElements, setChartElements] = useState<ChartElements | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!props.spreadsheet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = renderSpreadsheet(
|
||||||
|
props.chartType,
|
||||||
|
props.spreadsheet,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
setChartElements(elements);
|
||||||
|
|
||||||
|
const svg = exportToSvg(elements, {
|
||||||
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
|
if (props.selected) {
|
||||||
|
(previewNode.parentNode as HTMLDivElement).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
previewNode.removeChild(svg);
|
||||||
|
};
|
||||||
|
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="ChartPreview"
|
||||||
|
onClick={() => {
|
||||||
|
if (chartElements) {
|
||||||
|
props.onClick(props.chartType, chartElements);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={previewRef} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasteChartDialog = ({
|
||||||
|
setAppState,
|
||||||
|
appState,
|
||||||
|
onClose,
|
||||||
|
onInsertChart,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
onClose: () => void;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
onInsertChart: (elements: LibraryItem) => void;
|
||||||
|
}) => {
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
|
||||||
|
onInsertChart(elements);
|
||||||
|
trackEvent("magic", "chart", chartType);
|
||||||
|
setAppState({
|
||||||
|
currentChartType: chartType,
|
||||||
|
pasteDialog: {
|
||||||
|
shown: false,
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
small
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
title={t("labels.pasteCharts")}
|
||||||
|
className={"PasteChartDialog"}
|
||||||
|
autofocus={false}
|
||||||
|
>
|
||||||
|
<div className={"container"}>
|
||||||
|
<ChartPreviewBtn
|
||||||
|
chartType="bar"
|
||||||
|
spreadsheet={appState.pasteDialog.data}
|
||||||
|
selected={appState.currentChartType === "bar"}
|
||||||
|
onClick={handleChartClick}
|
||||||
|
/>
|
||||||
|
<ChartPreviewBtn
|
||||||
|
chartType="line"
|
||||||
|
spreadsheet={appState.pasteDialog.data}
|
||||||
|
selected={appState.currentChartType === "line"}
|
||||||
|
onClick={handleChartClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,341 +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
|
|
||||||
maxWidth={900}
|
|
||||||
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("buttons.toggleFullScreen")}
|
|
||||||
shortcuts={["F"]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.toggleZenMode")}
|
|
||||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.toggleGridMode")}
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 24px 8px 0;
|
margin: 0 24px 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
@@ -29,9 +31,21 @@
|
|||||||
}
|
}
|
||||||
tr {
|
tr {
|
||||||
td:nth-child(2) {
|
td:nth-child(2) {
|
||||||
min-width: 48px;
|
min-width: 24px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px 24px;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export const Stats = (props: {
|
|||||||
<td>{t("stats.total")}</td>
|
<td>{t("stats.total")}</td>
|
||||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{selectedElements.length === 1 && (
|
{selectedElements.length === 1 && (
|
||||||
<tr>
|
<tr>
|
||||||
<th colSpan={2}>{t("stats.element")}</th>
|
<th colSpan={2}>{t("stats.element")}</th>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@import "../css/_variables";
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,4 +1,6 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
|
@import "../css/variables";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ToolIcon {
|
.ToolIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -140,6 +142,7 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shrink shape icons on small viewports to make them fit
|
||||||
@media (max-width: 425px) {
|
@media (max-width: 425px) {
|
||||||
.Shape .ToolIcon__icon {
|
.Shape .ToolIcon__icon {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@@ -151,6 +154,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// move the lock button out of the way on small viewports
|
||||||
|
// it begins to collide with the GitHub icon before we switch to mobile mode
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -160,6 +165,7 @@
|
|||||||
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
border-radius: 20px 0 0 20px;
|
border-radius: 20px 0 0 20px;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
|
|
||||||
@@ -181,6 +187,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.TooltipIcon {
|
||||||
|
width: 0.9em;
|
||||||
|
height: 0.9em;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.unlocked-icon {
|
.unlocked-icon {
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
|
|||||||
@@ -7,39 +7,56 @@
|
|||||||
.Tooltip__label {
|
.Tooltip__label {
|
||||||
--arrow-size: 4px;
|
--arrow-size: 4px;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
width: 10ch;
|
|
||||||
background: $oc-black;
|
background: $oc-black;
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
padding: 4px;
|
padding: 8px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
font-size: 0.7rem;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
top: calc(100% + var(--arrow-size) + 3px);
|
font-weight: 500;
|
||||||
// extra pixel offset for unknown reasons
|
// extra pixel offset for unknown reasons
|
||||||
left: calc(-50% + var(--arrow-size) / 2 - 1px);
|
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||||
|
transform: translateX(-50%);
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
border: var(--arrow-size) solid transparent;
|
border: var(--arrow-size) solid transparent;
|
||||||
border-bottom-color: $oc-black;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
|
||||||
left: calc(50% - var(--arrow-size));
|
left: calc(50% - var(--arrow-size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--above {
|
||||||
|
bottom: calc(100% + var(--arrow-size) + 3px);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-top-color: $oc-black;
|
||||||
|
top: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--below {
|
||||||
|
top: calc(100% + var(--arrow-size) + 3px);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-bottom-color: $oc-black;
|
||||||
|
bottom: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
// 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
|
// 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
|
// time it still works when clicking on the link/shield
|
||||||
body:active .Tooltip:not(:hover) {
|
|
||||||
|
body:active & .Tooltip:not(:hover) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:not(:active) .Tooltip:hover .Tooltip__label {
|
body:not(:active) & .Tooltip:hover .Tooltip__label {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,27 @@ import React from "react";
|
|||||||
type TooltipProps = {
|
type TooltipProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
position?: "above" | "below";
|
||||||
|
long?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tooltip = ({ children, label }: TooltipProps) => (
|
export const Tooltip = ({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
position = "below",
|
||||||
|
long = false,
|
||||||
|
}: TooltipProps) => (
|
||||||
<div className="Tooltip">
|
<div className="Tooltip">
|
||||||
<span className="Tooltip__label">{label}</span>
|
<span
|
||||||
|
className={
|
||||||
|
position === "above"
|
||||||
|
? "Tooltip__label Tooltip__label--above"
|
||||||
|
: "Tooltip__label Tooltip__label--below"
|
||||||
|
}
|
||||||
|
style={{ width: long ? "50ch" : "10ch" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export const redo = createIcon(
|
|||||||
{ mirror: true },
|
{ mirror: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const questionCircle = createIcon(
|
||||||
|
"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zM262.655 90c-54.497 0-89.255 22.957-116.549 63.758-3.536 5.286-2.353 12.415 2.715 16.258l34.699 26.31c5.205 3.947 12.621 3.008 16.665-2.122 17.864-22.658 30.113-35.797 57.303-35.797 20.429 0 45.698 13.148 45.698 32.958 0 14.976-12.363 22.667-32.534 33.976C247.128 238.528 216 254.941 216 296v4c0 6.627 5.373 12 12 12h56c6.627 0 12-5.373 12-12v-1.333c0-28.462 83.186-29.647 83.186-106.667 0-58.002-60.165-102-116.531-102zM256 338c-25.365 0-46 20.635-46 46 0 25.364 20.635 46 46 46s46-20.636 46-46c0-25.365-20.635-46-46-46z",
|
||||||
|
{ mirror: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Icon imported form Storybook
|
// Icon imported form Storybook
|
||||||
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
|
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
|
||||||
export const resetZoom = createIcon(
|
export const resetZoom = createIcon(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FontFamily } from "./element/types";
|
import { FontFamily } from "./element/types";
|
||||||
|
|
||||||
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
export const DRAGGING_THRESHOLD = 10; // 10px
|
export const DRAGGING_THRESHOLD = 10; // 10px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
@@ -68,6 +70,7 @@ export const DEFAULT_FONT_SIZE = 20;
|
|||||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||||
export const DEFAULT_TEXT_ALIGN = "left";
|
export const DEFAULT_TEXT_ALIGN = "left";
|
||||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||||
|
export const DEFAULT_VERSION = "{version}";
|
||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
@@ -85,3 +88,6 @@ export const STORAGE_KEYS = {
|
|||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
|
export const TITLE_TIMEOUT = 10000;
|
||||||
|
export const TOAST_TIMEOUT = 5000;
|
||||||
|
export const VERSION_TIMEOUT = 15000;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
$media-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
// keep up to date with is-mobile.tsx
|
||||||
|
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: $oc-blue-7; /* OC Blue 7 */
|
color: var(--link-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -431,6 +431,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
fill: $oc-gray-6;
|
fill: $oc-gray-6;
|
||||||
bottom: 14px;
|
bottom: 14px;
|
||||||
|
width: 1.5rem;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
right: 14px;
|
right: 14px;
|
||||||
@@ -441,7 +442,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$media-query} {
|
@media #{$is-mobile-query} {
|
||||||
aside {
|
aside {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-color-island: rgba(255, 255, 255, 0.9);
|
--bg-color-island: rgba(255, 255, 255, 0.9);
|
||||||
--popup-background-color: #{$oc-white};
|
--popup-background-color: #{$oc-white};
|
||||||
--border-radius-m: 4px;
|
|
||||||
--space-factor: 0.25rem;
|
--space-factor: 0.25rem;
|
||||||
--button-gray-1: #{$oc-gray-2};
|
--button-gray-1: #{$oc-gray-2};
|
||||||
--button-gray-2: #{$oc-gray-4};
|
--button-gray-2: #{$oc-gray-4};
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
--icon-fill-color: #{$oc-black};
|
--icon-fill-color: #{$oc-black};
|
||||||
--icon-green-fill-color: #{$oc-green-9};
|
--icon-green-fill-color: #{$oc-green-9};
|
||||||
--keybinding-color: #{$oc-gray-5};
|
--keybinding-color: #{$oc-gray-5};
|
||||||
--color-overlay-text-color: #ccc;
|
|
||||||
--sat: env(safe-area-inset-top);
|
--sat: env(safe-area-inset-top);
|
||||||
--sab: env(safe-area-inset-bottom);
|
--sab: env(safe-area-inset-bottom);
|
||||||
--sal: env(safe-area-inset-left);
|
--sal: env(safe-area-inset-left);
|
||||||
@@ -23,8 +21,6 @@
|
|||||||
--text-color-primary: #{$oc-gray-8};
|
--text-color-primary: #{$oc-gray-8};
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
||||||
--overlay-background-color: #{transparentize($oc-white, 0.12)};
|
--overlay-background-color: #{transparentize($oc-white, 0.12)};
|
||||||
--border-radius-m: 4px;
|
|
||||||
--space-factor: 0.25rem;
|
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||||
--focus-highlight-color: #{$oc-blue-2};
|
--focus-highlight-color: #{$oc-blue-2};
|
||||||
--select-highlight-color: #{$oc-blue-5};
|
--select-highlight-color: #{$oc-blue-5};
|
||||||
@@ -35,6 +31,8 @@
|
|||||||
--popup-secondary-background-color: #{$oc-gray-1};
|
--popup-secondary-background-color: #{$oc-gray-1};
|
||||||
--popup-text-color: #{$oc-black};
|
--popup-text-color: #{$oc-black};
|
||||||
--popup-text-inverted-color: #{$oc-white};
|
--popup-text-inverted-color: #{$oc-white};
|
||||||
|
--dialog-border: #{$oc-gray-6};
|
||||||
|
--link-color: #{$oc-blue-7};
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
@@ -60,10 +58,8 @@
|
|||||||
--icon-fill-color: #{$oc-gray-4};
|
--icon-fill-color: #{$oc-gray-4};
|
||||||
--icon-green-fill-color: #{$oc-green-4};
|
--icon-green-fill-color: #{$oc-green-4};
|
||||||
--keybinding-color: #{$oc-gray-6};
|
--keybinding-color: #{$oc-gray-6};
|
||||||
--color-overlay-text-color: #bbb;
|
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
||||||
--overlay-background-color: rgba(30, 30, 30, 0.88);
|
--overlay-background-color: rgba(30, 30, 30, 0.88);
|
||||||
// #{$oc-gray-4}; inlined
|
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||||
--focus-highlight-color: #{$oc-blue-6};
|
--focus-highlight-color: #{$oc-blue-6};
|
||||||
--select-highlight-color: #{$oc-blue-4};
|
--select-highlight-color: #{$oc-blue-4};
|
||||||
@@ -74,5 +70,6 @@
|
|||||||
--popup-secondary-background-color: #222;
|
--popup-secondary-background-color: #222;
|
||||||
--popup-text-color: #{$oc-gray-4};
|
--popup-text-color: #{$oc-gray-4};
|
||||||
--popup-text-inverted-color: #2c2c2c;
|
--popup-text-inverted-color: #2c2c2c;
|
||||||
|
--dialog-border: #{$oc-gray-9};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { EVENT_IO, trackEvent } from "../analytics";
|
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
@@ -9,7 +8,7 @@ import { AppState } from "../types";
|
|||||||
import { restore } from "./restore";
|
import { restore } from "./restore";
|
||||||
import { ImportedDataState, LibraryData } from "./types";
|
import { ImportedDataState, LibraryData } from "./types";
|
||||||
|
|
||||||
export const parseFileContents = async (blob: Blob | File) => {
|
const parseFileContents = async (blob: Blob | File) => {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
|
|
||||||
if (blob.type === "image/png") {
|
if (blob.type === "image/png") {
|
||||||
@@ -111,7 +110,6 @@ export const loadFromBlob = async (
|
|||||||
localAppState,
|
localAppState,
|
||||||
);
|
);
|
||||||
|
|
||||||
trackEvent(EVENT_IO, "load", getMimeType(blob));
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
|
|||||||
@@ -1,83 +1,19 @@
|
|||||||
import { fileSave } from "browser-nativefs";
|
import { fileSave } from "browser-nativefs";
|
||||||
import { EVENT_IO, trackEvent } from "../analytics";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
|
||||||
import {
|
import {
|
||||||
copyCanvasToClipboardAsPng,
|
copyCanvasToClipboardAsPng,
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import {
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
ExcalidrawElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
} from "../element/types";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { canvasToBlob } from "./blob";
|
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import { canvasToBlob } from "./blob";
|
||||||
import { serializeAsJSON } from "./json";
|
import { serializeAsJSON } from "./json";
|
||||||
|
|
||||||
export { loadFromBlob } from "./blob";
|
export { loadFromBlob } from "./blob";
|
||||||
export { loadFromJSON, saveAsJSON } from "./json";
|
export { loadFromJSON, saveAsJSON } from "./json";
|
||||||
|
|
||||||
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
|
||||||
|
|
||||||
export const exportToBackend = async (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
appState: AppState,
|
|
||||||
) => {
|
|
||||||
const json = serializeAsJSON(elements, appState);
|
|
||||||
const encoded = new TextEncoder().encode(json);
|
|
||||||
|
|
||||||
const key = await window.crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
length: 128,
|
|
||||||
},
|
|
||||||
true, // extractable
|
|
||||||
["encrypt", "decrypt"],
|
|
||||||
);
|
|
||||||
// The iv is set to 0. We are never going to reuse the same key so we don't
|
|
||||||
// need to have an iv. (I hope that's correct...)
|
|
||||||
const iv = new Uint8Array(12);
|
|
||||||
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
|
||||||
// includes checks that the ciphertext has not been modified by an attacker.
|
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
encoded,
|
|
||||||
);
|
|
||||||
// We use jwk encoding to be able to extract just the base64 encoded key.
|
|
||||||
// We will hardcode the rest of the attributes when importing back the key.
|
|
||||||
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(BACKEND_V2_POST, {
|
|
||||||
method: "POST",
|
|
||||||
body: encrypted,
|
|
||||||
});
|
|
||||||
const json = await response.json();
|
|
||||||
if (json.id) {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
// We need to store the key (and less importantly the id) as hash instead
|
|
||||||
// of queryParam in order to never send it to the server
|
|
||||||
url.hash = `json=${json.id},${exportedKey.k!}`;
|
|
||||||
const urlString = url.toString();
|
|
||||||
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
|
|
||||||
trackEvent(EVENT_IO, "export", "backend");
|
|
||||||
} else if (json.error_class === "RequestTooLargeError") {
|
|
||||||
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
|
|
||||||
} else {
|
|
||||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const exportCanvas = async (
|
export const exportCanvas = async (
|
||||||
type: ExportType,
|
type: ExportType,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
@@ -100,7 +36,7 @@ export const exportCanvas = async (
|
|||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||||
}
|
}
|
||||||
if (type === "svg" || type === "clipboard-svg") {
|
if (type === "svg" || type === "clipboard-svg") {
|
||||||
const tempSvg = exportToSvg(elements, {
|
const tempSvg = exportToSvg(elements, {
|
||||||
@@ -123,10 +59,8 @@ export const exportCanvas = async (
|
|||||||
fileName: `${name}.svg`,
|
fileName: `${name}.svg`,
|
||||||
extensions: [".svg"],
|
extensions: [".svg"],
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_IO, "export", "svg");
|
|
||||||
return;
|
return;
|
||||||
} else if (type === "clipboard-svg") {
|
} else if (type === "clipboard-svg") {
|
||||||
trackEvent(EVENT_IO, "export", "clipboard-svg");
|
|
||||||
copyTextToSystemClipboard(tempSvg.outerHTML);
|
copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -158,24 +92,15 @@ export const exportCanvas = async (
|
|||||||
fileName,
|
fileName,
|
||||||
extensions: [".png"],
|
extensions: [".png"],
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_IO, "export", "png");
|
|
||||||
} else if (type === "clipboard") {
|
} else if (type === "clipboard") {
|
||||||
try {
|
try {
|
||||||
await copyCanvasToClipboardAsPng(tempCanvas);
|
await copyCanvasToClipboardAsPng(tempCanvas);
|
||||||
trackEvent(EVENT_IO, "export", "clipboard-png");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||||
}
|
}
|
||||||
} else if (type === "backend") {
|
|
||||||
exportToBackend(elements, {
|
|
||||||
...appState,
|
|
||||||
viewBackgroundColor: exportBackground
|
|
||||||
? appState.viewBackgroundColor
|
|
||||||
: getDefaultAppState().viewBackgroundColor,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean up the DOM
|
// clean up the DOM
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { cleanAppStateForExport } from "../appState";
|
|
||||||
|
|
||||||
import { fileOpen, fileSave } from "browser-nativefs";
|
import { fileOpen, fileSave } from "browser-nativefs";
|
||||||
import { loadFromBlob } from "./blob";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { Library } from "./library";
|
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
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 = (
|
export const serializeAsJSON = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -84,7 +82,6 @@ export const saveLibraryAsJSON = async () => {
|
|||||||
description: "Excalidraw library file",
|
description: "Excalidraw library file",
|
||||||
extensions: [".excalidrawlib"],
|
extensions: [".excalidrawlib"],
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_LIBRARY, "save");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const importLibraryFromJSON = async () => {
|
export const importLibraryFromJSON = async () => {
|
||||||
@@ -93,6 +90,5 @@ export const importLibraryFromJSON = async () => {
|
|||||||
extensions: [".json", ".excalidrawlib"],
|
extensions: [".json", ".excalidrawlib"],
|
||||||
mimeTypes: ["application/json"],
|
mimeTypes: ["application/json"],
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_LIBRARY, "load");
|
|
||||||
Library.importLibrary(blob);
|
Library.importLibrary(blob);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export {
|
|||||||
export {
|
export {
|
||||||
resizeTest,
|
resizeTest,
|
||||||
getCursorForResizingElement,
|
getCursorForResizingElement,
|
||||||
normalizeTransformHandleType,
|
|
||||||
getElementWithTransformHandleType,
|
getElementWithTransformHandleType,
|
||||||
getTransformHandleTypeFromCoords,
|
getTransformHandleTypeFromCoords,
|
||||||
} from "./resizeTest";
|
} from "./resizeTest";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { rescalePoints } from "../points";
|
|||||||
import {
|
import {
|
||||||
rotate,
|
rotate,
|
||||||
adjustXYWithRotation,
|
adjustXYWithRotation,
|
||||||
getFlipAdjustment,
|
|
||||||
centerPoint,
|
centerPoint,
|
||||||
rotatePoint,
|
rotatePoint,
|
||||||
} from "../math";
|
} from "../math";
|
||||||
@@ -13,21 +12,16 @@ import {
|
|||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
ExcalidrawGenericElement,
|
|
||||||
ExcalidrawElement,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getCommonBounds,
|
getCommonBounds,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks";
|
import { isLinearElement, isTextElement } from "./typeChecks";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { getPerfectElementSize } from "./sizeHelpers";
|
import { getPerfectElementSize } from "./sizeHelpers";
|
||||||
import {
|
import { getCursorForResizingElement } from "./resizeTest";
|
||||||
getCursorForResizingElement,
|
|
||||||
normalizeTransformHandleType,
|
|
||||||
} from "./resizeTest";
|
|
||||||
import { measureText, getFontString } from "../utils";
|
import { measureText, getFontString } from "../utils";
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import {
|
import {
|
||||||
@@ -49,7 +43,6 @@ const normalizeAngle = (angle: number): number => {
|
|||||||
export const transformElements = (
|
export const transformElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
transformHandleType: MaybeTransformHandleType,
|
transformHandleType: MaybeTransformHandleType,
|
||||||
setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void,
|
|
||||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
resizeArrowDirection: "origin" | "end",
|
resizeArrowDirection: "origin" | "end",
|
||||||
isRotateWithDiscreteAngle: boolean,
|
isRotateWithDiscreteAngle: boolean,
|
||||||
@@ -101,36 +94,15 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
updateBoundElements(element);
|
updateBoundElements(element);
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
if (isGenericElement(element)) {
|
resizeSingleElement(
|
||||||
resizeSingleGenericElement(
|
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
shouldKeepSidesRatio,
|
||||||
shouldKeepSidesRatio,
|
element,
|
||||||
element,
|
transformHandleType,
|
||||||
transformHandleType,
|
isResizeCenterPoint,
|
||||||
isResizeCenterPoint,
|
pointerX,
|
||||||
pointerX,
|
pointerY,
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// update cursor
|
// update cursor
|
||||||
@@ -414,8 +386,8 @@ const resizeSingleTextElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleGenericElement = (
|
const resizeSingleElement = (
|
||||||
stateAtResizeStart: NonDeleted<ExcalidrawGenericElement>,
|
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||||
shouldKeepSidesRatio: boolean,
|
shouldKeepSidesRatio: boolean,
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
transformHandleDirection: TransformHandleDirection,
|
transformHandleDirection: TransformHandleDirection,
|
||||||
@@ -423,251 +395,184 @@ const resizeSingleGenericElement = (
|
|||||||
pointerX: number,
|
pointerX: number,
|
||||||
pointerY: 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 startTopLeft: Point = [x1, y1];
|
||||||
const startBottomRight: Point = [x2, y2];
|
const startBottomRight: Point = [x2, y2];
|
||||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||||
|
|
||||||
// Calculate new dimensions based on cursor position
|
// Calculate new dimensions based on cursor position
|
||||||
let newWidth = stateAtResizeStart.width;
|
|
||||||
let newHeight = stateAtResizeStart.height;
|
|
||||||
const rotatedPointer = rotatePoint(
|
const rotatedPointer = rotatePoint(
|
||||||
[pointerX, pointerY],
|
[pointerX, pointerY],
|
||||||
startCenter,
|
startCenter,
|
||||||
-stateAtResizeStart.angle,
|
-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")) {
|
if (transformHandleDirection.includes("e")) {
|
||||||
newWidth = rotatedPointer[0] - startTopLeft[0];
|
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||||
}
|
}
|
||||||
if (transformHandleDirection.includes("s")) {
|
if (transformHandleDirection.includes("s")) {
|
||||||
newHeight = rotatedPointer[1] - startTopLeft[1];
|
scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
|
||||||
}
|
}
|
||||||
if (transformHandleDirection.includes("w")) {
|
if (transformHandleDirection.includes("w")) {
|
||||||
newWidth = startBottomRight[0] - rotatedPointer[0];
|
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
||||||
}
|
}
|
||||||
if (transformHandleDirection.includes("n")) {
|
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
|
// adjust dimensions for resizing from center
|
||||||
if (isResizeFromCenter) {
|
if (isResizeFromCenter) {
|
||||||
newWidth = 2 * newWidth - stateAtResizeStart.width;
|
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
||||||
newHeight = 2 * newHeight - stateAtResizeStart.height;
|
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjust dimensions to keep sides ratio
|
// adjust dimensions to keep sides ratio
|
||||||
if (shouldKeepSidesRatio) {
|
if (shouldKeepSidesRatio) {
|
||||||
const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width;
|
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
||||||
const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height;
|
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
||||||
if (transformHandleDirection.length === 1) {
|
if (transformHandleDirection.length === 1) {
|
||||||
newHeight *= widthRatio;
|
eleNewHeight *= widthRatio;
|
||||||
newWidth *= heightRatio;
|
eleNewWidth *= heightRatio;
|
||||||
}
|
}
|
||||||
if (transformHandleDirection.length === 2) {
|
if (transformHandleDirection.length === 2) {
|
||||||
const ratio = Math.max(widthRatio, heightRatio);
|
const ratio = Math.max(widthRatio, heightRatio);
|
||||||
newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth);
|
eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth);
|
||||||
newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight);
|
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
|
// 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)) {
|
if (["n", "w", "nw"].includes(transformHandleDirection)) {
|
||||||
newTopLeft = [
|
newTopLeft = [
|
||||||
startBottomRight[0] - Math.abs(newWidth),
|
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||||
startBottomRight[1] - Math.abs(newHeight),
|
startBottomRight[1] - Math.abs(newBoundsHeight),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (transformHandleDirection === "ne") {
|
if (transformHandleDirection === "ne") {
|
||||||
const bottomLeft = [
|
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||||
stateAtResizeStart.x,
|
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
|
||||||
stateAtResizeStart.y + stateAtResizeStart.height,
|
|
||||||
];
|
|
||||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)];
|
|
||||||
}
|
}
|
||||||
if (transformHandleDirection === "sw") {
|
if (transformHandleDirection === "sw") {
|
||||||
const topRight = [
|
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||||
stateAtResizeStart.x + stateAtResizeStart.width,
|
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
|
||||||
stateAtResizeStart.y,
|
|
||||||
];
|
|
||||||
newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keeps opposite handle fixed during resize
|
// Keeps opposite handle fixed during resize
|
||||||
if (shouldKeepSidesRatio) {
|
if (shouldKeepSidesRatio) {
|
||||||
if (["s", "n"].includes(transformHandleDirection)) {
|
if (["s", "n"].includes(transformHandleDirection)) {
|
||||||
newTopLeft[0] = startCenter[0] - newWidth / 2;
|
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||||
}
|
}
|
||||||
if (["e", "w"].includes(transformHandleDirection)) {
|
if (["e", "w"].includes(transformHandleDirection)) {
|
||||||
newTopLeft[1] = startCenter[1] - newHeight / 2;
|
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flip horizontally
|
// Flip horizontally
|
||||||
if (newWidth < 0) {
|
if (eleNewWidth < 0) {
|
||||||
if (transformHandleDirection.includes("e")) {
|
if (transformHandleDirection.includes("e")) {
|
||||||
newTopLeft[0] -= Math.abs(newWidth);
|
newTopLeft[0] -= Math.abs(newBoundsWidth);
|
||||||
}
|
}
|
||||||
if (transformHandleDirection.includes("w")) {
|
if (transformHandleDirection.includes("w")) {
|
||||||
newTopLeft[0] += Math.abs(newWidth);
|
newTopLeft[0] += Math.abs(newBoundsWidth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flip vertically
|
// Flip vertically
|
||||||
if (newHeight < 0) {
|
if (eleNewHeight < 0) {
|
||||||
if (transformHandleDirection.includes("s")) {
|
if (transformHandleDirection.includes("s")) {
|
||||||
newTopLeft[1] -= Math.abs(newHeight);
|
newTopLeft[1] -= Math.abs(newBoundsHeight);
|
||||||
}
|
}
|
||||||
if (transformHandleDirection.includes("n")) {
|
if (transformHandleDirection.includes("n")) {
|
||||||
newTopLeft[1] += Math.abs(newHeight);
|
newTopLeft[1] += Math.abs(newBoundsHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResizeFromCenter) {
|
if (isResizeFromCenter) {
|
||||||
newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2;
|
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
|
||||||
newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2;
|
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// adjust topLeft to new rotation point
|
// adjust topLeft to new rotation point
|
||||||
const angle = stateAtResizeStart.angle;
|
const angle = stateAtResizeStart.angle;
|
||||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||||
const newCenter: Point = [
|
const newCenter: Point = [
|
||||||
newTopLeft[0] + Math.abs(newWidth) / 2,
|
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||||
newTopLeft[1] + Math.abs(newHeight) / 2,
|
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||||
];
|
];
|
||||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -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 = {
|
const resizedElement = {
|
||||||
width: Math.abs(newWidth),
|
width: Math.abs(eleNewWidth),
|
||||||
height: Math.abs(newHeight),
|
height: Math.abs(eleNewHeight),
|
||||||
x: newTopLeft[0],
|
x: newOrigin[0],
|
||||||
y: newTopLeft[1],
|
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 (
|
if (
|
||||||
nextWidth !== 0 &&
|
resizedElement.width !== 0 &&
|
||||||
nextHeight !== 0 &&
|
resizedElement.height !== 0 &&
|
||||||
Number.isFinite(nextElementX) &&
|
Number.isFinite(resizedElement.x) &&
|
||||||
Number.isFinite(nextElementY)
|
Number.isFinite(resizedElement.y)
|
||||||
) {
|
) {
|
||||||
mutateElement(element, {
|
updateBoundElements(element, {
|
||||||
width: nextWidth,
|
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||||
height: nextHeight,
|
|
||||||
x: nextElementX,
|
|
||||||
y: nextElementY,
|
|
||||||
...rescaledPoints,
|
|
||||||
});
|
});
|
||||||
|
mutateElement(element, resizedElement);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -173,57 +173,3 @@ export const getCursorForResizingElement = (resizingElement: {
|
|||||||
|
|
||||||
return cursor ? `${cursor}-resize` : "";
|
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
import { FONT_FAMILY } from "../constants";
|
import { FONT_FAMILY } from "../constants";
|
||||||
|
|
||||||
|
export type ChartType = "bar" | "line";
|
||||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||||
export type FontFamily = keyof typeof FONT_FAMILY;
|
export type FontFamily = keyof typeof FONT_FAMILY;
|
||||||
export type FontString = string & { _brand: "fontString" };
|
export type FontString = string & { _brand: "fontString" };
|
||||||
@@ -26,11 +27,21 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
angle: number;
|
angle: number;
|
||||||
|
/** Random integer used to seed shape generation so that the roughjs shape
|
||||||
|
doesn't differ across renders. */
|
||||||
seed: number;
|
seed: number;
|
||||||
|
/** Integer that is sequentially incremented on each change. Used to reconcile
|
||||||
|
elements during collaboration or when saving to server. */
|
||||||
version: number;
|
version: number;
|
||||||
|
/** Random integer that is regenerated on each change.
|
||||||
|
Used for deterministic reconciliation of updates during collaboration,
|
||||||
|
in case the versions (see above) are identical. */
|
||||||
versionNonce: number;
|
versionNonce: number;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
|
/** List of groups the element belongs to.
|
||||||
|
Ordered from deepest to shallowest. */
|
||||||
groupIds: readonly GroupId[];
|
groupIds: readonly GroupId[];
|
||||||
|
/** Ids of (linear) elements that are bound to this element. */
|
||||||
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
|
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,36 @@
|
|||||||
import React, { PureComponent } from "react";
|
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
|
import React, { PureComponent } from "react";
|
||||||
import { ENV, EVENT } from "../../constants";
|
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||||
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||||
import {
|
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||||
decryptAESGEM,
|
import { ImportedDataState } from "../../data/types";
|
||||||
SocketUpdateDataSource,
|
|
||||||
getCollaborationLinkData,
|
|
||||||
generateCollaborationLink,
|
|
||||||
SOCKET_SERVER,
|
|
||||||
} from "../data";
|
|
||||||
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
|
|
||||||
|
|
||||||
import Portal from "./Portal";
|
|
||||||
import { AppState, Collaborator, Gesture } from "../../types";
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import {
|
|
||||||
importUsernameFromLocalStorage,
|
|
||||||
saveUsernameToLocalStorage,
|
|
||||||
STORAGE_KEYS,
|
|
||||||
} from "../data/localStorage";
|
|
||||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
|
||||||
import {
|
import {
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
} from "../../packages/excalidraw/index";
|
} from "../../packages/excalidraw/index";
|
||||||
import RoomDialog from "./RoomDialog";
|
import { AppState, Collaborator, Gesture } from "../../types";
|
||||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||||
import { ImportedDataState } from "../../data/types";
|
|
||||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
|
||||||
import {
|
import {
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
SCENE,
|
SCENE,
|
||||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
import {
|
||||||
|
decryptAESGEM,
|
||||||
|
generateCollaborationLink,
|
||||||
|
getCollaborationLinkData,
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
SOCKET_SERVER,
|
||||||
|
} from "../data";
|
||||||
|
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
|
||||||
|
import {
|
||||||
|
importUsernameFromLocalStorage,
|
||||||
|
saveUsernameToLocalStorage,
|
||||||
|
STORAGE_KEYS,
|
||||||
|
} from "../data/localStorage";
|
||||||
|
import Portal from "./Portal";
|
||||||
|
import RoomDialog from "./RoomDialog";
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
@@ -157,11 +153,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
openPortal = async () => {
|
openPortal = async () => {
|
||||||
window.history.pushState(
|
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
|
||||||
{},
|
|
||||||
"Excalidraw",
|
|
||||||
await generateCollaborationLink(),
|
|
||||||
);
|
|
||||||
const elements = this.excalidrawRef.current!.getSceneElements();
|
const elements = this.excalidrawRef.current!.getSceneElements();
|
||||||
// remove deleted elements from elements array & history to ensure we don't
|
// remove deleted elements from elements array & history to ensure we don't
|
||||||
// expose potentially sensitive user data in case user manually deletes
|
// expose potentially sensitive user data in case user manually deletes
|
||||||
@@ -172,15 +164,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
elements,
|
elements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_SHARE, "session start");
|
|
||||||
return this.initializeSocketClient();
|
return this.initializeSocketClient();
|
||||||
};
|
};
|
||||||
|
|
||||||
closePortal = () => {
|
closePortal = () => {
|
||||||
this.saveCollabRoomToFirebase();
|
this.saveCollabRoomToFirebase();
|
||||||
window.history.pushState({}, "Excalidraw", window.location.origin);
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||||
this.destroySocketClient();
|
this.destroySocketClient();
|
||||||
trackEvent(EVENT_SHARE, "session end");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private destroySocketClient = () => {
|
private destroySocketClient = () => {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import React, { useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
import { t } from "../../i18n";
|
|
||||||
import { Dialog } from "../../components/Dialog";
|
|
||||||
import { copyTextToSystemClipboard } from "../../clipboard";
|
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||||
import { ToolButton } from "../../components/ToolButton";
|
import { Dialog } from "../../components/Dialog";
|
||||||
import { clipboard, start, stop } from "../../components/icons";
|
import { clipboard, start, stop } from "../../components/icons";
|
||||||
|
import { ToolButton } from "../../components/ToolButton";
|
||||||
|
import { t } from "../../i18n";
|
||||||
import "./RoomDialog.scss";
|
import "./RoomDialog.scss";
|
||||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
|
||||||
|
|
||||||
const RoomDialog = ({
|
const RoomDialog = ({
|
||||||
handleClose,
|
handleClose,
|
||||||
@@ -30,7 +28,6 @@ const RoomDialog = ({
|
|||||||
const copyRoomLink = async () => {
|
const copyRoomLink = async () => {
|
||||||
try {
|
try {
|
||||||
await copyTextToSystemClipboard(activeRoomLink);
|
await copyTextToSystemClipboard(activeRoomLink);
|
||||||
trackEvent(EVENT_SHARE, "copy link");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error.message);
|
setErrorMessage(error.message);
|
||||||
}
|
}
|
||||||
@@ -95,7 +92,6 @@ const RoomDialog = ({
|
|||||||
value={username || ""}
|
value={username || ""}
|
||||||
className="RoomDialog-username TextInput"
|
className="RoomDialog-username TextInput"
|
||||||
onChange={(event) => onUsernameChange(event.target.value)}
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
onBlur={() => trackEvent(EVENT_SHARE, "name")}
|
|
||||||
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,11 +119,7 @@ const RoomDialog = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog small onCloseRequest={handleClose} title={t("labels.createRoom")}>
|
||||||
maxWidth={800}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
title={t("labels.createRoom")}
|
|
||||||
>
|
|
||||||
{renderRoomDialog()}
|
{renderRoomDialog()}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import * as i18n from "../i18n";
|
import * as i18n from "../../i18n";
|
||||||
|
|
||||||
export const LanguageList = ({
|
export const LanguageList = ({
|
||||||
onChange,
|
onChange,
|
||||||
languages = i18n.languages,
|
languages = i18n.languages,
|
||||||
currentLanguage = i18n.getLanguage().lng,
|
currentLangCode = i18n.getLanguage().code,
|
||||||
floating,
|
floating,
|
||||||
}: {
|
}: {
|
||||||
languages?: { lng: string; label: string }[];
|
languages?: { code: string; label: string }[];
|
||||||
onChange: (value: string) => void;
|
onChange: (langCode: i18n.Language["code"]) => void;
|
||||||
currentLanguage?: string;
|
currentLangCode?: i18n.Language["code"];
|
||||||
floating?: boolean;
|
floating?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
@@ -19,12 +19,15 @@ export const LanguageList = ({
|
|||||||
"dropdown-select--floating": floating,
|
"dropdown-select--floating": floating,
|
||||||
})}
|
})}
|
||||||
onChange={({ target }) => onChange(target.value)}
|
onChange={({ target }) => onChange(target.value)}
|
||||||
value={currentLanguage}
|
value={currentLangCode}
|
||||||
aria-label={i18n.t("buttons.selectLanguage")}
|
aria-label={i18n.t("buttons.selectLanguage")}
|
||||||
>
|
>
|
||||||
{languages.map((language) => (
|
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
|
||||||
<option key={language.lng} value={language.lng}>
|
{i18n.defaultLang.label}
|
||||||
{language.label}
|
</option>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<option key={lang.code} value={lang.code}>
|
||||||
|
{lang.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { t } from "../../i18n";
|
import { serializeAsJSON } from "../../data/json";
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
|
||||||
import { AppState } from "../../types";
|
|
||||||
import { ImportedDataState } from "../../data/types";
|
|
||||||
import { restore } from "../../data/restore";
|
import { restore } from "../../data/restore";
|
||||||
import { EVENT_ACTION, trackEvent } from "../../analytics";
|
import { ImportedDataState } from "../../data/types";
|
||||||
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { AppState } from "../../types";
|
||||||
|
|
||||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||||
|
|
||||||
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
|
const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
|
||||||
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
||||||
|
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
||||||
|
|
||||||
const generateRandomID = async () => {
|
const generateRandomID = async () => {
|
||||||
const arr = new Uint8Array(10);
|
const arr = new Uint8Array(10);
|
||||||
@@ -190,7 +191,6 @@ const importFromBackend = async (
|
|||||||
data = await response.json();
|
data = await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
trackEvent(EVENT_ACTION, "import");
|
|
||||||
return {
|
return {
|
||||||
elements: data.elements || null,
|
elements: data.elements || null,
|
||||||
appState: data.appState || null,
|
appState: data.appState || null,
|
||||||
@@ -228,3 +228,59 @@ export const loadScene = async (
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const exportToBackend = async (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const json = serializeAsJSON(elements, appState);
|
||||||
|
const encoded = new TextEncoder().encode(json);
|
||||||
|
|
||||||
|
const key = await window.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
length: 128,
|
||||||
|
},
|
||||||
|
true, // extractable
|
||||||
|
["encrypt", "decrypt"],
|
||||||
|
);
|
||||||
|
// The iv is set to 0. We are never going to reuse the same key so we don't
|
||||||
|
// need to have an iv. (I hope that's correct...)
|
||||||
|
const iv = new Uint8Array(12);
|
||||||
|
// We use symmetric encryption. AES-GCM is the recommended algorithm and
|
||||||
|
// includes checks that the ciphertext has not been modified by an attacker.
|
||||||
|
const encrypted = await window.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encoded,
|
||||||
|
);
|
||||||
|
// We use jwk encoding to be able to extract just the base64 encoded key.
|
||||||
|
// We will hardcode the rest of the attributes when importing back the key.
|
||||||
|
const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(BACKEND_V2_POST, {
|
||||||
|
method: "POST",
|
||||||
|
body: encrypted,
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.id) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
// We need to store the key (and less importantly the id) as hash instead
|
||||||
|
// of queryParam in order to never send it to the server
|
||||||
|
url.hash = `json=${json.id},${exportedKey.k!}`;
|
||||||
|
const urlString = url.toString();
|
||||||
|
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
|
||||||
|
} else if (json.error_class === "RequestTooLargeError") {
|
||||||
|
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
|
||||||
|
} else {
|
||||||
|
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,28 +1,53 @@
|
|||||||
import React, { useState, useLayoutEffect, useEffect, useRef } from "react";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
import React, {
|
||||||
import Excalidraw from "../packages/excalidraw/index";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { getDefaultAppState } from "../appState";
|
||||||
|
import { ExcalidrawImperativeAPI } from "../components/App";
|
||||||
|
import { ErrorDialog } from "../components/ErrorDialog";
|
||||||
|
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||||
|
import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants";
|
||||||
|
import { ImportedDataState } from "../data/types";
|
||||||
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "../element/types";
|
||||||
|
import { Language, t } from "../i18n";
|
||||||
|
import Excalidraw, {
|
||||||
|
defaultLang,
|
||||||
|
languages,
|
||||||
|
} from "../packages/excalidraw/index";
|
||||||
|
import { AppState, ExcalidrawAPIRefValue } from "../types";
|
||||||
|
import {
|
||||||
|
debounce,
|
||||||
|
getVersion,
|
||||||
|
ResolvablePromise,
|
||||||
|
resolvablePromise,
|
||||||
|
} from "../utils";
|
||||||
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
|
||||||
|
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
|
||||||
|
import { LanguageList } from "./components/LanguageList";
|
||||||
|
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
||||||
|
import { loadFromFirebase } from "./data/firebase";
|
||||||
import {
|
import {
|
||||||
getTotalStorageSize,
|
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
saveToLocalStorage,
|
saveToLocalStorage,
|
||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
|
|
||||||
import { ImportedDataState } from "../data/types";
|
const languageDetector = new LanguageDetector();
|
||||||
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
|
languageDetector.init({
|
||||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
languageUtils: {
|
||||||
import { t } from "../i18n";
|
formatLanguageCode: (langCode: Language["code"]) => langCode,
|
||||||
import { loadScene } from "./data";
|
isWhitelisted: () => true,
|
||||||
import { getCollaborationLinkData } from "./data";
|
},
|
||||||
import { EVENT } from "../constants";
|
checkWhitelist: false,
|
||||||
import { loadFromFirebase } from "./data/firebase";
|
});
|
||||||
import { ExcalidrawImperativeAPI } from "../components/App";
|
|
||||||
import { debounce, ResolvablePromise, resolvablePromise } from "../utils";
|
|
||||||
import { AppState, ExcalidrawAPIRefValue } from "../types";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
|
|
||||||
import { EVENT_LOAD, EVENT_SHARE, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const excalidrawRef: React.MutableRefObject<
|
const excalidrawRef: React.MutableRefObject<
|
||||||
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
|
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
|
||||||
@@ -87,7 +112,6 @@ type Scene = ImportedDataState & { commitToHistory: boolean };
|
|||||||
const initializeScene = async (opts: {
|
const initializeScene = async (opts: {
|
||||||
resetScene: ExcalidrawImperativeAPI["resetScene"];
|
resetScene: ExcalidrawImperativeAPI["resetScene"];
|
||||||
initializeSocketClient: CollabAPI["initializeSocketClient"];
|
initializeSocketClient: CollabAPI["initializeSocketClient"];
|
||||||
onLateInitialization?: (scene: Scene) => void;
|
|
||||||
}): Promise<Scene | null> => {
|
}): Promise<Scene | null> => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
@@ -113,26 +137,24 @@ const initializeScene = async (opts: {
|
|||||||
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
||||||
}
|
}
|
||||||
if (!isCollabScene) {
|
if (!isCollabScene) {
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
window.addEventListener(
|
return new Promise((resolve, reject) => {
|
||||||
"focus",
|
window.addEventListener(
|
||||||
() =>
|
"focus",
|
||||||
initializeScene(opts).then((_scene) => {
|
() => initializeScene(opts).then(resolve).catch(reject),
|
||||||
opts?.onLateInitialization?.(_scene || scene);
|
{
|
||||||
}),
|
once: true,
|
||||||
{
|
},
|
||||||
once: true,
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollabScene = false;
|
isCollabScene = false;
|
||||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isCollabScene) {
|
if (isCollabScene) {
|
||||||
@@ -140,7 +162,6 @@ const initializeScene = async (opts: {
|
|||||||
// into the remote scene
|
// into the remote scene
|
||||||
opts.resetScene();
|
opts.resetScene();
|
||||||
const scenePromise = opts.initializeSocketClient();
|
const scenePromise = opts.initializeSocketClient();
|
||||||
trackEvent(EVENT_SHARE, "session join");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [, roomId, roomKey] = getCollaborationLinkData(
|
const [, roomId, roomKey] = getCollaborationLinkData(
|
||||||
@@ -178,6 +199,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight,
|
height: window.innerHeight,
|
||||||
});
|
});
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||||
|
const [langCode, setLangCode] = useState(currentLangCode);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
@@ -205,19 +229,15 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
const { collab } = props;
|
const { collab } = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storageSize = getTotalStorageSize();
|
// Delayed so that the app has a time to load the latest SW
|
||||||
if (storageSize) {
|
setTimeout(() => {
|
||||||
trackEvent(EVENT_LOAD, "storage", "size", storageSize);
|
trackEvent("load", "version", getVersion());
|
||||||
} else {
|
}, VERSION_TIMEOUT);
|
||||||
trackEvent(EVENT_LOAD, "first time");
|
|
||||||
}
|
|
||||||
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
|
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
|
||||||
initializeScene({
|
initializeScene({
|
||||||
resetScene: excalidrawApi.resetScene,
|
resetScene: excalidrawApi.resetScene,
|
||||||
initializeSocketClient: collab.initializeSocketClient,
|
initializeSocketClient: collab.initializeSocketClient,
|
||||||
onLateInitialization: (scene) => {
|
|
||||||
initialStatePromiseRef.current.promise.resolve(scene);
|
|
||||||
},
|
|
||||||
}).then((scene) => {
|
}).then((scene) => {
|
||||||
initialStatePromiseRef.current.promise.resolve(scene);
|
initialStatePromiseRef.current.promise.resolve(scene);
|
||||||
});
|
});
|
||||||
@@ -240,6 +260,10 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleTimeout = setTimeout(
|
||||||
|
() => (document.title = APP_NAME),
|
||||||
|
TITLE_TIMEOUT,
|
||||||
|
);
|
||||||
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||||
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
||||||
window.addEventListener(EVENT.BLUR, onBlur, false);
|
window.addEventListener(EVENT.BLUR, onBlur, false);
|
||||||
@@ -247,9 +271,14 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||||
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
||||||
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
||||||
|
clearTimeout(titleTimeout);
|
||||||
};
|
};
|
||||||
}, [collab.initializeSocketClient]);
|
}, [collab.initializeSocketClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
|
}, [langCode]);
|
||||||
|
|
||||||
const onChange = (
|
const onChange = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@@ -260,18 +289,80 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onExportToBackend = async (
|
||||||
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
) => {
|
||||||
|
if (exportedElements.length === 0) {
|
||||||
|
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||||
|
}
|
||||||
|
if (canvas) {
|
||||||
|
try {
|
||||||
|
await exportToBackend(exportedElements, {
|
||||||
|
...appState,
|
||||||
|
viewBackgroundColor: appState.exportBackground
|
||||||
|
? appState.viewBackgroundColor
|
||||||
|
: getDefaultAppState().viewBackgroundColor,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
const { width, height } = canvas;
|
||||||
|
console.error(error, { width, height });
|
||||||
|
setErrorMessage(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFooter = useCallback(
|
||||||
|
(isMobile: boolean) => {
|
||||||
|
const renderLanguageList = () => (
|
||||||
|
<LanguageList
|
||||||
|
onChange={(langCode) => {
|
||||||
|
setLangCode(langCode);
|
||||||
|
}}
|
||||||
|
languages={languages}
|
||||||
|
floating={!isMobile}
|
||||||
|
currentLangCode={langCode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.language")}</legend>
|
||||||
|
{renderLanguageList()}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderLanguageList();
|
||||||
|
},
|
||||||
|
[langCode],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Excalidraw
|
<>
|
||||||
ref={excalidrawRef}
|
<Excalidraw
|
||||||
onChange={onChange}
|
ref={excalidrawRef}
|
||||||
width={dimensions.width}
|
onChange={onChange}
|
||||||
height={dimensions.height}
|
width={dimensions.width}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
height={dimensions.height}
|
||||||
user={{ name: collab.username }}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
onCollabButtonClick={collab.onCollabButtonClick}
|
user={{ name: collab.username }}
|
||||||
isCollaborating={collab.isCollaborating}
|
onCollabButtonClick={collab.onCollabButtonClick}
|
||||||
onPointerUpdate={collab.onPointerUpdate}
|
isCollaborating={collab.isCollaborating}
|
||||||
/>
|
onPointerUpdate={collab.onPointerUpdate}
|
||||||
|
onExportToBackend={onExportToBackend}
|
||||||
|
renderFooter={renderFooter}
|
||||||
|
langCode={langCode}
|
||||||
|
/>
|
||||||
|
{errorMessage && (
|
||||||
|
<ErrorDialog
|
||||||
|
message={errorMessage}
|
||||||
|
onClose={() => setErrorMessage("")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +1,81 @@
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import fallbackLangData from "./locales/en.json";
|
||||||
import { EVENT_CHANGE, trackEvent } from "./analytics";
|
|
||||||
|
|
||||||
import fallbackLanguageData from "./locales/en.json";
|
|
||||||
import percentages from "./locales/percentages.json";
|
import percentages from "./locales/percentages.json";
|
||||||
|
|
||||||
const COMPLETION_THRESHOLD_TO_EXCEED = 85;
|
const COMPLETION_THRESHOLD = 85;
|
||||||
|
|
||||||
interface Language {
|
export interface Language {
|
||||||
lng: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
rtl?: boolean;
|
rtl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allLanguages: Language[] = [
|
export const defaultLang = { code: "en", label: "English" };
|
||||||
{ lng: "ar-SA", label: "العربية", rtl: true },
|
|
||||||
{ lng: "bg-BG", label: "Български" },
|
|
||||||
{ lng: "ca-ES", label: "Catalan" },
|
|
||||||
{ lng: "de-DE", label: "Deutsch" },
|
|
||||||
{ lng: "el-GR", label: "Ελληνικά" },
|
|
||||||
{ lng: "es-ES", label: "Español" },
|
|
||||||
{ lng: "fa-IR", label: "فارسی", rtl: true },
|
|
||||||
{ lng: "fi-FI", label: "Suomi" },
|
|
||||||
{ lng: "fr-FR", label: "Français" },
|
|
||||||
{ lng: "he-IL", label: "עברית", rtl: true },
|
|
||||||
{ lng: "hi-IN", label: "हिन्दी" },
|
|
||||||
{ lng: "hu-HU", label: "Magyar" },
|
|
||||||
{ lng: "id-ID", label: "Bahasa Indonesia" },
|
|
||||||
{ lng: "it-IT", label: "Italiano" },
|
|
||||||
{ lng: "ja-JP", label: "日本語" },
|
|
||||||
{ lng: "ko-KR", label: "한국어" },
|
|
||||||
{ lng: "my-MM", label: "Burmese" },
|
|
||||||
{ lng: "nb-NO", label: "Norsk bokmål" },
|
|
||||||
{ lng: "nl-NL", label: "Nederlands" },
|
|
||||||
{ lng: "nn-NO", label: "Norsk nynorsk" },
|
|
||||||
{ lng: "pl-PL", label: "Polski" },
|
|
||||||
{ lng: "pt-PT", label: "Português" },
|
|
||||||
{ lng: "ro-RO", label: "Română" },
|
|
||||||
{ lng: "ru-RU", label: "Русский" },
|
|
||||||
{ lng: "sk-SK", label: "Slovenčina" },
|
|
||||||
{ lng: "sv-SE", label: "Svenska" },
|
|
||||||
{ lng: "tr-TR", label: "Türkçe" },
|
|
||||||
{ lng: "uk-UA", label: "Українська" },
|
|
||||||
{ lng: "zh-CN", label: "简体中文" },
|
|
||||||
{ lng: "zh-TW", label: "繁體中文" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const languages: Language[] = [{ lng: "en", label: "English" }]
|
const allLanguages: Language[] = [
|
||||||
.concat(
|
{ code: "ar-SA", label: "العربية", rtl: true },
|
||||||
allLanguages.sort((left, right) => (left.label > right.label ? 1 : -1)),
|
{ code: "bg-BG", label: "Български" },
|
||||||
)
|
{ code: "ca-ES", label: "Català" },
|
||||||
|
{ code: "de-DE", label: "Deutsch" },
|
||||||
|
{ code: "el-GR", label: "Ελληνικά" },
|
||||||
|
{ code: "es-ES", label: "Español" },
|
||||||
|
{ code: "fa-IR", label: "فارسی", rtl: true },
|
||||||
|
{ code: "fi-FI", label: "Suomi" },
|
||||||
|
{ code: "fr-FR", label: "Français" },
|
||||||
|
{ code: "he-IL", label: "עברית", rtl: true },
|
||||||
|
{ code: "hi-IN", label: "हिन्दी" },
|
||||||
|
{ code: "hu-HU", label: "Magyar" },
|
||||||
|
{ code: "id-ID", label: "Bahasa Indonesia" },
|
||||||
|
{ code: "it-IT", label: "Italiano" },
|
||||||
|
{ code: "ja-JP", label: "日本語" },
|
||||||
|
{ code: "ko-KR", label: "한국어" },
|
||||||
|
{ code: "my-MM", label: "Burmese" },
|
||||||
|
{ code: "nb-NO", label: "Norsk bokmål" },
|
||||||
|
{ code: "nl-NL", label: "Nederlands" },
|
||||||
|
{ code: "nn-NO", label: "Norsk nynorsk" },
|
||||||
|
{ code: "pa-IN", label: "ਪੰਜਾਬੀ" },
|
||||||
|
{ code: "pl-PL", label: "Polski" },
|
||||||
|
{ code: "pt-BR", label: "Português Brasileiro" },
|
||||||
|
{ code: "pt-PT", label: "Português" },
|
||||||
|
{ code: "ro-RO", label: "Română" },
|
||||||
|
{ code: "ru-RU", label: "Русский" },
|
||||||
|
{ code: "sk-SK", label: "Slovenčina" },
|
||||||
|
{ code: "sv-SE", label: "Svenska" },
|
||||||
|
{ code: "tr-TR", label: "Türkçe" },
|
||||||
|
{ code: "uk-UA", label: "Українська" },
|
||||||
|
{ code: "zh-CN", label: "简体中文" },
|
||||||
|
{ code: "zh-TW", label: "繁體中文" },
|
||||||
|
].concat([defaultLang]);
|
||||||
|
|
||||||
|
export const languages: Language[] = allLanguages
|
||||||
|
.sort((left, right) => (left.label > right.label ? 1 : -1))
|
||||||
.filter(
|
.filter(
|
||||||
(lang) =>
|
(lang) =>
|
||||||
(percentages as Record<string, number>)[lang.lng] >
|
(percentages as Record<string, number>)[lang.code] >=
|
||||||
COMPLETION_THRESHOLD_TO_EXCEED,
|
COMPLETION_THRESHOLD,
|
||||||
);
|
);
|
||||||
|
|
||||||
let currentLanguage = languages[0];
|
let currentLang: Language = defaultLang;
|
||||||
let currentLanguageData = {};
|
let currentLangData = {};
|
||||||
const fallbackLanguage = languages[0];
|
|
||||||
|
|
||||||
export const setLanguage = async (newLng: string | undefined) => {
|
export const setLanguage = async (lang: Language) => {
|
||||||
currentLanguage =
|
currentLang = lang;
|
||||||
languages.find((language) => language.lng === newLng) || fallbackLanguage;
|
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||||
|
|
||||||
document.documentElement.dir = currentLanguage.rtl ? "rtl" : "ltr";
|
currentLangData = await import(
|
||||||
|
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
||||||
currentLanguageData = await import(
|
|
||||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLanguage.lng}.json`
|
|
||||||
);
|
);
|
||||||
languageDetector.cacheUserLanguage(currentLanguage.lng);
|
|
||||||
trackEvent(EVENT_CHANGE, "language", currentLanguage.lng);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setLanguageFirstTime = async () => {
|
export const setLanguageFirstTime = async (lang: Language) => {
|
||||||
const newLng: string | undefined = languageDetector.detect();
|
currentLang = lang;
|
||||||
|
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||||
|
|
||||||
currentLanguage =
|
currentLangData = await import(
|
||||||
languages.find((language) => language.lng === newLng) || fallbackLanguage;
|
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
||||||
|
|
||||||
document.documentElement.dir = currentLanguage.rtl ? "rtl" : "ltr";
|
|
||||||
|
|
||||||
currentLanguageData = await import(
|
|
||||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLanguage.lng}.json`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
languageDetector.cacheUserLanguage(currentLanguage.lng);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLanguage = () => currentLanguage;
|
export const getLanguage = () => currentLang;
|
||||||
|
|
||||||
const findPartsForData = (data: any, parts: string[]) => {
|
const findPartsForData = (data: any, parts: string[]) => {
|
||||||
for (let index = 0; index < parts.length; ++index) {
|
for (let index = 0; index < parts.length; ++index) {
|
||||||
@@ -106,8 +94,8 @@ const findPartsForData = (data: any, parts: string[]) => {
|
|||||||
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||||
const parts = path.split(".");
|
const parts = path.split(".");
|
||||||
let translation =
|
let translation =
|
||||||
findPartsForData(currentLanguageData, parts) ||
|
findPartsForData(currentLangData, parts) ||
|
||||||
findPartsForData(fallbackLanguageData, parts);
|
findPartsForData(fallbackLangData, parts);
|
||||||
if (translation === undefined) {
|
if (translation === undefined) {
|
||||||
throw new Error(`Can't find translation for ${path}`);
|
throw new Error(`Can't find translation for ${path}`);
|
||||||
}
|
}
|
||||||
@@ -119,12 +107,3 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
|
|||||||
}
|
}
|
||||||
return translation;
|
return translation;
|
||||||
};
|
};
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
|
||||||
languageDetector.init({
|
|
||||||
languageUtils: {
|
|
||||||
formatLanguageCode: (lng: string) => lng,
|
|
||||||
isWhitelisted: () => true,
|
|
||||||
},
|
|
||||||
checkWhitelist: false,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const IsMobileProvider = ({
|
|||||||
if (!query.current) {
|
if (!query.current) {
|
||||||
query.current = window.matchMedia
|
query.current = window.matchMedia
|
||||||
? window.matchMedia(
|
? window.matchMedia(
|
||||||
|
// keep up to date with _variables.scss
|
||||||
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
|
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
|
||||||
)
|
)
|
||||||
: (({
|
: (({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const CODES = {
|
|||||||
BRACKET_RIGHT: "BracketRight",
|
BRACKET_RIGHT: "BracketRight",
|
||||||
BRACKET_LEFT: "BracketLeft",
|
BRACKET_LEFT: "BracketLeft",
|
||||||
ONE: "Digit1",
|
ONE: "Digit1",
|
||||||
|
TWO: "Digit2",
|
||||||
NINE: "Digit9",
|
NINE: "Digit9",
|
||||||
QUOTE: "Quote",
|
QUOTE: "Quote",
|
||||||
ZERO: "Digit0",
|
ZERO: "Digit0",
|
||||||
@@ -39,6 +40,7 @@ export const KEYS = {
|
|||||||
D: "d",
|
D: "d",
|
||||||
E: "e",
|
E: "e",
|
||||||
L: "l",
|
L: "l",
|
||||||
|
O: "o",
|
||||||
P: "p",
|
P: "p",
|
||||||
Q: "q",
|
Q: "q",
|
||||||
R: "r",
|
R: "r",
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
Please do not contribute changes directly to these files, as we manage them with Crowdin. Instead:
|
Please do not contribute changes directly to these files, as we manage them with Crowdin. Instead:
|
||||||
|
|
||||||
- to request a new translation, [open an issue](https://github.com/excalidraw/excalidraw/issues/new/choose).
|
- to request a new translation, [open an issue](https://github.com/excalidraw/excalidraw/issues/new/choose).
|
||||||
- to update existing translations, [edit them on Crowdin](https://crowdin.com/translate/excalidraw/10)
|
- to update existing translations, [edit them on Crowdin](https://crowdin.com/translate/excalidraw/10) and we should have them included in the app soon!
|
||||||
and we should have them included in the app soon!
|
|
||||||
|
|
||||||
## Completion of translation
|
## Completion of translation
|
||||||
|
|
||||||
[percentages.json](./percentages.json) holds a percentage of completion for each language. We generate these
|
[percentages.json](./percentages.json) holds a percentage of completion for each language. We generate these automatically [on build time](./../../.github/workflows/locales-coverage.yml) when a new translation PR appears.
|
||||||
automatically [on build time](./../../.github/workflows/locales-coverage.yml) when a new translation PR appears.
|
|
||||||
|
|
||||||
We only make a language available on the app if it exceeds a certain threshold of completion.
|
We only make a language available on the app if it exceeds a certain threshold of completion.
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"labels": {
|
"labels": {
|
||||||
"paste": "لصق",
|
"paste": "لصق",
|
||||||
|
"pasteCharts": "لصق الرسوم البيانية",
|
||||||
"selectAll": "تحديد الكل",
|
"selectAll": "تحديد الكل",
|
||||||
"multiSelect": "إضافة عنصر للتحديد",
|
"multiSelect": "إضافة عنصر للتحديد",
|
||||||
"moveCanvas": "نقل لوح رسم",
|
"moveCanvas": "نقل لوح رسم",
|
||||||
|
"cut": "قص",
|
||||||
"copy": "نسخ",
|
"copy": "نسخ",
|
||||||
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
|
"copyAsPng": "نسخ إلى الحافظة بصيغة PNG",
|
||||||
"copyAsSvg": "نسخ بصيغة SVG",
|
"copyAsSvg": "نسخ إلى الحافظة بصيغة SVG",
|
||||||
"bringForward": "ارقع للأمام",
|
"bringForward": "جلب للأمام",
|
||||||
"sendToBack": "أرسل للخلف",
|
"sendToBack": "أرسل للخلف",
|
||||||
"bringToFront": "أحضر للأمام",
|
"bringToFront": "أحضر للأمام",
|
||||||
"sendBackward": "أنزل للوراء",
|
"sendBackward": "أرسل للخلف",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
"copyStyles": "نسخ النمط",
|
"copyStyles": "نسخ النمط",
|
||||||
"pasteStyles": "لصق النمط",
|
"pasteStyles": "لصق النمط",
|
||||||
@@ -28,6 +30,11 @@
|
|||||||
"edges": "الحواف",
|
"edges": "الحواف",
|
||||||
"sharp": "حادة",
|
"sharp": "حادة",
|
||||||
"round": "دائرية",
|
"round": "دائرية",
|
||||||
|
"arrowheads": "رؤوس الأسهم",
|
||||||
|
"arrowhead_none": "لا شيء",
|
||||||
|
"arrowhead_arrow": "سهم",
|
||||||
|
"arrowhead_bar": "شريط",
|
||||||
|
"arrowhead_dot": "نقطة",
|
||||||
"fontSize": "حجم الخط",
|
"fontSize": "حجم الخط",
|
||||||
"fontFamily": "نوع الخط",
|
"fontFamily": "نوع الخط",
|
||||||
"onlySelected": "المحدد فقط",
|
"onlySelected": "المحدد فقط",
|
||||||
@@ -54,7 +61,7 @@
|
|||||||
"architect": "معماري",
|
"architect": "معماري",
|
||||||
"artist": "رسام",
|
"artist": "رسام",
|
||||||
"cartoonist": "كرتوني",
|
"cartoonist": "كرتوني",
|
||||||
"fileTitle": "",
|
"fileTitle": "عنوان الملف",
|
||||||
"colorPicker": "اختيار الألوان",
|
"colorPicker": "اختيار الألوان",
|
||||||
"canvasBackground": "خلفية اللوحة",
|
"canvasBackground": "خلفية اللوحة",
|
||||||
"drawingCanvas": "لوحة الرسم",
|
"drawingCanvas": "لوحة الرسم",
|
||||||
@@ -63,17 +70,18 @@
|
|||||||
"language": "اللغة",
|
"language": "اللغة",
|
||||||
"createRoom": "مشاركة الجلسة مباشرة",
|
"createRoom": "مشاركة الجلسة مباشرة",
|
||||||
"duplicateSelection": "تكرار",
|
"duplicateSelection": "تكرار",
|
||||||
"untitled": "",
|
"untitled": "غير معنون",
|
||||||
"name": "الاسم",
|
"name": "الاسم",
|
||||||
"yourName": "اسمك",
|
"yourName": "اسمك",
|
||||||
"madeWithExcalidraw": "مصنوعة بواسطة Excalidraw",
|
"madeWithExcalidraw": "مصنوعة بواسطة Excalidraw",
|
||||||
"group": "تحديد مجموعة",
|
"group": "تحديد مجموعة",
|
||||||
"ungroup": "إلغاء تحديد مجموعة",
|
"ungroup": "إلغاء تحديد مجموعة",
|
||||||
"collaborators": "المتعاونون",
|
"collaborators": "المتعاونون",
|
||||||
"toggleGridMode": "التبديل إلى وضع الشبكة",
|
"gridMode": "وضع الشبكة",
|
||||||
"addToLibrary": "أضف إلى المكتبة",
|
"addToLibrary": "أضف إلى المكتبة",
|
||||||
"removeFromLibrary": "حذف من المكتبة",
|
"removeFromLibrary": "حذف من المكتبة",
|
||||||
"libraryLoadingMessage": "جارٍ تحميل المكتبة...",
|
"libraryLoadingMessage": "جارٍ تحميل المكتبة...",
|
||||||
|
"libraries": "تصفح المكتبات",
|
||||||
"loadingScene": "جاري تحميل المشهد...",
|
"loadingScene": "جاري تحميل المشهد...",
|
||||||
"align": "محاذاة",
|
"align": "محاذاة",
|
||||||
"alignTop": "محاذاة إلى اﻷعلى",
|
"alignTop": "محاذاة إلى اﻷعلى",
|
||||||
@@ -110,9 +118,10 @@
|
|||||||
"redo": "إعادة تنفيذ",
|
"redo": "إعادة تنفيذ",
|
||||||
"roomDialog": "بدء المشاركة الحية",
|
"roomDialog": "بدء المشاركة الحية",
|
||||||
"createNewRoom": "إنشاء غرفة جديدة",
|
"createNewRoom": "إنشاء غرفة جديدة",
|
||||||
"toggleFullScreen": "التبديل لوضع ملء الشاشة",
|
"fullScreen": "شاشة كاملة",
|
||||||
"toggleDarkMode": "تبديل الوضع الليلي",
|
"darkMode": "الوضع المظلم",
|
||||||
"toggleZenMode": "تبديل الوضع الليلي",
|
"lightMode": "الوضع المضيء",
|
||||||
|
"zenMode": "وضع التأمل",
|
||||||
"exitZenMode": "إلغاء الوضع الليلى"
|
"exitZenMode": "إلغاء الوضع الليلى"
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
@@ -128,7 +137,7 @@
|
|||||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||||
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||||
"imageDoesNotContainScene": "لا يحتوي ملف الصورة على بيانات المشهد. هل قمت بتمكين هذا أثناء التصدير؟",
|
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
|
||||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة"
|
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
@@ -153,6 +162,7 @@
|
|||||||
"freeDraw": "انقر واسحب، افرج عند الانتهاء",
|
"freeDraw": "انقر واسحب، افرج عند الانتهاء",
|
||||||
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
|
"text": "نصيحة: يمكنك أيضًا إضافة نص بالنقر المزدوج في أي مكان بأداة الاختيار",
|
||||||
"linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء",
|
"linearElementMulti": "انقر فوق النقطة الأخيرة أو اضغط على Esc أو Enter للإنهاء",
|
||||||
|
"lockAngle": "يمكنك تقييد الزاوية بالضغط على SHIFT",
|
||||||
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
|
"resize": "يمكنك تقييد النسب بالضغط على SHIFT أثناء تغيير الحجم،\nاضغط على ALT لتغيير الحجم من المركز",
|
||||||
"rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران",
|
"rotate": "يمكنك تقييد الزوايا من خلال الضغط على SHIFT أثناء الدوران",
|
||||||
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
|
"lineEditor_info": "انقر نقراً مزدوجاً أو اضغط Enter لتعديل النقاط",
|
||||||
@@ -189,29 +199,44 @@
|
|||||||
"errorDialog": {
|
"errorDialog": {
|
||||||
"title": "خطأ"
|
"title": "خطأ"
|
||||||
},
|
},
|
||||||
"shortcutsDialog": {
|
"helpDialog": {
|
||||||
"title": "اختصارات لوحة المفاتيح",
|
"blog": "",
|
||||||
"shapes": "الأشكال",
|
"click": "",
|
||||||
"or": "أو",
|
"curvedArrow": "",
|
||||||
"click": "انقر فوق",
|
"curvedLine": "",
|
||||||
"drag": "اسحب",
|
"documentation": "",
|
||||||
"curvedArrow": "سهم منحنى",
|
"drag": "",
|
||||||
"curvedLine": "خط منحنى",
|
"editor": "",
|
||||||
"editor": "المحرر",
|
"github": "",
|
||||||
"view": "المشهد",
|
"howto": "",
|
||||||
"blog": "اقرأ مدونتنا",
|
"or": "",
|
||||||
"howto": "اتبع دليلنا",
|
"preventBinding": "",
|
||||||
"github": "عثرت على مشكلة؟ إرسال",
|
"shapes": "",
|
||||||
"textNewLine": "إضافة سطر جديد (نص)",
|
"shortcuts": "",
|
||||||
"textFinish": "الانتهاء من تحرير (النص)",
|
"textFinish": "",
|
||||||
"zoomToFit": "تكبير لتلائم جميع العناصر",
|
"textNewLine": "",
|
||||||
"preventBinding": "منع ربط السهم"
|
"title": "",
|
||||||
|
"view": "",
|
||||||
|
"zoomToFit": "",
|
||||||
|
"zoomToSelection": ""
|
||||||
},
|
},
|
||||||
"encrypted": {
|
"encrypted": {
|
||||||
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا."
|
"tooltip": "رسوماتك مشفرة من النهاية إلى النهاية حتى أن خوادم Excalidraw لن تراها أبدا."
|
||||||
},
|
},
|
||||||
"charts": {
|
"stats": {
|
||||||
"noNumericColumn": "قمت بلصق جدول بيانات دون عمود رقمي.",
|
"angle": "الزاوية",
|
||||||
"tooManyColumns": "قمت بلصق جدول بيانات دون عمود رقمي."
|
"element": "عنصر",
|
||||||
|
"elements": "العناصر",
|
||||||
|
"height": "الارتفاع",
|
||||||
|
"scene": "المشهد",
|
||||||
|
"selected": "المحدد",
|
||||||
|
"storage": "التخزين",
|
||||||
|
"title": "إحصائيات للمهووسين",
|
||||||
|
"total": "المجموع",
|
||||||
|
"width": "العرض"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"copyStyles": "",
|
||||||
|
"copyToClipboardAsPng": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||