Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce4b64b2a3 | |||
| ef82e15ee8 | |||
| 9f6e3c5a9d | |||
| 8a106dde57 | |||
| 2dc84f04be |
@@ -1,5 +1,5 @@
|
||||
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_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.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"}'
|
||||
|
||||
@@ -1 +1 @@
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
REACT_APP_INCLUDE_GTAG=true
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 39 KiB |
@@ -1,37 +0,0 @@
|
||||
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,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,18 +30,3 @@ jobs:
|
||||
git commit -am "Auto commit: Calculate translation coverage"
|
||||
git push
|
||||
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 }}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
{
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
1. Run `npm install` to install dependencies
|
||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||
|
||||
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||
> To keep `master` branch pointing to remote repository and make
|
||||
> pull requests from branches on your fork. To do this, run:
|
||||
>
|
||||
> ```sh
|
||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||
@@ -24,41 +25,3 @@
|
||||
1. Tap on `Fork Sandbox`
|
||||
1. Write your code
|
||||
1. Commit and PR automatically
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
|
||||
|
||||
### Title
|
||||
|
||||
Make sure the title starts with a semantic prefix:
|
||||
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **improvement**: An improvement to a current feature
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||
- **perf**: A code change that improves performance
|
||||
- **test**: Adding missing tests or correcting existing tests
|
||||
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
|
||||
- **chore**: Other changes that don't modify src or test files
|
||||
- **revert**: Reverts a previous commit
|
||||
|
||||
### Changelog
|
||||
|
||||
Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
|
||||
|
||||
Notes:
|
||||
|
||||
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
|
||||
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
|
||||
|
||||
### Testing
|
||||
|
||||
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
|
||||
|
||||
It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
|
||||
|
||||
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||
|
||||
@@ -5,6 +5,7 @@ WORKDIR /opt/node_app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm i --no-optional
|
||||
|
||||
ARG REACT_APP_INCLUDE_GTAG=false
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div align="center" style="display:flex;flex-direction:column;">
|
||||
<a href="https://excalidraw.com">
|
||||
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||
</a>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end to end encrypted.</h3>
|
||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
||||
<p>
|
||||
<a href="https://twitter.com/Excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||
@@ -10,6 +10,9 @@
|
||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||
</a>
|
||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -17,51 +20,13 @@
|
||||
|
||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Shape libraries
|
||||
|
||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||
|
||||
## Developement
|
||||
## Run the code
|
||||
|
||||
### Code Sandbox
|
||||
|
||||
@@ -98,7 +63,7 @@ You can use docker-compose to work on excalidraw locally if you don't want to se
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
### Self hosting
|
||||
## Self hosting
|
||||
|
||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||
|
||||
@@ -117,11 +82,57 @@ 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.
|
||||
|
||||
## Notable used tools
|
||||
## Translating
|
||||
|
||||
- [Create React App](https://github.com/facebook/create-react-app)
|
||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||
|
||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||
|
||||
## Excalidraw is built using these awesome tools
|
||||
|
||||
- [React](https://reactjs.org)
|
||||
- [Rough.js](https://roughjs.com)
|
||||
- [TypeScript](https://www.typescriptlang.org)
|
||||
- [Vercel](https://vercel.com)
|
||||
|
||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||
|
||||
## Testimonials
|
||||
|
||||
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
|
||||
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
|
||||
|
||||
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
|
||||
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
|
||||
|
||||
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
|
||||
|
||||
## Contributors
|
||||
|
||||
### Code Contributors
|
||||
|
||||
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
|
||||
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
### Financial Contributors
|
||||
|
||||
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
||||
|
||||
#### Individuals
|
||||
|
||||
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
|
||||
|
||||
#### Organizations
|
||||
|
||||
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
||||
|
||||
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
| 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,22 +19,23 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "5.30.0",
|
||||
"@sentry/integrations": "5.30.0",
|
||||
"@testing-library/jest-dom": "5.11.9",
|
||||
"@testing-library/react": "11.2.3",
|
||||
"@types/jest": "26.0.20",
|
||||
"@sentry/browser": "5.28.0",
|
||||
"@sentry/integrations": "5.28.0",
|
||||
"@testing-library/jest-dom": "5.11.6",
|
||||
"@testing-library/react": "11.2.2",
|
||||
"@types/jest": "26.0.16",
|
||||
"@types/nanoid": "2.1.0",
|
||||
"@types/react": "17.0.0",
|
||||
"@types/react-dom": "17.0.0",
|
||||
"@types/socket.io-client": "1.4.35",
|
||||
"browser-nativefs": "0.12.0",
|
||||
"@types/socket.io-client": "1.4.34",
|
||||
"browser-nativefs": "0.11.1",
|
||||
"clsx": "1.1.1",
|
||||
"firebase": "8.2.3",
|
||||
"firebase": "8.1.2",
|
||||
"i18next-browser-languagedetector": "6.0.1",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.1.20",
|
||||
"nanoid": "2.1.11",
|
||||
"node-sass": "4.14.1",
|
||||
"open-color": "1.8.0",
|
||||
"open-color": "1.7.0",
|
||||
"pako": "1.0.11",
|
||||
"png-chunk-text": "1.0.0",
|
||||
"png-chunks-encode": "1.0.0",
|
||||
@@ -46,18 +47,19 @@
|
||||
"react-scripts": "4.0.1",
|
||||
"roughjs": "4.3.1",
|
||||
"socket.io-client": "2.3.1",
|
||||
"typescript": "4.1.3"
|
||||
"typescript": "4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash.throttle": "4.1.6",
|
||||
"@types/pako": "1.0.1",
|
||||
"eslint-config-prettier": "7.1.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"firebase-tools": "9.2.1",
|
||||
"husky": "4.3.8",
|
||||
"asar": "3.0.3",
|
||||
"eslint-config-prettier": "7.0.0",
|
||||
"eslint-plugin-prettier": "3.1.4",
|
||||
"firebase-tools": "8.17.0",
|
||||
"husky": "4.3.0",
|
||||
"jest-canvas-mock": "2.3.0",
|
||||
"lint-staged": "10.5.3",
|
||||
"pepjs": "0.5.3",
|
||||
"pepjs": "0.5.2",
|
||||
"prettier": "2.2.1",
|
||||
"rewire": "5.0.0"
|
||||
},
|
||||
@@ -80,16 +82,15 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build": "npm run build:app && npm run build:version",
|
||||
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
||||
"build:zip": "node ./scripts/build-version.js",
|
||||
"build": "npm run build:app && npm run build:zip",
|
||||
"eject": "react-scripts eject",
|
||||
"fix:code": "npm run test:code -- --fix",
|
||||
"fix:other": "npm run prettier -- --write",
|
||||
"fix": "npm run fix:other && npm run fix:code",
|
||||
"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",
|
||||
"start": "react-scripts start",
|
||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 9.0 KiB |
@@ -55,8 +55,6 @@
|
||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||
|
||||
<!-- Excalidraw version -->
|
||||
<meta name="version" content="{version}" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="FG_Virgil.woff2"
|
||||
@@ -86,10 +84,10 @@
|
||||
|
||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
@@ -97,7 +95,7 @@
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||
gtag("config", "UA-387204-13");
|
||||
</script>
|
||||
<% } %>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -25,6 +25,5 @@
|
||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"capture_links": "new_client"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 83 KiB |
@@ -2,60 +2,36 @@
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const versionFile = path.join("build", "version.json");
|
||||
const indexFile = path.join("build", "index.html");
|
||||
const asar = require("asar");
|
||||
|
||||
const versionDate = (date) => date.toISOString().replace(".000", "");
|
||||
const zero = (digit) => `0${digit}`.slice(-2);
|
||||
|
||||
const commitHash = () => {
|
||||
try {
|
||||
return require("child_process")
|
||||
.execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "none";
|
||||
}
|
||||
const versionDate = (date) => {
|
||||
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
|
||||
date.getDate(),
|
||||
)}`;
|
||||
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
|
||||
date.getSeconds(),
|
||||
)}`;
|
||||
return `${date_}-${time}`;
|
||||
};
|
||||
|
||||
const 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 now = new Date();
|
||||
|
||||
const data = JSON.stringify(
|
||||
{
|
||||
version: getFullVersion(),
|
||||
asar: "excalidraw.asar",
|
||||
version: versionDate(now),
|
||||
},
|
||||
undefined,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.writeFileSync(versionFile, data);
|
||||
fs.writeFileSync(path.join("build", "version.json"), data);
|
||||
|
||||
// https://stackoverflow.com/a/14181136/8418
|
||||
fs.readFile(indexFile, "utf8", (error, data) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
const result = data.replace(/{version}/g, getFullVersion());
|
||||
(async () => {
|
||||
const src = "build/";
|
||||
const dest = path.join("build", `excalidraw.asar`);
|
||||
|
||||
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
await asar.createPackage(src, dest);
|
||||
})();
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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,6 +3,7 @@ import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { Library } from "../data/library";
|
||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
@@ -15,6 +16,7 @@ export const actionAddToLibrary = register({
|
||||
Library.loadLibrary().then((items) => {
|
||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
return false;
|
||||
},
|
||||
contextMenuOrder: 6,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import { KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
AlignBottomIcon,
|
||||
AlignLeftIcon,
|
||||
@@ -8,15 +10,14 @@ import {
|
||||
CenterHorizontallyIcon,
|
||||
CenterVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { alignElements, Alignment } from "../align";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -43,6 +44,7 @@ const alignSelectedElements = (
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "top");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -72,6 +74,7 @@ export const actionAlignTop = register({
|
||||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -101,6 +104,7 @@ export const actionAlignBottom = register({
|
||||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "left");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -130,6 +134,7 @@ export const actionAlignLeft = register({
|
||||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "align", "right");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -159,6 +164,7 @@ export const actionAlignRight = register({
|
||||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
@@ -184,6 +190,7 @@ export const actionAlignVerticallyCentered = register({
|
||||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import React from "react";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getCommonBounds } from "../element";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import colors from "../colors";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
perform: (_, appState, value) => {
|
||||
if (value !== appState.viewBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"canvas color",
|
||||
colors.canvasBackground.includes(value)
|
||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
appState: { ...appState, viewBackgroundColor: value },
|
||||
commitToHistory: true,
|
||||
@@ -41,6 +51,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState: AppState) => {
|
||||
trackEvent(EVENT_ACTION, "clear canvas");
|
||||
return {
|
||||
elements: elements.map((element) =>
|
||||
newElementWith(element, { isDeleted: true }),
|
||||
@@ -54,7 +65,6 @@ export const actionClearCanvas = register({
|
||||
gridSize: appState.gridSize,
|
||||
shouldAddWatermark: appState.shouldAddWatermark,
|
||||
showStats: appState.showStats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
@@ -83,9 +93,9 @@ export const actionZoomIn = register({
|
||||
const zoom = getNewZoom(
|
||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||
appState.zoom,
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -116,10 +126,10 @@ export const actionZoomOut = register({
|
||||
const zoom = getNewZoom(
|
||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||
appState.zoom,
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{ x: appState.width / 2, y: appState.height / 2 },
|
||||
);
|
||||
|
||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
@@ -147,18 +157,14 @@ export const actionZoomOut = register({
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
zoom: getNewZoom(
|
||||
1 as NormalizedZoomValue,
|
||||
appState.zoom,
|
||||
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||
{
|
||||
x: appState.width / 2,
|
||||
y: appState.height / 2,
|
||||
},
|
||||
),
|
||||
zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
|
||||
x: appState.width / 2,
|
||||
y: appState.height / 2,
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -198,61 +204,38 @@ const zoomValueToFitBoundsOnViewport = (
|
||||
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({
|
||||
name: "zoomToFit",
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
perform: (elements, appState) => {
|
||||
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) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
|
||||
@@ -136,7 +136,7 @@ export const actionDeleteSelected = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.delete",
|
||||
contextMenuOrder: 999999,
|
||||
contextMenuOrder: 3,
|
||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React from "react";
|
||||
import { CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import {
|
||||
DistributeHorizontallyIcon,
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
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 { getElementMap, getNonDeletedElements } from "../element";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -39,6 +40,7 @@ const distributeSelectedElements = (
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
@@ -67,6 +69,7 @@ export const distributeHorizontally = register({
|
||||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
perform: (elements, appState) => {
|
||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import React from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
||||
import { load, save, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
perform: (_elements, appState, value) => {
|
||||
trackEvent("change", "title");
|
||||
trackEvent(EVENT_CHANGE, "title");
|
||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
@@ -55,20 +54,13 @@ export const actionChangeExportEmbedScene = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<label style={{ display: "flex" }}>
|
||||
<label title={t("labels.exportEmbedScene_details")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appState.exportEmbedScene}
|
||||
onChange={(event) => updateData(event.target.checked)}
|
||||
/>{" "}
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip
|
||||
label={t("labels.exportEmbedScene_details")}
|
||||
position="above"
|
||||
long={true}
|
||||
>
|
||||
<div className="TooltipIcon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</label>
|
||||
),
|
||||
});
|
||||
@@ -98,6 +90,7 @@ export const actionSaveScene = register({
|
||||
perform: async (elements, appState, value) => {
|
||||
try {
|
||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||
trackEvent(EVENT_IO, "save");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
@@ -128,6 +121,7 @@ export const actionSaveAsScene = register({
|
||||
...appState,
|
||||
fileHandle: null,
|
||||
});
|
||||
trackEvent(EVENT_IO, "save as");
|
||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError") {
|
||||
@@ -155,29 +149,18 @@ export const actionSaveAsScene = register({
|
||||
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
perform: async (elements, appState) => {
|
||||
try {
|
||||
const {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
} = await loadFromJSON(appState);
|
||||
return {
|
||||
elements: loadedElements,
|
||||
appState: loadedAppState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
elements,
|
||||
appState: { ...appState, errorMessage: error.message },
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
perform: (
|
||||
elements,
|
||||
appState,
|
||||
{ elements: loadedElements, appState: loadedAppState, error },
|
||||
) => ({
|
||||
elements: loadedElements,
|
||||
appState: {
|
||||
...loadedAppState,
|
||||
errorMessage: error,
|
||||
},
|
||||
commitToHistory: true,
|
||||
}),
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@@ -185,7 +168,16 @@ export const actionLoadScene = register({
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
onClick={updateData}
|
||||
onClick={() => {
|
||||
loadFromJSON(appState)
|
||||
.then(({ elements, appState }) => {
|
||||
updateData({ elements, appState });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
updateData({ error: error.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -118,14 +118,11 @@ export const actionFinalize = register({
|
||||
);
|
||||
}
|
||||
|
||||
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||
if (!appState.elementLocked) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||
!multiPointElement
|
||||
) {
|
||||
if (!appState.elementLocked || !multiPointElement) {
|
||||
resetCursor();
|
||||
}
|
||||
return {
|
||||
@@ -133,8 +130,7 @@ export const actionFinalize = register({
|
||||
appState: {
|
||||
...appState,
|
||||
elementType:
|
||||
(appState.elementLocked || appState.elementType === "draw") &&
|
||||
multiPointElement
|
||||
appState.elementLocked && multiPointElement
|
||||
? appState.elementType
|
||||
: "selection",
|
||||
draggingElement: null,
|
||||
@@ -143,9 +139,7 @@ export const actionFinalize = register({
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
multiPointElement &&
|
||||
!appState.elementLocked &&
|
||||
appState.elementType !== "draw"
|
||||
multiPointElement && !appState.elementLocked
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[multiPointElement.id]: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { HelpIcon } from "../components/HelpIcon";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
@@ -71,16 +72,17 @@ export const actionFullScreen = register({
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
perform: (_elements, appState) => {
|
||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showHelpDialog: true,
|
||||
showShortcutsDialog: true,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
|
||||
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
|
||||
),
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React from "react";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import { getClientColors, getClientInitials } from "../clients";
|
||||
import { Collaborator } from "../types";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
||||
if (!point) {
|
||||
return { appState, commitToHistory: false };
|
||||
}
|
||||
|
||||
@@ -1,53 +1,56 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../../src/types";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { getLanguage } from "../i18n";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
TextAlign,
|
||||
FontFamily,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "../element/types";
|
||||
import {
|
||||
getCommonAttributeOfSelectedElements,
|
||||
isSomeElementSelected,
|
||||
getTargetElements,
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
} from "../scene";
|
||||
import { ButtonSelect } from "../components/ButtonSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import {
|
||||
isTextElement,
|
||||
redrawTextBoundingBox,
|
||||
getNonDeletedElements,
|
||||
} from "../element";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { AppState } from "../../src/types";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
FillHachureIcon,
|
||||
FillCrossHatchIcon,
|
||||
FillSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeStyleDashedIcon,
|
||||
StrokeStyleDottedIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
ArrowheadArrowIcon,
|
||||
ArrowheadBarIcon,
|
||||
ArrowheadDotIcon,
|
||||
ArrowheadNoneIcon,
|
||||
EdgeRoundIcon,
|
||||
EdgeSharpIcon,
|
||||
FillCrossHatchIcon,
|
||||
FillHachureIcon,
|
||||
FillSolidIcon,
|
||||
SloppinessArchitectIcon,
|
||||
SloppinessArtistIcon,
|
||||
SloppinessCartoonistIcon,
|
||||
StrokeStyleDashedIcon,
|
||||
StrokeStyleDottedIcon,
|
||||
StrokeStyleSolidIcon,
|
||||
StrokeWidthIcon,
|
||||
} from "../components/icons";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||
import {
|
||||
getNonDeletedElements,
|
||||
isTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
import {
|
||||
Arrowhead,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamily,
|
||||
TextAlign,
|
||||
} from "../element/types";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
getCommonAttributeOfSelectedElements,
|
||||
getTargetElements,
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { register } from "./register";
|
||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import colors from "../colors";
|
||||
|
||||
const changeProperty = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -89,6 +92,15 @@ const getFormValue = function <T>(
|
||||
export const actionChangeStrokeColor = register({
|
||||
name: "changeStrokeColor",
|
||||
perform: (elements, appState, value) => {
|
||||
if (value !== appState.currentItemStrokeColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"stroke color",
|
||||
colors.elementStroke.includes(value)
|
||||
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -120,6 +132,16 @@ export const actionChangeStrokeColor = register({
|
||||
export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
if (value !== appState.currentItemBackgroundColor) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
"background color",
|
||||
colors.elementBackground.includes(value)
|
||||
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
|
||||
: value,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -151,6 +173,7 @@ export const actionChangeBackgroundColor = register({
|
||||
export const actionChangeFillStyle = register({
|
||||
name: "changeFillStyle",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "fill", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -200,6 +223,7 @@ export const actionChangeFillStyle = register({
|
||||
export const actionChangeStrokeWidth = register({
|
||||
name: "changeStrokeWidth",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -262,6 +286,7 @@ export const actionChangeStrokeWidth = register({
|
||||
export const actionChangeSloppiness = register({
|
||||
name: "changeSloppiness",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -310,6 +335,7 @@ export const actionChangeSloppiness = register({
|
||||
export const actionChangeStrokeStyle = register({
|
||||
name: "changeStrokeStyle",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "style", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -357,6 +383,7 @@ export const actionChangeStrokeStyle = register({
|
||||
export const actionChangeOpacity = register({
|
||||
name: "changeOpacity",
|
||||
perform: (elements, appState, value) => {
|
||||
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -553,6 +580,7 @@ export const actionChangeSharpness = register({
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.elementType);
|
||||
trackEvent(EVENT_CHANGE, "edge", value);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
@@ -614,6 +642,12 @@ export const actionChangeArrowhead = register({
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) => {
|
||||
if (isLinearElement(el)) {
|
||||
trackEvent(
|
||||
EVENT_CHANGE,
|
||||
`arrowhead ${value.position}`,
|
||||
value.type || "none",
|
||||
);
|
||||
|
||||
const { position, type } = value;
|
||||
|
||||
if (position === "start") {
|
||||
|
||||
@@ -1,32 +1,109 @@
|
||||
import {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
redrawTextBoundingBox,
|
||||
getNonDeletedElements,
|
||||
} from "../element";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { register } from "./register";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementPossibleProps,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
getSelectedElements,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
hasText,
|
||||
} from "../scene";
|
||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||
|
||||
type AppStateStyles = {
|
||||
[K in AssertSubset<
|
||||
keyof AppState,
|
||||
typeof copyableStyles[number][0]
|
||||
>]: AppState[K];
|
||||
};
|
||||
|
||||
type ElementStyles = {
|
||||
[K in AssertSubset<
|
||||
keyof ExcalidrawElementPossibleProps,
|
||||
typeof copyableStyles[number][1]
|
||||
>]: ExcalidrawElementPossibleProps[K];
|
||||
};
|
||||
|
||||
type ElemelementStylesByType = Record<ExcalidrawElement["type"], ElementStyles>;
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
let COPIED_STYLES: {
|
||||
appStateStyles: Partial<AppStateStyles>;
|
||||
elementStyles: Partial<ElementStyles>;
|
||||
elementStylesByType: Partial<ElemelementStylesByType>;
|
||||
} | null = null;
|
||||
|
||||
/* [AppState prop, ExcalidrawElement prop, predicate] */
|
||||
const copyableStyles = [
|
||||
["currentItemOpacity", "opacity", () => true],
|
||||
["currentItemStrokeColor", "strokeColor", () => true],
|
||||
["currentItemStrokeStyle", "strokeStyle", hasStroke],
|
||||
["currentItemStrokeWidth", "strokeWidth", hasStroke],
|
||||
["currentItemRoughness", "roughness", hasStroke],
|
||||
["currentItemBackgroundColor", "backgroundColor", hasBackground],
|
||||
["currentItemFillStyle", "fillStyle", hasBackground],
|
||||
["currentItemStrokeSharpness", "strokeSharpness", canChangeSharpness],
|
||||
["currentItemLinearStrokeSharpness", "strokeSharpness", isLinearElementType],
|
||||
["currentItemStartArrowhead", "startArrowhead", isLinearElementType],
|
||||
["currentItemEndArrowhead", "endArrowhead", isLinearElementType],
|
||||
["currentItemFontFamily", "fontFamily", hasText],
|
||||
["currentItemFontSize", "fontSize", hasText],
|
||||
["currentItemTextAlign", "textAlign", hasText],
|
||||
] as const;
|
||||
|
||||
const getCommonStyleProps = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): Exclude<typeof COPIED_STYLES, null> => {
|
||||
const appStateStyles = {} as AppStateStyles;
|
||||
const elementStyles = {} as ElementStyles;
|
||||
|
||||
const elementStylesByType = elements.reduce((acc, element) => {
|
||||
// only use the first element of given type
|
||||
if (!acc[element.type]) {
|
||||
acc[element.type] = {} as ElementStyles;
|
||||
copyableStyles.forEach(([appStateProp, prop, predicate]) => {
|
||||
const value = (element as any)[prop];
|
||||
if (value !== undefined && predicate(element.type)) {
|
||||
if (appStateStyles[appStateProp] === undefined) {
|
||||
(appStateStyles as any)[appStateProp] = value;
|
||||
}
|
||||
if (elementStyles[prop] === undefined) {
|
||||
(elementStyles as any)[prop] = value;
|
||||
}
|
||||
(acc as any)[element.type][prop] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, {} as ElemelementStylesByType);
|
||||
|
||||
// clone in case we ever make some of the props into non-primitives
|
||||
return JSON.parse(
|
||||
JSON.stringify({ appStateStyles, elementStyles, elementStylesByType }),
|
||||
);
|
||||
};
|
||||
|
||||
export const actionCopyStyles = register({
|
||||
name: "copyStyles",
|
||||
perform: (elements, appState) => {
|
||||
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||
if (element) {
|
||||
copiedStyles = JSON.stringify(element);
|
||||
}
|
||||
COPIED_STYLES = getCommonStyleProps(
|
||||
getSelectedElements(getNonDeletedElements(elements), appState),
|
||||
);
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
toastMessage: t("toast.copyStyles"),
|
||||
...COPIED_STYLES.appStateStyles,
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
@@ -40,31 +117,49 @@ export const actionCopyStyles = register({
|
||||
export const actionPasteStyles = register({
|
||||
name: "pasteStyles",
|
||||
perform: (elements, appState) => {
|
||||
const pastedElement = JSON.parse(copiedStyles);
|
||||
if (!isExcalidrawElement(pastedElement)) {
|
||||
if (!COPIED_STYLES) {
|
||||
return { elements, commitToHistory: false };
|
||||
}
|
||||
const getStyle = <T extends ExcalidrawElement, K extends keyof T>(
|
||||
element: T,
|
||||
prop: K,
|
||||
) => {
|
||||
return (COPIED_STYLES?.elementStylesByType[element.type]?.[
|
||||
prop as keyof ElementStyles
|
||||
] ??
|
||||
COPIED_STYLES?.elementStyles[prop as keyof ElementStyles] ??
|
||||
element[prop]) as T[K];
|
||||
};
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (appState.selectedElementIds[element.id]) {
|
||||
const newElement = newElementWith(element, {
|
||||
backgroundColor: pastedElement?.backgroundColor,
|
||||
strokeWidth: pastedElement?.strokeWidth,
|
||||
strokeColor: pastedElement?.strokeColor,
|
||||
strokeStyle: pastedElement?.strokeStyle,
|
||||
fillStyle: pastedElement?.fillStyle,
|
||||
opacity: pastedElement?.opacity,
|
||||
roughness: pastedElement?.roughness,
|
||||
});
|
||||
if (isTextElement(newElement)) {
|
||||
mutateElement(newElement, {
|
||||
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||
const commonProps = {
|
||||
backgroundColor: getStyle(element, "backgroundColor"),
|
||||
strokeWidth: getStyle(element, "strokeWidth"),
|
||||
strokeColor: getStyle(element, "strokeColor"),
|
||||
strokeStyle: getStyle(element, "strokeStyle"),
|
||||
fillStyle: getStyle(element, "fillStyle"),
|
||||
opacity: getStyle(element, "opacity"),
|
||||
roughness: getStyle(element, "roughness"),
|
||||
strokeSharpness: getStyle(element, "strokeSharpness"),
|
||||
};
|
||||
if (isTextElement(element)) {
|
||||
const newElement = newElementWith(element, {
|
||||
...commonProps,
|
||||
fontSize: getStyle(element, "fontSize"),
|
||||
fontFamily: getStyle(element, "fontFamily"),
|
||||
textAlign: getStyle(element, "textAlign"),
|
||||
});
|
||||
redrawTextBoundingBox(newElement);
|
||||
return newElement;
|
||||
} else if (isLinearElement(element)) {
|
||||
return newElementWith(element, {
|
||||
...commonProps,
|
||||
startArrowhead: getStyle(element, "startArrowhead"),
|
||||
endArrowhead: getStyle(element, "endArrowhead"),
|
||||
});
|
||||
}
|
||||
return newElement;
|
||||
return newElementWith(element, commonProps);
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
|
||||
@@ -19,9 +19,8 @@ export type ShortcutName =
|
||||
| "copyAsSvg"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "toggleGridMode"
|
||||
| "toggleStats"
|
||||
| "addToLibrary";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
@@ -34,7 +33,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
delete: [getShortcutKey("Del")],
|
||||
duplicateSelection: [
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
||||
],
|
||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||
@@ -52,9 +51,8 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
copyAsSvg: [],
|
||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
zenMode: [getShortcutKey("Alt+Z")],
|
||||
stats: [],
|
||||
toggleGridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||
toggleStats: [],
|
||||
addToLibrary: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export type ActionName =
|
||||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "toggleFullScreen"
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
export const trackEvent =
|
||||
typeof process !== "undefined" &&
|
||||
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||
typeof window !== "undefined" &&
|
||||
window.gtag
|
||||
? (category: string, name: string, label?: string, value?: number) => {
|
||||
window.gtag("event", name, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||
? (category: string, name: string, label?: string, value?: number) => {}
|
||||
: (category: string, name: string, label?: string, value?: number) => {
|
||||
// Uncomment the next line to track locally
|
||||
// console.info("Track Event", category, name, label, value);
|
||||
};
|
||||
export const EVENT_ACTION = "action";
|
||||
export const EVENT_ALIGN = "align";
|
||||
export const EVENT_CHANGE = "change";
|
||||
export const EVENT_DIALOG = "dialog";
|
||||
export const EVENT_EXIT = "exit";
|
||||
export const EVENT_IO = "io";
|
||||
export const EVENT_LAYER = "layer";
|
||||
export const EVENT_LIBRARY = "library";
|
||||
export const EVENT_LOAD = "load";
|
||||
export const EVENT_SHAPE = "shape";
|
||||
export const EVENT_SHARE = "share";
|
||||
export const EVENT_MAGIC = "magic";
|
||||
|
||||
export const trackEvent = window.gtag
|
||||
? (category: string, name: string, label?: string, value?: number) => {
|
||||
window.gtag("event", name, {
|
||||
event_category: category,
|
||||
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 {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "./constants";
|
||||
import { t } from "./i18n";
|
||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
||||
import { getDateTime } from "./utils";
|
||||
import { t } from "./i18n";
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "./constants";
|
||||
|
||||
export const getDefaultAppState = (): Omit<
|
||||
AppState,
|
||||
@@ -14,64 +14,66 @@ export const getDefaultAppState = (): Omit<
|
||||
> => {
|
||||
return {
|
||||
appearance: "light",
|
||||
collaborators: new Map(),
|
||||
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,
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
elementLocked: false,
|
||||
elementType: "selection",
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
draggingElement: null,
|
||||
resizingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
startBoundElement: null,
|
||||
editingLinearElement: null,
|
||||
elementType: "selection",
|
||||
elementLocked: false,
|
||||
exportBackground: true,
|
||||
exportEmbedScene: false,
|
||||
fileHandle: null,
|
||||
gridSize: null,
|
||||
height: window.innerHeight,
|
||||
isBindingEnabled: true,
|
||||
isLibraryOpen: false,
|
||||
isLoading: false,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
openMenu: null,
|
||||
pasteDialog: { shown: false, data: null },
|
||||
previousSelectedElementIds: {},
|
||||
resizingElement: null,
|
||||
scrolledOutside: false,
|
||||
shouldAddWatermark: false,
|
||||
currentItemStrokeColor: oc.black,
|
||||
currentItemBackgroundColor: "transparent",
|
||||
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,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
cursorButton: "up",
|
||||
scrolledOutside: false,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
isBindingEnabled: true,
|
||||
isResizing: false,
|
||||
isRotating: false,
|
||||
selectionElement: null,
|
||||
shouldAddWatermark: false,
|
||||
zoom: {
|
||||
value: 1 as NormalizedZoomValue,
|
||||
translation: { x: 0, y: 0 },
|
||||
},
|
||||
openMenu: null,
|
||||
lastPointerDownWith: "mouse",
|
||||
selectedElementIds: {},
|
||||
previousSelectedElementIds: {},
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showHelpDialog: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
showShortcutsDialog: false,
|
||||
suggestedBindings: [],
|
||||
toastMessage: null,
|
||||
viewBackgroundColor: oc.white,
|
||||
width: window.innerWidth,
|
||||
zenModeEnabled: false,
|
||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||
gridSize: null,
|
||||
editingGroupId: null,
|
||||
selectedGroupIds: {},
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
isLibraryOpen: false,
|
||||
fileHandle: null,
|
||||
collaborators: new Map(),
|
||||
showStats: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -91,25 +93,26 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||
) => config)({
|
||||
appearance: { browser: true, export: false },
|
||||
collaborators: { browser: false, export: false },
|
||||
currentChartType: { browser: true, export: false },
|
||||
currentItemBackgroundColor: { browser: true, export: false },
|
||||
currentItemEndArrowhead: { browser: true, export: false },
|
||||
currentItemFillStyle: { browser: true, export: false },
|
||||
currentItemFontFamily: { browser: true, export: false },
|
||||
currentItemFontSize: { browser: true, export: false },
|
||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||
currentItemOpacity: { browser: true, export: false },
|
||||
currentItemRoughness: { browser: true, export: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false },
|
||||
currentItemStrokeColor: { browser: true, export: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false },
|
||||
currentItemStrokeWidth: { 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 },
|
||||
cursorX: { browser: true, export: false },
|
||||
cursorY: { browser: true, export: false },
|
||||
draggingElement: { browser: false, export: false },
|
||||
editingElement: { browser: false, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
editingGroupId: { browser: true, export: false },
|
||||
editingLinearElement: { browser: false, export: false },
|
||||
elementLocked: { browser: true, export: false },
|
||||
@@ -117,7 +120,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
errorMessage: { browser: false, export: false },
|
||||
exportBackground: { browser: true, export: false },
|
||||
exportEmbedScene: { browser: true, export: false },
|
||||
fileHandle: { browser: false, export: false },
|
||||
gridSize: { browser: true, export: true },
|
||||
height: { browser: false, export: false },
|
||||
isBindingEnabled: { browser: false, export: false },
|
||||
@@ -128,10 +130,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
lastPointerDownWith: { browser: true, export: false },
|
||||
multiElement: { browser: false, export: false },
|
||||
name: { browser: true, export: false },
|
||||
offsetLeft: { browser: false, export: false },
|
||||
offsetTop: { browser: false, export: false },
|
||||
openMenu: { browser: true, export: false },
|
||||
pasteDialog: { browser: false, export: false },
|
||||
previousSelectedElementIds: { browser: true, export: false },
|
||||
resizingElement: { browser: false, export: false },
|
||||
scrolledOutside: { browser: true, export: false },
|
||||
@@ -142,15 +141,17 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
selectionElement: { browser: false, export: false },
|
||||
shouldAddWatermark: { browser: true, export: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||
showHelpDialog: { browser: false, export: false },
|
||||
showStats: { browser: true, export: false },
|
||||
startBoundElement: { browser: false, export: false },
|
||||
showShortcutsDialog: { browser: false, export: false },
|
||||
suggestedBindings: { browser: false, export: false },
|
||||
toastMessage: { browser: false, export: false },
|
||||
viewBackgroundColor: { browser: true, export: true },
|
||||
width: { browser: false, export: false },
|
||||
zenModeEnabled: { browser: true, export: false },
|
||||
zoom: { browser: true, export: false },
|
||||
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">(
|
||||
@@ -165,6 +166,11 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||
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]) {
|
||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||
stateForExport[key] = appState[key];
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
||||
import colors from "./colors";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
|
||||
import { newElement, newTextElement, newLinearElement } from "./element";
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
const BAR_WIDTH = 32;
|
||||
const BAR_GAP = 12;
|
||||
const BAR_HEIGHT = 256;
|
||||
const GRID_OPACITY = 50;
|
||||
|
||||
export interface Spreadsheet {
|
||||
title: string | null;
|
||||
@@ -21,15 +19,15 @@ export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||
|
||||
type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof NOT_SPREADSHEET }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||
const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return parseFloat(match[1].replace(/,/g, ""));
|
||||
return parseFloat(match[1]);
|
||||
};
|
||||
|
||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||
@@ -39,12 +37,12 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const numCols = cells[0].length;
|
||||
|
||||
if (numCols > 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
if (numCols === 1) {
|
||||
if (!isNumericColumn(cells, 0)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||
@@ -53,7 +51,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
);
|
||||
|
||||
if (values.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -69,7 +67,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||
|
||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||
@@ -77,7 +75,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
||||
const rows = hasHeader ? cells.slice(1) : cells;
|
||||
|
||||
if (rows.length < 2) {
|
||||
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -106,13 +104,13 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||
// For now we only accept 2 columns with an optional header
|
||||
|
||||
// Check for tab separated values
|
||||
// Check for tab separeted values
|
||||
let lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => line.trim().split("\t"));
|
||||
|
||||
// Check for comma separated files
|
||||
// Check for comma separeted files
|
||||
if (lines.length && lines[0].length !== 2) {
|
||||
lines = text
|
||||
.trim()
|
||||
@@ -121,17 +119,14 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
const numColsFirstLine = lines[0].length;
|
||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||
|
||||
if (!isSpreadsheet) {
|
||||
return {
|
||||
type: NOT_SPREADSHEET,
|
||||
reason: "All rows don't have same number of columns",
|
||||
};
|
||||
return { type: NOT_SPREADSHEET };
|
||||
}
|
||||
|
||||
const result = tryParseCells(lines);
|
||||
@@ -141,48 +136,111 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
return transposedResults;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const bgColors = colors.elementBackground.slice(
|
||||
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 = (
|
||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||
export const renderSpreadsheet = (
|
||||
spreadsheet: Spreadsheet,
|
||||
x: number,
|
||||
y: number,
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
return (
|
||||
): ExcalidrawElement[] => {
|
||||
const values = spreadsheet.values;
|
||||
const max = Math.max(...values);
|
||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||
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) => {
|
||||
return newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
@@ -193,287 +251,29 @@ const chartXLabels = (
|
||||
textAlign: "center",
|
||||
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
|
||||
? newTextElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
...commonProps,
|
||||
text: spreadsheet.title,
|
||||
x: x + chartWidth / 2,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
textAlign: "center",
|
||||
})
|
||||
: null;
|
||||
|
||||
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 [
|
||||
...(debugRect ? [debugRect] : []),
|
||||
...(title ? [title] : []),
|
||||
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||
];
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
|
||||
return [
|
||||
title,
|
||||
...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);
|
||||
...xLabels,
|
||||
xAxisLine,
|
||||
yAxisLine,
|
||||
maxValueLine,
|
||||
minYLabel,
|
||||
maxYLabel,
|
||||
].filter((element) => element !== null) as ExcalidrawElement[];
|
||||
};
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
canChangeSharpness,
|
||||
hasText,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
} from "../scene";
|
||||
import { t } from "../i18n";
|
||||
import { SHAPES } from "../shapes";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@@ -163,9 +164,9 @@ export const ShapesSwitcher = ({
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter = typeof key === "string" ? key : key[0];
|
||||
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
const shortcut = `${capitalizeString(letter)} ${t(
|
||||
"shortcutsDialog.or",
|
||||
)} ${index + 1}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className="Shape"
|
||||
@@ -180,6 +181,7 @@ export const ShapesSwitcher = ({
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
trackEvent(EVENT_SHAPE, value, "toolbar");
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
@@ -201,6 +203,9 @@ export const ShapesSwitcher = ({
|
||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||
aria-label={capitalizeString(t("toolBar.library"))}
|
||||
onClick={() => {
|
||||
if (!isLibraryOpen) {
|
||||
trackEvent(EVENT_DIALOG, "library");
|
||||
}
|
||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,164 +1,181 @@
|
||||
import { Point, simplify } from "points-on-curve";
|
||||
import React from "react";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
import rough from "roughjs/bin/rough";
|
||||
import "../actions";
|
||||
import { actionDeleteSelected, actionFinalize } from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { simplify, Point } from "points-on-curve";
|
||||
|
||||
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 } from "../actions/register";
|
||||
|
||||
import { ActionResult } from "../actions/types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { t, getLanguage } from "../i18n";
|
||||
|
||||
import {
|
||||
copyToClipboard,
|
||||
parseClipboard,
|
||||
probablySupportsClipboardBlob,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { normalizeScroll } from "../scene";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||
|
||||
import {
|
||||
APP_NAME,
|
||||
CANVAS_ONLY_ACTIONS,
|
||||
CURSOR_TYPE,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
DRAGGING_THRESHOLD,
|
||||
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
|
||||
ELEMENT_TRANSLATE_AMOUNT,
|
||||
ENV,
|
||||
EVENT,
|
||||
GRID_SIZE,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MIME_TYPES,
|
||||
POINTER_BUTTON,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
DRAGGING_THRESHOLD,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
EVENT,
|
||||
ENV,
|
||||
CANVAS_ONLY_ACTIONS,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
GRID_SIZE,
|
||||
MIME_TYPES,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
TOUCH_CTX_MENU_TIMEOUT,
|
||||
} from "../constants";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
import { isValidLibrary } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { restore } from "../data/restore";
|
||||
import {
|
||||
dragNewElement,
|
||||
dragSelectedElements,
|
||||
duplicateElement,
|
||||
getCommonBounds,
|
||||
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 LayerUI from "./LayerUI";
|
||||
import { ScrollBars, SceneState } from "../scene/types";
|
||||
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";
|
||||
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 { SceneState, ScrollBars } from "../scene/types";
|
||||
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 { getNewZoom } from "../scene/zoom";
|
||||
import { findShapeByKey } from "../shapes";
|
||||
import { restore } from "../data/restore";
|
||||
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";
|
||||
EVENT_DIALOG,
|
||||
EVENT_LIBRARY,
|
||||
EVENT_SHAPE,
|
||||
trackEvent,
|
||||
} from "../analytics";
|
||||
import { Stats } from "./Stats";
|
||||
import { Toast } from "./Toast";
|
||||
|
||||
const { history } = createHistory();
|
||||
|
||||
@@ -328,15 +345,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
offsetLeft,
|
||||
} = this.state;
|
||||
|
||||
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
|
||||
const { onCollabButtonClick } = this.props;
|
||||
const canvasScale = window.devicePixelRatio;
|
||||
|
||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||
|
||||
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
|
||||
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw"
|
||||
@@ -356,19 +370,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
elements={this.scene.getElements()}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary(
|
||||
elements,
|
||||
DEFAULT_PASTE_X,
|
||||
DEFAULT_PASTE_Y,
|
||||
)
|
||||
onInsertShape={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary(elements)
|
||||
}
|
||||
zenModeEnabled={zenModeEnabled}
|
||||
toggleZenMode={this.toggleZenMode}
|
||||
langCode={getLanguage().code}
|
||||
lng={getLanguage().lng}
|
||||
isCollaborating={this.props.isCollaborating || false}
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderCustomFooter={renderFooter}
|
||||
/>
|
||||
{this.state.showStats && (
|
||||
<Stats
|
||||
@@ -377,12 +385,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
onClose={this.toggleStats}
|
||||
/>
|
||||
)}
|
||||
{this.state.toastMessage !== null && (
|
||||
<Toast
|
||||
message={this.state.toastMessage}
|
||||
clearToast={this.clearToast}
|
||||
/>
|
||||
)}
|
||||
<main>
|
||||
<canvas
|
||||
id="canvas"
|
||||
@@ -492,7 +494,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
};
|
||||
|
||||
private importLibraryFromUrl = async (url: string) => {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||
try {
|
||||
const request = await fetch(url);
|
||||
const blob = await request.blob();
|
||||
@@ -506,6 +508,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
)
|
||||
) {
|
||||
await Library.importLibrary(blob);
|
||||
trackEvent(EVENT_LIBRARY, "import");
|
||||
this.setState({
|
||||
isLibraryOpen: true,
|
||||
});
|
||||
@@ -583,8 +586,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
scene.elements,
|
||||
{
|
||||
...scene.appState,
|
||||
width: this.state.width,
|
||||
height: this.state.height,
|
||||
offsetTop: this.state.offsetTop,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
},
|
||||
@@ -740,10 +741,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||
if (prevProps.langCode !== this.props.langCode) {
|
||||
this.updateLanguage();
|
||||
}
|
||||
|
||||
if (
|
||||
prevProps.width !== this.props.width ||
|
||||
prevProps.height !== this.props.height ||
|
||||
@@ -867,16 +864,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
|
||||
}
|
||||
|
||||
// Copy/paste
|
||||
@@ -918,7 +906,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
this.canvas!,
|
||||
this.state,
|
||||
);
|
||||
this.setState({ toastMessage: t("toast.copyToClipboardAsPng") });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ errorMessage: error.message });
|
||||
@@ -1006,12 +993,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
if (data.errorMessage) {
|
||||
this.setState({ errorMessage: data.errorMessage });
|
||||
} else if (data.spreadsheet) {
|
||||
this.setState({
|
||||
pasteDialog: {
|
||||
data: data.spreadsheet,
|
||||
shown: true,
|
||||
},
|
||||
});
|
||||
this.addElementsFromPasteOrLibrary(
|
||||
renderSpreadsheet(data.spreadsheet, cursorX, cursorY),
|
||||
);
|
||||
} else if (data.elements) {
|
||||
this.addElementsFromPasteOrLibrary(data.elements);
|
||||
} else if (data.text) {
|
||||
@@ -1136,6 +1120,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
toggleLock = () => {
|
||||
this.setState((prevState) => {
|
||||
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
|
||||
return {
|
||||
elementLocked: !prevState.elementLocked,
|
||||
elementType: prevState.elementLocked
|
||||
@@ -1159,7 +1144,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
toggleStats = () => {
|
||||
if (!this.state.showStats) {
|
||||
trackEvent("dialog", "stats");
|
||||
trackEvent(EVENT_DIALOG, "stats");
|
||||
}
|
||||
this.setState({
|
||||
showStats: !this.state.showStats,
|
||||
@@ -1176,10 +1161,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
clearToast = () => {
|
||||
this.setState({ toastMessage: null });
|
||||
};
|
||||
|
||||
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
||||
if (sceneData.commitToHistory) {
|
||||
history.resumeRecording();
|
||||
@@ -1249,7 +1230,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
if (event.key === KEYS.QUESTION_MARK) {
|
||||
this.setState({
|
||||
showHelpDialog: true,
|
||||
showShortcutsDialog: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1275,6 +1256,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}
|
||||
|
||||
if (event.code === CODES.NINE) {
|
||||
if (!this.state.isLibraryOpen) {
|
||||
trackEvent(EVENT_DIALOG, "library");
|
||||
}
|
||||
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
||||
}
|
||||
|
||||
@@ -1359,6 +1343,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
) {
|
||||
const shape = findShapeByKey(event.key);
|
||||
if (shape) {
|
||||
trackEvent(EVENT_SHAPE, shape, "shortcut");
|
||||
this.selectShapeTool(shape);
|
||||
} else if (event.key === KEYS.Q) {
|
||||
this.toggleLock();
|
||||
@@ -1431,27 +1416,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
|
||||
private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
// onGestureChange only has zoom factor but not the center.
|
||||
// If we're on iPad or iPhone, then we recognize multi-touch and will
|
||||
// zoom in at the right location on the touchMove handler already.
|
||||
// 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 },
|
||||
),
|
||||
}));
|
||||
}
|
||||
this.setState(({ zoom }) => ({
|
||||
zoom: getNewZoom(
|
||||
getNormalizedZoom(gesture.initialScale! * event.scale),
|
||||
zoom,
|
||||
{ x: cursorX, y: cursorY },
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
|
||||
@@ -1742,6 +1713,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
resetCursor();
|
||||
|
||||
if (!event[KEYS.CTRL_OR_CMD]) {
|
||||
trackEvent(EVENT_SHAPE, "text", "double-click");
|
||||
this.startTextEditing({
|
||||
sceneX,
|
||||
sceneY,
|
||||
@@ -1762,28 +1734,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
const initialScale = gesture.initialScale;
|
||||
if (
|
||||
gesture.pointers.size === 2 &&
|
||||
gesture.lastCenter &&
|
||||
initialScale &&
|
||||
gesture.initialDistance
|
||||
) {
|
||||
if (gesture.pointers.size === 2) {
|
||||
const center = getCenter(gesture.pointers);
|
||||
const deltaX = center.x - gesture.lastCenter.x;
|
||||
const deltaY = center.y - gesture.lastCenter.y;
|
||||
const deltaX = center.x - gesture.lastCenter!.x;
|
||||
const deltaY = center.y - gesture.lastCenter!.y;
|
||||
gesture.lastCenter = center;
|
||||
|
||||
const distance = getDistance(Array.from(gesture.pointers.values()));
|
||||
const scaleFactor = distance / gesture.initialDistance;
|
||||
const scaleFactor = distance / gesture.initialDistance!;
|
||||
|
||||
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
|
||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
||||
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
|
||||
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
|
||||
zoom: getNewZoom(
|
||||
getNormalizedZoom(initialScale * scaleFactor),
|
||||
getNormalizedZoom(gesture.initialScale! * scaleFactor),
|
||||
zoom,
|
||||
{ left: offsetLeft, top: offsetTop },
|
||||
center,
|
||||
),
|
||||
shouldCacheIgnoreZoom: true,
|
||||
@@ -2465,7 +2430,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
// otherwise, it will trigger selection based on current
|
||||
// state of the box
|
||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||
// if we are currently editing a group, exiting editing mode and deselect the group.
|
||||
// if we are currently editing a group, treat all selections outside of the group
|
||||
// as exiting editing mode.
|
||||
if (
|
||||
this.state.editingGroupId &&
|
||||
!isElementInGroup(hitElement, this.state.editingGroupId)
|
||||
@@ -2475,6 +2441,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add hit element to selection. At this point if we're not holding
|
||||
@@ -2612,9 +2579,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
|
||||
/* 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
|
||||
an arrow, we want it to be null for both. Otherwise, we want it to use the
|
||||
values from appState. */
|
||||
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
|
||||
values from appState. */
|
||||
|
||||
const { currentItemStartArrowhead, currentItemEndArrowhead } = this.state;
|
||||
const [startArrowhead, endArrowhead] =
|
||||
@@ -3141,7 +3108,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!elementLocked && elementType !== "draw") {
|
||||
if (!elementLocked) {
|
||||
resetCursor();
|
||||
this.setState((prevState) => ({
|
||||
draggingElement: null,
|
||||
@@ -3288,7 +3255,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elementLocked && elementType !== "draw" && draggingElement) {
|
||||
if (!elementLocked && draggingElement) {
|
||||
this.setState((prevState) => ({
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
@@ -3312,7 +3279,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (!elementLocked && elementType !== "draw") {
|
||||
if (!elementLocked) {
|
||||
resetCursor();
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
@@ -3587,6 +3554,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
transformElements(
|
||||
pointerDownState,
|
||||
transformHandleType,
|
||||
(newTransformHandle) => {
|
||||
pointerDownState.resize.handleType = newTransformHandle;
|
||||
},
|
||||
selectedElements,
|
||||
pointerDownState.resize.arrowDirection,
|
||||
getRotateWithDiscreteAngleKey(event),
|
||||
@@ -3642,21 +3612,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
||||
),
|
||||
{
|
||||
checked: this.state.gridSize !== null,
|
||||
shortcutName: "gridMode",
|
||||
label: t("labels.gridMode"),
|
||||
shortcutName: "toggleGridMode",
|
||||
label: t("labels.toggleGridMode"),
|
||||
action: this.toggleGridMode,
|
||||
},
|
||||
{
|
||||
checked: this.state.zenModeEnabled,
|
||||
shortcutName: "zenMode",
|
||||
label: t("buttons.zenMode"),
|
||||
action: this.toggleZenMode,
|
||||
},
|
||||
{
|
||||
checked: this.state.showStats,
|
||||
shortcutName: "stats",
|
||||
label: t("stats.title"),
|
||||
shortcutName: "toggleStats",
|
||||
label: t("labels.toggleStats"),
|
||||
action: this.toggleStats,
|
||||
},
|
||||
],
|
||||
@@ -3733,16 +3695,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
|
||||
zoom: getNewZoom(
|
||||
getNormalizedZoom(zoom.value - delta / 100),
|
||||
zoom,
|
||||
{ left: offsetLeft, top: offsetTop },
|
||||
{
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
},
|
||||
),
|
||||
this.setState(({ zoom }) => ({
|
||||
zoom: getNewZoom(getNormalizedZoom(zoom.value - delta / 100), zoom, {
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
}),
|
||||
selectedElementIds: {},
|
||||
previousSelectedElementIds:
|
||||
Object.keys(selectedElementIds).length !== 0
|
||||
@@ -3825,9 +3782,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
};
|
||||
|
||||
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
|
||||
if (!this.unmounted) {
|
||||
this.setState({ shouldCacheIgnoreZoom: false });
|
||||
}
|
||||
this.setState({ shouldCacheIgnoreZoom: false });
|
||||
}, 300);
|
||||
|
||||
private getCanvasOffsets(offsets?: {
|
||||
@@ -3859,14 +3814,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
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,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
||||
import { AppState } from "../types";
|
||||
import { DarkModeToggle } from "./DarkModeToggle";
|
||||
|
||||
@@ -18,6 +19,8 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
||||
<DarkModeToggle
|
||||
value={appState.appearance}
|
||||
onChange={(appearance) => {
|
||||
// TODO: track the theme on the first load too
|
||||
trackEvent(EVENT_CHANGE, "theme", appearance);
|
||||
setAppState({ appearance });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import useIsMobile from "../is-mobile";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
const CollabButton = ({
|
||||
isCollaborating,
|
||||
@@ -22,7 +23,10 @@ const CollabButton = ({
|
||||
className={clsx("CollabButton", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
onClick={onClick}
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "collaboration");
|
||||
onClick();
|
||||
}}
|
||||
icon={users}
|
||||
type="button"
|
||||
title={t("buttons.roomDialog")}
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@media #{$media-query} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "../css/_variables";
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
.excalidraw {
|
||||
.context-menu {
|
||||
@@ -32,29 +32,13 @@
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.2fr;
|
||||
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 {
|
||||
div:nth-child(1) {
|
||||
justify-self: start;
|
||||
margin-inline-end: 20px;
|
||||
}
|
||||
.context-menu-option__shortcut {
|
||||
div:nth-child(2) {
|
||||
justify-self: end;
|
||||
opacity: 0.6;
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
@@ -62,30 +46,9 @@
|
||||
.context-menu-option:hover {
|
||||
color: var(--popup-background-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 {
|
||||
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,7 +10,6 @@ import {
|
||||
} from "../actions/shortcuts";
|
||||
|
||||
type ContextMenuOption = {
|
||||
checked?: boolean;
|
||||
shortcutName: ShortcutName;
|
||||
label: string;
|
||||
action(): void;
|
||||
@@ -27,6 +26,7 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
||||
const isDarkTheme = !!document
|
||||
.querySelector(".excalidraw")
|
||||
?.classList.contains("Appearance_dark");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("excalidraw", {
|
||||
@@ -43,20 +43,15 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{options.map(({ action, checked, shortcutName, label }, idx) => (
|
||||
{options.map(({ action, shortcutName, label }, idx) => (
|
||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
||||
<button
|
||||
className={`context-menu-option
|
||||
${shortcutName === "delete" ? "dangerous" : ""}
|
||||
${checked ? "checkmark" : ""}`}
|
||||
onClick={action}
|
||||
>
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
<button className="context-menu-option" onClick={action}>
|
||||
<div>{label}</div>
|
||||
<div>
|
||||
{shortcutName
|
||||
? getShortcutFromShortcutName(shortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -14,9 +14,7 @@ export const DarkModeToggle = (props: {
|
||||
return (
|
||||
<label
|
||||
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
||||
title={
|
||||
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
|
||||
}
|
||||
title={t("buttons.toggleDarkMode")}
|
||||
>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||
@@ -25,11 +23,7 @@ export const DarkModeToggle = (props: {
|
||||
props.onChange(event.target.checked ? "dark" : "light")
|
||||
}
|
||||
checked={props.value === "dark"}
|
||||
aria-label={
|
||||
props.value === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode")
|
||||
}
|
||||
aria-label={t("buttons.toggleDarkMode")}
|
||||
/>
|
||||
<div className="ToolIcon__icon">
|
||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.Dialog {
|
||||
user-select: text;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.Dialog__title {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||
grid-gap: var(--metric);
|
||||
padding: calc(var(--space-factor) * 2);
|
||||
text-align: center;
|
||||
font-variant: small-caps;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.Dialog__titleContent {
|
||||
@@ -27,11 +18,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.Dialog__content {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@media #{$media-query} {
|
||||
.Dialog {
|
||||
--metric: calc(var(--space-factor) * 4);
|
||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||
@@ -43,8 +30,13 @@
|
||||
var(--space-factor) * 7
|
||||
);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
top: calc(-1 * var(--metric));
|
||||
margin: calc(-1 * var(--inset-right));
|
||||
margin-top: calc(-1 * var(--metric));
|
||||
margin-bottom: var(--metric);
|
||||
padding: calc(var(--space-factor) * 2);
|
||||
padding-left: var(--inset-left);
|
||||
padding-right: var(--inset-right);
|
||||
background: var(--bg-color-island);
|
||||
font-size: 1.25em;
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Modal } from "./Modal";
|
||||
import { Island } from "./Island";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, close } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
import "./Dialog.scss";
|
||||
|
||||
const useRefState = <T,>() => {
|
||||
const [refValue, setRefValue] = useState<T | null>(null);
|
||||
@@ -19,10 +20,9 @@ const useRefState = <T,>() => {
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
small?: boolean;
|
||||
maxWidth?: number;
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
}) => {
|
||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Dialog = (props: {
|
||||
|
||||
const focusableElements = queryFocusableElements(islandNode);
|
||||
|
||||
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||
if (focusableElements.length > 0) {
|
||||
// If there's an element other than close, focus it.
|
||||
(focusableElements[1] || focusableElements[0]).focus();
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export const Dialog = (props: {
|
||||
islandNode.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
}, [islandNode]);
|
||||
|
||||
const queryFocusableElements = (node: HTMLElement) => {
|
||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||
@@ -76,10 +76,10 @@ export const Dialog = (props: {
|
||||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
maxWidth={props.maxWidth}
|
||||
onCloseRequest={props.onCloseRequest}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<Island padding={4} ref={setIslandNode}>
|
||||
<h2 id="dialog-title" className="Dialog__title">
|
||||
<span className="Dialog__titleContent">{props.title}</span>
|
||||
<button
|
||||
@@ -90,7 +90,7 @@ export const Dialog = (props: {
|
||||
{useIsMobile() ? back : close}
|
||||
</button>
|
||||
</h2>
|
||||
<div className="Dialog__content">{props.children}</div>
|
||||
{props.children}
|
||||
</Island>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -24,18 +24,11 @@ export const ErrorDialog = ({
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
small
|
||||
maxWidth={500}
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
<div>
|
||||
{message.split("\n").map((line) => (
|
||||
<>
|
||||
{line}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<div>{message}</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@media (max-width: 550px) {
|
||||
.ExportDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -51,7 +51,9 @@
|
||||
.ExportDialog__actions > * {
|
||||
margin-bottom: calc(var(--space-factor) * 3);
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$media-query} {
|
||||
.ExportDialog__preview canvas {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { ActionsManagerInterface } from "../actions/types";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
@@ -66,7 +67,7 @@ const ExportModal = ({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
@@ -154,15 +155,13 @@ const ExportModal = ({
|
||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||
/>
|
||||
)}
|
||||
{onExportToBackend && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={link}
|
||||
title={t("buttons.getShareableLink")}
|
||||
aria-label={t("buttons.getShareableLink")}
|
||||
onClick={() => onExportToBackend(exportedElements)}
|
||||
/>
|
||||
)}
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={link}
|
||||
title={t("buttons.getShareableLink")}
|
||||
aria-label={t("buttons.getShareableLink")}
|
||||
onClick={() => onExportToBackend(exportedElements)}
|
||||
/>
|
||||
</Stack.Row>
|
||||
<div className="ExportDialog__name">
|
||||
{actionManager.renderAction("changeProjectName")}
|
||||
@@ -236,7 +235,7 @@ export const ExportDialog = ({
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend?: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
@@ -250,6 +249,7 @@ export const ExportDialog = ({
|
||||
<>
|
||||
<ToolButton
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "export");
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
icon={exportFile}
|
||||
@@ -260,7 +260,11 @@ export const ExportDialog = ({
|
||||
ref={triggerButton}
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<Dialog
|
||||
maxWidth={800}
|
||||
onCloseRequest={handleClose}
|
||||
title={t("buttons.export")}
|
||||
>
|
||||
<ExportModal
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
import oc from "open-color";
|
||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
@@ -16,6 +17,9 @@ export const GitHubCorner = React.memo(
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "github");
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { questionCircle } from "../components/icons";
|
||||
|
||||
type HelpIconProps = {
|
||||
title?: string;
|
||||
@@ -8,8 +7,19 @@ type HelpIconProps = {
|
||||
onClick?(): void;
|
||||
};
|
||||
|
||||
const ICON = (
|
||||
<svg
|
||||
width="30"
|
||||
height="22"
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M528 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h480c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM128 180v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm288 0v-40c0-6.627-5.373-12-12-12H172c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h232c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const HelpIcon = (props: HelpIconProps) => (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{questionCircle}</div>
|
||||
<div onClick={props.onClick}>{ICON}</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
// this is loosely based on the longest hint text
|
||||
$wide-viewport-width: 1000px;
|
||||
|
||||
.excalidraw {
|
||||
.HintViewer {
|
||||
pointer-events: none;
|
||||
@@ -19,9 +16,12 @@ $wide-viewport-width: 1000px;
|
||||
color: $oc-gray-6;
|
||||
font-size: 0.8rem;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@media (min-width: 1200px) {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@media #{$media-query} {
|
||||
position: static;
|
||||
padding-right: 2em;
|
||||
}
|
||||
|
||||
> span {
|
||||
|
||||
@@ -38,8 +38,8 @@ const getHints = ({ appState, elements }: Hint) => {
|
||||
selectedElements.length === 1
|
||||
) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle");
|
||||
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
|
||||
return null;
|
||||
}
|
||||
return t("hints.resize");
|
||||
}
|
||||
|
||||
@@ -93,16 +93,12 @@
|
||||
grid-auto-flow: column;
|
||||
grid-gap: 0.5rem;
|
||||
border-radius: 4px;
|
||||
:root[dir="rtl"] & {
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-keybinding {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
font-size: 0.7em;
|
||||
color: var(--keybinding-color);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 2px;
|
||||
@@ -111,7 +107,7 @@
|
||||
:root[dir="rtl"] & {
|
||||
left: 2px;
|
||||
}
|
||||
@media #{$is-mobile-query} {
|
||||
@media #{$media-query} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import {
|
||||
defaultLang,
|
||||
Language,
|
||||
languages,
|
||||
setLanguageFirstTime,
|
||||
} from "../i18n";
|
||||
import { setLanguageFirstTime } from "../i18n";
|
||||
|
||||
interface Props {
|
||||
langCode: Language["code"];
|
||||
}
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
export class InitializeApp extends React.Component<Props, State> {
|
||||
export class InitializeApp extends React.Component<
|
||||
any,
|
||||
{ isLoading: boolean }
|
||||
> {
|
||||
public state: { isLoading: boolean } = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const currentLang =
|
||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||
defaultLang;
|
||||
await setLanguageFirstTime(currentLang);
|
||||
await setLanguageFirstTime();
|
||||
this.setState({
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
background-color: var(--bg-color-island);
|
||||
backdrop-filter: saturate(100%) blur(10px);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius-m);
|
||||
padding: calc(var(--padding) * var(--space-factor));
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import * as i18n from "../../i18n";
|
||||
import * as i18n from "../i18n";
|
||||
|
||||
export const LanguageList = ({
|
||||
onChange,
|
||||
languages = i18n.languages,
|
||||
currentLangCode = i18n.getLanguage().code,
|
||||
currentLanguage = i18n.getLanguage().lng,
|
||||
floating,
|
||||
}: {
|
||||
languages?: { code: string; label: string }[];
|
||||
onChange: (langCode: i18n.Language["code"]) => void;
|
||||
currentLangCode?: i18n.Language["code"];
|
||||
languages?: { lng: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
currentLanguage?: string;
|
||||
floating?: boolean;
|
||||
}) => (
|
||||
<React.Fragment>
|
||||
@@ -19,15 +19,12 @@ export const LanguageList = ({
|
||||
"dropdown-select--floating": floating,
|
||||
})}
|
||||
onChange={({ target }) => onChange(target.value)}
|
||||
value={currentLangCode}
|
||||
value={currentLanguage}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
>
|
||||
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
|
||||
{i18n.defaultLang.label}
|
||||
</option>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
{languages.map((language) => (
|
||||
<option key={language.lng} value={language.lng}>
|
||||
{language.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -7,23 +7,11 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
.browse-libraries {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +39,64 @@
|
||||
width: 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 {
|
||||
|
||||
@@ -1,46 +1,56 @@
|
||||
import clsx from "clsx";
|
||||
import React, {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { Library } from "../data/library";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { exportCanvas } from "../data";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { Island } from "./Island";
|
||||
import Stack from "./Stack";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { UserList } from "./UserList";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
import { t, languages, setLanguage } from "../i18n";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import useIsMobile from "../is-mobile";
|
||||
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { CLASSES } from "../constants";
|
||||
import { shield, exportFile, load } from "./icons";
|
||||
import { GitHubCorner } from "./GitHubCorner";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, shield } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LayerUI.scss";
|
||||
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 { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import clsx from "clsx";
|
||||
import { Library } from "../data/library";
|
||||
import {
|
||||
EVENT_ACTION,
|
||||
EVENT_EXIT,
|
||||
EVENT_LIBRARY,
|
||||
trackEvent,
|
||||
} from "../analytics";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -50,17 +60,11 @@ interface LayerUIProps {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
zenModeEnabled: boolean;
|
||||
toggleZenMode: () => void;
|
||||
langCode: Language["code"];
|
||||
lng: string;
|
||||
isCollaborating: boolean;
|
||||
onExportToBackend?: (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
canvas: HTMLCanvasElement | null,
|
||||
) => void;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
@@ -114,45 +118,59 @@ const LibraryMenuItems = ({
|
||||
let addedPendingElements = false;
|
||||
|
||||
rows.push(
|
||||
<div className="layer-ui__library-header">
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
<>
|
||||
<a
|
||||
className="browse-libraries"
|
||||
href="https://libraries.excalidraw.com"
|
||||
target="_excalidraw_libraries"
|
||||
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 });
|
||||
});
|
||||
trackEvent(EVENT_EXIT, "libraries");
|
||||
}}
|
||||
/>
|
||||
<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")}
|
||||
</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++) {
|
||||
@@ -251,6 +269,7 @@ const LibraryMenu = ({
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
Library.saveLibrary(nextItems);
|
||||
trackEvent(EVENT_LIBRARY, "remove");
|
||||
setLibraryItems(nextItems);
|
||||
}, []);
|
||||
|
||||
@@ -259,6 +278,7 @@ const LibraryMenu = ({
|
||||
const items = await Library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
trackEvent(EVENT_LIBRARY, "add");
|
||||
Library.saveLibrary(nextItems);
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
@@ -293,15 +313,14 @@ const LayerUI = ({
|
||||
elements,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onInsertElements,
|
||||
onInsertShape,
|
||||
zenModeEnabled,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
onExportToBackend,
|
||||
renderCustomFooter,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// TODO: Extend tooltip component and use here.
|
||||
const renderEncryptedIcon = () => (
|
||||
<a
|
||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||
@@ -310,10 +329,14 @@ const LayerUI = ({
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_EXIT, "e2ee shield");
|
||||
}}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||
{shield}
|
||||
</Tooltip>
|
||||
<span className="tooltip-text" dir="auto">
|
||||
{t("encrypted.tooltip")}
|
||||
</span>
|
||||
{shield}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -337,7 +360,6 @@ const LayerUI = ({
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExportDialog
|
||||
elements={elements}
|
||||
@@ -346,14 +368,28 @@ const LayerUI = ({
|
||||
onExportToPng={createExporter("png")}
|
||||
onExportToSvg={createExporter("svg")}
|
||||
onExportToClipboard={createExporter("clipboard")}
|
||||
onExportToBackend={
|
||||
onExportToBackend
|
||||
? (elements) => {
|
||||
onExportToBackend &&
|
||||
onExportToBackend(elements, appState, canvas);
|
||||
onExportToBackend={async (exportedElements) => {
|
||||
if (canvas) {
|
||||
try {
|
||||
await exportCanvas(
|
||||
"backend",
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -429,7 +465,7 @@ const LayerUI = ({
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState)}
|
||||
onClickOutside={closeLibrary}
|
||||
onInsertShape={onInsertElements}
|
||||
onInsertShape={onInsertShape}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
@@ -531,7 +567,14 @@ const LayerUI = ({
|
||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{renderCustomFooter?.(false)}
|
||||
<LanguageList
|
||||
onChange={async (lng) => {
|
||||
await setLanguage(lng);
|
||||
setAppState({});
|
||||
}}
|
||||
languages={languages}
|
||||
floating
|
||||
/>
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
@@ -546,6 +589,7 @@ const LayerUI = ({
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_ACTION, "scroll to content");
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
@@ -557,8 +601,21 @@ const LayerUI = ({
|
||||
</footer>
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
return isMobile ? (
|
||||
<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.errorMessage && (
|
||||
<ErrorDialog
|
||||
@@ -566,44 +623,11 @@ const LayerUI = ({
|
||||
onClose={() => setAppState({ errorMessage: null })}
|
||||
/>
|
||||
)}
|
||||
{appState.showHelpDialog && (
|
||||
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||
)}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
setAppState={setAppState}
|
||||
appState={appState}
|
||||
onInsertChart={onInsertElements}
|
||||
onClose={() =>
|
||||
setAppState({
|
||||
pasteDialog: { shown: false, data: null },
|
||||
})
|
||||
}
|
||||
{appState.showShortcutsDialog && (
|
||||
<ShortcutsDialog
|
||||
onClose={() => setAppState({ showShortcutsDialog: false })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
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()}
|
||||
{renderBottomAppMenu()}
|
||||
{
|
||||
@@ -626,6 +650,8 @@ const LayerUI = ({
|
||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||
const {
|
||||
cursorX,
|
||||
cursorY,
|
||||
suggestedBindings,
|
||||
startBoundElement: boundElement,
|
||||
...ret
|
||||
@@ -636,8 +662,9 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
|
||||
return (
|
||||
prev.langCode === next.langCode &&
|
||||
prev.lng === next.lng &&
|
||||
prev.elements === next.elements &&
|
||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { close } from "../components/icons";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
|
||||
import "./LibraryUnit.scss";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
|
||||
// fa-plus
|
||||
const PLUS_ICON = (
|
||||
@@ -38,7 +38,7 @@ export const LibraryUnit = ({
|
||||
}
|
||||
const svg = exportToSvg(elementsToRender, {
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
viewBackgroundColor: "#fff",
|
||||
shouldAddWatermark: false,
|
||||
});
|
||||
for (const child of ref.current!.children) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import { t, setLanguage } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
import { LanguageList } from "./LanguageList";
|
||||
import { showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
@@ -14,8 +15,10 @@ import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { EVENT_ACTION, trackEvent } from "../analytics";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@@ -28,7 +31,6 @@ type MobileMenuProps = {
|
||||
onLockToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@@ -42,9 +44,9 @@ export const MobileMenu = ({
|
||||
onLockToggle,
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
}: MobileMenuProps) => (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
<FixedSideContainer side="top">
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
@@ -102,7 +104,15 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
{renderCustomFooter?.(true)}
|
||||
<fieldset>
|
||||
<legend>{t("labels.language")}</legend>
|
||||
<LanguageList
|
||||
onChange={async (lng) => {
|
||||
await setLanguage(lng);
|
||||
setAppState({});
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList mobile>
|
||||
@@ -148,6 +158,7 @@ export const MobileMenu = ({
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_ACTION, "scroll to content");
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
|
||||
@@ -30,26 +30,18 @@
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
max-height: 100%;
|
||||
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
|
||||
// for modals, reset blurry bg
|
||||
background: var(--bg-color-island);
|
||||
backdrop-filter: none;
|
||||
|
||||
border: 1px solid var(--dialog-border);
|
||||
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||
border-radius: 6px;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@media #{$media-query} {
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +68,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
.Modal__close--floating {
|
||||
position: absolute;
|
||||
right: calc(var(--space-factor) * 5);
|
||||
top: calc(var(--space-factor) * 5);
|
||||
}
|
||||
|
||||
@media #{$media-query} {
|
||||
.Modal {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,11 @@ export const Modal = (props: {
|
||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
||||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
style={{
|
||||
"--max-width": `${props.maxWidth}px`,
|
||||
maxHeight: "100%",
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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,28 +1,23 @@
|
||||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.HelpDialog h3 {
|
||||
border-bottom: 1px solid var(--button-gray-2);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.HelpDialog--island {
|
||||
.ShortcutsDialog-island {
|
||||
border: 1px solid var(--button-gray-2);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.HelpDialog--island-title {
|
||||
.ShortcutsDialog-island-title {
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
background-color: var(--button-gray-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.HelpDialog--shortcut {
|
||||
.ShorcutsDialog-shortcut {
|
||||
border-top: 1px solid var(--button-gray-2);
|
||||
}
|
||||
|
||||
.HelpDialog--key {
|
||||
.ShorcutsDialog-key {
|
||||
word-break: keep-all;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
padding: 2px 8px;
|
||||
@@ -34,23 +29,14 @@
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.HelpDialog--header {
|
||||
.ShortcutsDialog-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.HelpDialog--btn {
|
||||
border: 1px solid var(--link-color);
|
||||
padding: 8px 32px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.HelpDialog--btn:hover {
|
||||
text-decoration: none;
|
||||
border-top: 1px solid var(--button-gray-2);
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
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,10 +6,8 @@
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 999;
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close {
|
||||
@@ -31,21 +29,9 @@
|
||||
}
|
||||
tr {
|
||||
td:nth-child(2) {
|
||||
min-width: 24px;
|
||||
min-width: 48px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 12px;
|
||||
right: initial;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 24px;
|
||||
}
|
||||
.close {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export const Stats = (props: {
|
||||
<td>{t("stats.total")}</td>
|
||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||
</tr>
|
||||
|
||||
{selectedElements.length === 1 && (
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.element")}</th>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,6 +1,4 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/variables";
|
||||
|
||||
.excalidraw {
|
||||
.ToolIcon {
|
||||
display: inline-flex;
|
||||
@@ -142,7 +140,6 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
// shrink shape icons on small viewports to make them fit
|
||||
@media (max-width: 425px) {
|
||||
.Shape .ToolIcon__icon {
|
||||
width: 2rem;
|
||||
@@ -154,8 +151,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
.ToolIcon.ToolIcon__lock {
|
||||
display: inline-block;
|
||||
@@ -165,7 +160,6 @@
|
||||
|
||||
margin-left: 0;
|
||||
border-radius: 20px 0 0 20px;
|
||||
z-index: 1;
|
||||
|
||||
background-color: var(--button-gray-1);
|
||||
|
||||
@@ -187,17 +181,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.TooltipIcon {
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
margin-left: 5px;
|
||||
margin-top: 1px;
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.unlocked-icon {
|
||||
:root[dir="ltr"] & {
|
||||
left: 2px;
|
||||
|
||||
@@ -7,56 +7,39 @@
|
||||
.Tooltip__label {
|
||||
--arrow-size: 4px;
|
||||
visibility: hidden;
|
||||
width: 10ch;
|
||||
background: $oc-black;
|
||||
color: $oc-white;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
top: calc(100% + var(--arrow-size) + 3px);
|
||||
// extra pixel offset for unknown reasons
|
||||
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||
transform: translateX(-50%);
|
||||
left: calc(-50% + var(--arrow-size) / 2 - 1px);
|
||||
word-wrap: break-word;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
border: var(--arrow-size) solid transparent;
|
||||
border-bottom-color: $oc-black;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
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 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) {
|
||||
body:active .Tooltip:not(:hover) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body:not(:active) & .Tooltip:hover .Tooltip__label {
|
||||
body:not(:active) .Tooltip:hover .Tooltip__label {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,27 +5,11 @@ import React from "react";
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
position?: "above" | "below";
|
||||
long?: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
children,
|
||||
label,
|
||||
position = "below",
|
||||
long = false,
|
||||
}: TooltipProps) => (
|
||||
export const Tooltip = ({ children, label }: TooltipProps) => (
|
||||
<div className="Tooltip">
|
||||
<span
|
||||
className={
|
||||
position === "above"
|
||||
? "Tooltip__label Tooltip__label--above"
|
||||
: "Tooltip__label Tooltip__label--below"
|
||||
}
|
||||
style={{ width: long ? "50ch" : "10ch" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="Tooltip__label">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -108,11 +108,6 @@ export const redo = createIcon(
|
||||
{ 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
|
||||
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
|
||||
export const resetZoom = createIcon(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { FontFamily } from "./element/types";
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
export const DRAGGING_THRESHOLD = 10; // 10px
|
||||
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
@@ -70,7 +68,6 @@ export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
|
||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||
|
||||
@@ -88,6 +85,3 @@ export const STORAGE_KEYS = {
|
||||
// time in milliseconds
|
||||
export const TAP_TWICE_TIMEOUT = 300;
|
||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||
export const TITLE_TIMEOUT = 10000;
|
||||
export const TOAST_TIMEOUT = 5000;
|
||||
export const VERSION_TIMEOUT = 15000;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
@import "open-color/open-color.scss";
|
||||
|
||||
// keep up to date with is-mobile.tsx
|
||||
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
||||
$media-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
a {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: var(--link-color);
|
||||
color: $oc-blue-7; /* OC Blue 7 */
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
@@ -431,7 +431,6 @@
|
||||
cursor: pointer;
|
||||
fill: $oc-gray-6;
|
||||
bottom: 14px;
|
||||
width: 1.5rem;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
right: 14px;
|
||||
@@ -442,7 +441,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media #{$is-mobile-query} {
|
||||
@media #{$media-query} {
|
||||
aside {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
:root {
|
||||
--bg-color-island: rgba(255, 255, 255, 0.9);
|
||||
--popup-background-color: #{$oc-white};
|
||||
--border-radius-m: 4px;
|
||||
--space-factor: 0.25rem;
|
||||
--button-gray-1: #{$oc-gray-2};
|
||||
--button-gray-2: #{$oc-gray-4};
|
||||
@@ -14,6 +15,7 @@
|
||||
--icon-fill-color: #{$oc-black};
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--keybinding-color: #{$oc-gray-5};
|
||||
--color-overlay-text-color: #ccc;
|
||||
--sat: env(safe-area-inset-top);
|
||||
--sab: env(safe-area-inset-bottom);
|
||||
--sal: env(safe-area-inset-left);
|
||||
@@ -21,6 +23,8 @@
|
||||
--text-color-primary: #{$oc-gray-8};
|
||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
||||
--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>');
|
||||
--focus-highlight-color: #{$oc-blue-2};
|
||||
--select-highlight-color: #{$oc-blue-5};
|
||||
@@ -31,8 +35,6 @@
|
||||
--popup-secondary-background-color: #{$oc-gray-1};
|
||||
--popup-text-color: #{$oc-black};
|
||||
--popup-text-inverted-color: #{$oc-white};
|
||||
--dialog-border: #{$oc-gray-6};
|
||||
--link-color: #{$oc-blue-7};
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
@@ -58,8 +60,10 @@
|
||||
--icon-fill-color: #{$oc-gray-4};
|
||||
--icon-green-fill-color: #{$oc-green-4};
|
||||
--keybinding-color: #{$oc-gray-6};
|
||||
--color-overlay-text-color: #bbb;
|
||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
||||
--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>');
|
||||
--focus-highlight-color: #{$oc-blue-6};
|
||||
--select-highlight-color: #{$oc-blue-4};
|
||||
@@ -70,6 +74,5 @@
|
||||
--popup-secondary-background-color: #222;
|
||||
--popup-text-color: #{$oc-gray-4};
|
||||
--popup-text-inverted-color: #2c2c2c;
|
||||
--dialog-border: #{$oc-gray-9};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EVENT_IO, trackEvent } from "../analytics";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
@@ -8,7 +9,7 @@ import { AppState } from "../types";
|
||||
import { restore } from "./restore";
|
||||
import { ImportedDataState, LibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File) => {
|
||||
export const parseFileContents = async (blob: Blob | File) => {
|
||||
let contents: string;
|
||||
|
||||
if (blob.type === "image/png") {
|
||||
@@ -110,6 +111,7 @@ export const loadFromBlob = async (
|
||||
localAppState,
|
||||
);
|
||||
|
||||
trackEvent(EVENT_IO, "load", getMimeType(blob));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
import { fileSave } from "browser-nativefs";
|
||||
import { EVENT_IO, trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import {
|
||||
copyCanvasToClipboardAsPng,
|
||||
copyTextToSystemClipboard,
|
||||
} from "../clipboard";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppState } from "../types";
|
||||
import { canvasToBlob } from "./blob";
|
||||
import { AppState } from "../types";
|
||||
import { serializeAsJSON } from "./json";
|
||||
|
||||
export { loadFromBlob } from "./blob";
|
||||
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 (
|
||||
type: ExportType,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@@ -36,7 +100,7 @@ export const exportCanvas = async (
|
||||
},
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
if (type === "svg" || type === "clipboard-svg") {
|
||||
const tempSvg = exportToSvg(elements, {
|
||||
@@ -59,8 +123,10 @@ export const exportCanvas = async (
|
||||
fileName: `${name}.svg`,
|
||||
extensions: [".svg"],
|
||||
});
|
||||
trackEvent(EVENT_IO, "export", "svg");
|
||||
return;
|
||||
} else if (type === "clipboard-svg") {
|
||||
trackEvent(EVENT_IO, "export", "clipboard-svg");
|
||||
copyTextToSystemClipboard(tempSvg.outerHTML);
|
||||
return;
|
||||
}
|
||||
@@ -92,15 +158,24 @@ export const exportCanvas = async (
|
||||
fileName,
|
||||
extensions: [".png"],
|
||||
});
|
||||
trackEvent(EVENT_IO, "export", "png");
|
||||
} else if (type === "clipboard") {
|
||||
try {
|
||||
await copyCanvasToClipboardAsPng(tempCanvas);
|
||||
trackEvent(EVENT_IO, "export", "clipboard-png");
|
||||
} catch (error) {
|
||||
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(t("alerts.couldNotCopyToClipboard"));
|
||||
}
|
||||
} else if (type === "backend") {
|
||||
exportToBackend(elements, {
|
||||
...appState,
|
||||
viewBackgroundColor: exportBackground
|
||||
? appState.viewBackgroundColor
|
||||
: getDefaultAppState().viewBackgroundColor,
|
||||
});
|
||||
}
|
||||
|
||||
// clean up the DOM
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { fileOpen, fileSave } from "browser-nativefs";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
|
||||
import { fileOpen, fileSave } from "browser-nativefs";
|
||||
import { loadFromBlob } from "./blob";
|
||||
import { Library } from "./library";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
||||
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@@ -82,6 +84,7 @@ export const saveLibraryAsJSON = async () => {
|
||||
description: "Excalidraw library file",
|
||||
extensions: [".excalidrawlib"],
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "save");
|
||||
};
|
||||
|
||||
export const importLibraryFromJSON = async () => {
|
||||
@@ -90,5 +93,6 @@ export const importLibraryFromJSON = async () => {
|
||||
extensions: [".json", ".excalidrawlib"],
|
||||
mimeTypes: ["application/json"],
|
||||
});
|
||||
trackEvent(EVENT_LIBRARY, "load");
|
||||
Library.importLibrary(blob);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
export {
|
||||
resizeTest,
|
||||
getCursorForResizingElement,
|
||||
normalizeTransformHandleType,
|
||||
getElementWithTransformHandleType,
|
||||
getTransformHandleTypeFromCoords,
|
||||
} from "./resizeTest";
|
||||
|
||||
@@ -24,13 +24,13 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points } = updates as any;
|
||||
|
||||
if (typeof points !== "undefined") {
|
||||
if (points !== undefined) {
|
||||
updates = { ...getSizeFromPoints(points), ...updates };
|
||||
}
|
||||
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
if (value !== undefined) {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update in case its deep prop was mutated
|
||||
@@ -72,9 +72,9 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updates.height !== "undefined" ||
|
||||
typeof updates.width !== "undefined" ||
|
||||
typeof points !== "undefined"
|
||||
updates.height !== undefined ||
|
||||
updates.width !== undefined ||
|
||||
points !== undefined
|
||||
) {
|
||||
invalidateShapeForElement(element);
|
||||
}
|
||||
@@ -84,9 +84,12 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
Scene.getScene(element)?.informMutation();
|
||||
};
|
||||
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
export const newElementWith = <
|
||||
TElement extends ExcalidrawElement,
|
||||
K extends keyof Omit<TElement, "id" | "version" | "versionNonce">
|
||||
>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
updates: Pick<TElement, K>,
|
||||
): TElement => ({
|
||||
...element,
|
||||
...updates,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { rescalePoints } from "../points";
|
||||
import {
|
||||
rotate,
|
||||
adjustXYWithRotation,
|
||||
getFlipAdjustment,
|
||||
centerPoint,
|
||||
rotatePoint,
|
||||
} from "../math";
|
||||
@@ -12,16 +13,21 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawElement,
|
||||
} from "./types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { isLinearElement, isTextElement } from "./typeChecks";
|
||||
import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getCursorForResizingElement } from "./resizeTest";
|
||||
import {
|
||||
getCursorForResizingElement,
|
||||
normalizeTransformHandleType,
|
||||
} from "./resizeTest";
|
||||
import { measureText, getFontString } from "../utils";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import {
|
||||
@@ -43,6 +49,7 @@ const normalizeAngle = (angle: number): number => {
|
||||
export const transformElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
resizeArrowDirection: "origin" | "end",
|
||||
isRotateWithDiscreteAngle: boolean,
|
||||
@@ -94,15 +101,36 @@ export const transformElements = (
|
||||
);
|
||||
updateBoundElements(element);
|
||||
} else if (transformHandleType) {
|
||||
resizeSingleElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
shouldKeepSidesRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
if (isGenericElement(element)) {
|
||||
resizeSingleGenericElement(
|
||||
pointerDownState.originalElements.get(element.id) as typeof element,
|
||||
shouldKeepSidesRatio,
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
} else {
|
||||
const keepSquareAspectRatio = shouldKeepSidesRatio;
|
||||
resizeSingleNonGenericElement(
|
||||
element,
|
||||
transformHandleType,
|
||||
isResizeCenterPoint,
|
||||
keepSquareAspectRatio,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
setTransformHandle(
|
||||
normalizeTransformHandleType(element, transformHandleType),
|
||||
);
|
||||
if (element.width < 0) {
|
||||
mutateElement(element, { width: -element.width });
|
||||
}
|
||||
if (element.height < 0) {
|
||||
mutateElement(element, { height: -element.height });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update cursor
|
||||
@@ -386,8 +414,8 @@ const resizeSingleTextElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const resizeSingleElement = (
|
||||
stateAtResizeStart: NonDeletedExcalidrawElement,
|
||||
const resizeSingleGenericElement = (
|
||||
stateAtResizeStart: NonDeleted<ExcalidrawGenericElement>,
|
||||
shouldKeepSidesRatio: boolean,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
transformHandleDirection: TransformHandleDirection,
|
||||
@@ -395,184 +423,251 @@ const resizeSingleElement = (
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
// Gets bounds corners
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(stateAtResizeStart);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||
|
||||
// Calculate new dimensions based on cursor position
|
||||
let newWidth = stateAtResizeStart.width;
|
||||
let newHeight = stateAtResizeStart.height;
|
||||
const rotatedPointer = rotatePoint(
|
||||
[pointerX, pointerY],
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle,
|
||||
);
|
||||
|
||||
//Get bounds corners rendered on screen
|
||||
const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
const boundsCurrentHeight = esy2 - esy1;
|
||||
|
||||
// It's important we set the initial scale value based on the width and height at resize start,
|
||||
// otherwise previous dimensions affected by modifiers will be taken into account.
|
||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
||||
const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1];
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
|
||||
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||
newWidth = rotatedPointer[0] - startTopLeft[0];
|
||||
}
|
||||
if (transformHandleDirection.includes("s")) {
|
||||
scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight;
|
||||
newHeight = rotatedPointer[1] - startTopLeft[1];
|
||||
}
|
||||
if (transformHandleDirection.includes("w")) {
|
||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
||||
newWidth = startBottomRight[0] - rotatedPointer[0];
|
||||
}
|
||||
if (transformHandleDirection.includes("n")) {
|
||||
scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight;
|
||||
newHeight = startBottomRight[1] - rotatedPointer[1];
|
||||
}
|
||||
// Linear elements dimensions differ from bounds dimensions
|
||||
const eleInitialWidth = stateAtResizeStart.width;
|
||||
const eleInitialHeight = stateAtResizeStart.height;
|
||||
// We have to use dimensions of element on screen, otherwise the scaling of the
|
||||
// dimensions won't match the cursor for linear elements.
|
||||
let eleNewWidth = element.width * scaleX;
|
||||
let eleNewHeight = element.height * scaleY;
|
||||
|
||||
// adjust dimensions for resizing from center
|
||||
if (isResizeFromCenter) {
|
||||
eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
|
||||
eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
|
||||
newWidth = 2 * newWidth - stateAtResizeStart.width;
|
||||
newHeight = 2 * newHeight - stateAtResizeStart.height;
|
||||
}
|
||||
|
||||
// adjust dimensions to keep sides ratio
|
||||
if (shouldKeepSidesRatio) {
|
||||
const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
|
||||
const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
|
||||
const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width;
|
||||
const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height;
|
||||
if (transformHandleDirection.length === 1) {
|
||||
eleNewHeight *= widthRatio;
|
||||
eleNewWidth *= heightRatio;
|
||||
newHeight *= widthRatio;
|
||||
newWidth *= heightRatio;
|
||||
}
|
||||
if (transformHandleDirection.length === 2) {
|
||||
const ratio = Math.max(widthRatio, heightRatio);
|
||||
eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth);
|
||||
eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight);
|
||||
newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth);
|
||||
newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
const [
|
||||
newBoundsX1,
|
||||
newBoundsY1,
|
||||
newBoundsX2,
|
||||
newBoundsY2,
|
||||
] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
// Calculate new topLeft based on fixed corner during resize
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
let newTopLeft = startTopLeft as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleDirection)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startBottomRight[1] - Math.abs(newBoundsHeight),
|
||||
startBottomRight[0] - Math.abs(newWidth),
|
||||
startBottomRight[1] - Math.abs(newHeight),
|
||||
];
|
||||
}
|
||||
if (transformHandleDirection === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
|
||||
const bottomLeft = [
|
||||
stateAtResizeStart.x,
|
||||
stateAtResizeStart.y + stateAtResizeStart.height,
|
||||
];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)];
|
||||
}
|
||||
if (transformHandleDirection === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
|
||||
const topRight = [
|
||||
stateAtResizeStart.x + stateAtResizeStart.width,
|
||||
stateAtResizeStart.y,
|
||||
];
|
||||
newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]];
|
||||
}
|
||||
|
||||
// Keeps opposite handle fixed during resize
|
||||
if (shouldKeepSidesRatio) {
|
||||
if (["s", "n"].includes(transformHandleDirection)) {
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
newTopLeft[0] = startCenter[0] - newWidth / 2;
|
||||
}
|
||||
if (["e", "w"].includes(transformHandleDirection)) {
|
||||
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
|
||||
newTopLeft[1] = startCenter[1] - newHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Flip horizontally
|
||||
if (eleNewWidth < 0) {
|
||||
if (newWidth < 0) {
|
||||
if (transformHandleDirection.includes("e")) {
|
||||
newTopLeft[0] -= Math.abs(newBoundsWidth);
|
||||
newTopLeft[0] -= Math.abs(newWidth);
|
||||
}
|
||||
if (transformHandleDirection.includes("w")) {
|
||||
newTopLeft[0] += Math.abs(newBoundsWidth);
|
||||
newTopLeft[0] += Math.abs(newWidth);
|
||||
}
|
||||
}
|
||||
// Flip vertically
|
||||
if (eleNewHeight < 0) {
|
||||
if (newHeight < 0) {
|
||||
if (transformHandleDirection.includes("s")) {
|
||||
newTopLeft[1] -= Math.abs(newBoundsHeight);
|
||||
newTopLeft[1] -= Math.abs(newHeight);
|
||||
}
|
||||
if (transformHandleDirection.includes("n")) {
|
||||
newTopLeft[1] += Math.abs(newBoundsHeight);
|
||||
newTopLeft[1] += Math.abs(newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (isResizeFromCenter) {
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
|
||||
newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2;
|
||||
newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2;
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
newTopLeft[0] + Math.abs(newWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
|
||||
// Readjust points for linear elements
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
);
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtResizeStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtResizeStart.y - newBoundsY1;
|
||||
|
||||
const resizedElement = {
|
||||
width: Math.abs(eleNewWidth),
|
||||
height: Math.abs(eleNewHeight),
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
...rescaledPoints,
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(newHeight),
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
};
|
||||
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 (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
nextWidth !== 0 &&
|
||||
nextHeight !== 0 &&
|
||||
Number.isFinite(nextElementX) &&
|
||||
Number.isFinite(nextElementY)
|
||||
) {
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
mutateElement(element, {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: nextElementX,
|
||||
y: nextElementY,
|
||||
...rescaledPoints,
|
||||
});
|
||||
mutateElement(element, resizedElement);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -173,3 +173,57 @@ export const getCursorForResizingElement = (resizingElement: {
|
||||
|
||||
return cursor ? `${cursor}-resize` : "";
|
||||
};
|
||||
|
||||
export const normalizeTransformHandleType = (
|
||||
element: ExcalidrawElement,
|
||||
transformHandleType: TransformHandleType,
|
||||
): TransformHandleType => {
|
||||
if (element.width >= 0 && element.height >= 0) {
|
||||
return transformHandleType;
|
||||
}
|
||||
|
||||
if (element.width < 0 && element.height < 0) {
|
||||
switch (transformHandleType) {
|
||||
case "nw":
|
||||
return "se";
|
||||
case "ne":
|
||||
return "sw";
|
||||
case "se":
|
||||
return "nw";
|
||||
case "sw":
|
||||
return "ne";
|
||||
}
|
||||
} else if (element.width < 0) {
|
||||
switch (transformHandleType) {
|
||||
case "nw":
|
||||
return "ne";
|
||||
case "ne":
|
||||
return "nw";
|
||||
case "se":
|
||||
return "sw";
|
||||
case "sw":
|
||||
return "se";
|
||||
case "e":
|
||||
return "w";
|
||||
case "w":
|
||||
return "e";
|
||||
}
|
||||
} else {
|
||||
switch (transformHandleType) {
|
||||
case "nw":
|
||||
return "sw";
|
||||
case "ne":
|
||||
return "se";
|
||||
case "se":
|
||||
return "ne";
|
||||
case "sw":
|
||||
return "nw";
|
||||
case "n":
|
||||
return "s";
|
||||
case "s":
|
||||
return "n";
|
||||
}
|
||||
}
|
||||
|
||||
return transformHandleType;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Point } from "../types";
|
||||
import { FONT_FAMILY } from "../constants";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid";
|
||||
export type FontFamily = keyof typeof FONT_FAMILY;
|
||||
export type FontString = string & { _brand: "fontString" };
|
||||
@@ -22,26 +21,16 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
strokeWidth: number;
|
||||
strokeStyle: StrokeStyle;
|
||||
strokeSharpness: StrokeSharpness;
|
||||
roughness: number;
|
||||
roughness: 0 | 1 | 2;
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
/** Random integer used to seed shape generation so that the roughjs shape
|
||||
doesn't differ across renders. */
|
||||
seed: number;
|
||||
/** Integer that is sequentially incremented on each change. Used to reconcile
|
||||
elements during collaboration or when saving to server. */
|
||||
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;
|
||||
isDeleted: boolean;
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
/** Ids of (linear) elements that are bound to this element. */
|
||||
boundElementIds: readonly ExcalidrawLinearElement["id"][] | null;
|
||||
}>;
|
||||
|
||||
@@ -121,3 +110,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawElementTypes = Pick<ExcalidrawElement, "type">["type"];
|
||||
|
||||
/** @private */
|
||||
type __ExcalidrawElementPossibleProps_withoutType<T> = T extends any
|
||||
? { [K in keyof Omit<T, "type">]: T[K] }
|
||||
: never;
|
||||
|
||||
/** Do not use for anything unless you really need it for some abstract
|
||||
API types */
|
||||
export type ExcalidrawElementPossibleProps = UnionToIntersection<
|
||||
__ExcalidrawElementPossibleProps_withoutType<ExcalidrawElement>
|
||||
> & { type: ExcalidrawElementTypes };
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import React, { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { AppState, Collaborator, Gesture } from "../../types";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
import {
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
SCENE,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import { ENV, EVENT } from "../../constants";
|
||||
|
||||
import {
|
||||
decryptAESGEM,
|
||||
generateCollaborationLink,
|
||||
getCollaborationLinkData,
|
||||
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 {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
STORAGE_KEYS,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
import {
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||
import {
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
SCENE,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||
|
||||
interface CollabState {
|
||||
isCollaborating: boolean;
|
||||
@@ -153,7 +157,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
|
||||
window.history.pushState(
|
||||
{},
|
||||
"Excalidraw",
|
||||
await generateCollaborationLink(),
|
||||
);
|
||||
const elements = this.excalidrawRef.current!.getSceneElements();
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
@@ -164,13 +172,15 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
trackEvent(EVENT_SHARE, "session start");
|
||||
return this.initializeSocketClient();
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.saveCollabRoomToFirebase();
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
window.history.pushState({}, "Excalidraw", window.location.origin);
|
||||
this.destroySocketClient();
|
||||
trackEvent(EVENT_SHARE, "session end");
|
||||
};
|
||||
|
||||
private destroySocketClient = () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useRef } from "react";
|
||||
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||
import { Dialog } from "../../components/Dialog";
|
||||
import { clipboard, start, stop } from "../../components/icons";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { t } from "../../i18n";
|
||||
import { Dialog } from "../../components/Dialog";
|
||||
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { clipboard, start, stop } from "../../components/icons";
|
||||
|
||||
import "./RoomDialog.scss";
|
||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||
|
||||
const RoomDialog = ({
|
||||
handleClose,
|
||||
@@ -28,6 +30,7 @@ const RoomDialog = ({
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
trackEvent(EVENT_SHARE, "copy link");
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
@@ -92,6 +95,7 @@ const RoomDialog = ({
|
||||
value={username || ""}
|
||||
className="RoomDialog-username TextInput"
|
||||
onChange={(event) => onUsernameChange(event.target.value)}
|
||||
onBlur={() => trackEvent(EVENT_SHARE, "name")}
|
||||
onKeyPress={(event) => event.key === "Enter" && handleClose()}
|
||||
/>
|
||||
</div>
|
||||
@@ -119,7 +123,11 @@ const RoomDialog = ({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Dialog small onCloseRequest={handleClose} title={t("labels.createRoom")}>
|
||||
<Dialog
|
||||
maxWidth={800}
|
||||
onCloseRequest={handleClose}
|
||||
title={t("labels.createRoom")}
|
||||
>
|
||||
{renderRoomDialog()}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { serializeAsJSON } from "../../data/json";
|
||||
import { restore } from "../../data/restore";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { restore } from "../../data/restore";
|
||||
import { EVENT_ACTION, trackEvent } from "../../analytics";
|
||||
|
||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||
|
||||
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_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
||||
|
||||
const generateRandomID = async () => {
|
||||
const arr = new Uint8Array(10);
|
||||
@@ -191,6 +190,7 @@ const importFromBackend = async (
|
||||
data = await response.json();
|
||||
}
|
||||
|
||||
trackEvent(EVENT_ACTION, "import");
|
||||
return {
|
||||
elements: data.elements || null,
|
||||
appState: data.appState || null,
|
||||
@@ -228,59 +228,3 @@ export const loadScene = async (
|
||||
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,53 +1,28 @@
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import React, {
|
||||
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 React, { useState, useLayoutEffect, useEffect, useRef } from "react";
|
||||
|
||||
import Excalidraw from "../packages/excalidraw/index";
|
||||
|
||||
import {
|
||||
getTotalStorageSize,
|
||||
importFromLocalStorage,
|
||||
saveToLocalStorage,
|
||||
STORAGE_KEYS,
|
||||
} from "./data/localStorage";
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {
|
||||
formatLanguageCode: (langCode: Language["code"]) => langCode,
|
||||
isWhitelisted: () => true,
|
||||
},
|
||||
checkWhitelist: false,
|
||||
});
|
||||
import { ImportedDataState } from "../data/types";
|
||||
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
|
||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import { t } from "../i18n";
|
||||
import { loadScene } from "./data";
|
||||
import { getCollaborationLinkData } from "./data";
|
||||
import { EVENT } from "../constants";
|
||||
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<
|
||||
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
|
||||
@@ -112,6 +87,7 @@ type Scene = ImportedDataState & { commitToHistory: boolean };
|
||||
const initializeScene = async (opts: {
|
||||
resetScene: ExcalidrawImperativeAPI["resetScene"];
|
||||
initializeSocketClient: CollabAPI["initializeSocketClient"];
|
||||
onLateInitialization?: (scene: Scene) => void;
|
||||
}): Promise<Scene | null> => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const id = searchParams.get("id");
|
||||
@@ -137,24 +113,26 @@ const initializeScene = async (opts: {
|
||||
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
||||
}
|
||||
if (!isCollabScene) {
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||
}
|
||||
} else {
|
||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||
if (document.hidden) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() => initializeScene(opts).then(resolve).catch(reject),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
window.addEventListener(
|
||||
"focus",
|
||||
() =>
|
||||
initializeScene(opts).then((_scene) => {
|
||||
opts?.onLateInitialization?.(_scene || scene);
|
||||
}),
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
isCollabScene = false;
|
||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||
}
|
||||
}
|
||||
if (isCollabScene) {
|
||||
@@ -162,6 +140,7 @@ const initializeScene = async (opts: {
|
||||
// into the remote scene
|
||||
opts.resetScene();
|
||||
const scenePromise = opts.initializeSocketClient();
|
||||
trackEvent(EVENT_SHARE, "session join");
|
||||
|
||||
try {
|
||||
const [, roomId, roomKey] = getCollaborationLinkData(
|
||||
@@ -199,9 +178,6 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const currentLangCode = languageDetector.detect() || defaultLang.code;
|
||||
const [langCode, setLangCode] = useState(currentLangCode);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const onResize = () => {
|
||||
@@ -229,15 +205,19 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
||||
const { collab } = props;
|
||||
|
||||
useEffect(() => {
|
||||
// Delayed so that the app has a time to load the latest SW
|
||||
setTimeout(() => {
|
||||
trackEvent("load", "version", getVersion());
|
||||
}, VERSION_TIMEOUT);
|
||||
|
||||
const storageSize = getTotalStorageSize();
|
||||
if (storageSize) {
|
||||
trackEvent(EVENT_LOAD, "storage", "size", storageSize);
|
||||
} else {
|
||||
trackEvent(EVENT_LOAD, "first time");
|
||||
}
|
||||
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
|
||||
initializeScene({
|
||||
resetScene: excalidrawApi.resetScene,
|
||||
initializeSocketClient: collab.initializeSocketClient,
|
||||
onLateInitialization: (scene) => {
|
||||
initialStatePromiseRef.current.promise.resolve(scene);
|
||||
},
|
||||
}).then((scene) => {
|
||||
initialStatePromiseRef.current.promise.resolve(scene);
|
||||
});
|
||||
@@ -260,10 +240,6 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.addEventListener(EVENT.UNLOAD, onBlur, false);
|
||||
window.addEventListener(EVENT.BLUR, onBlur, false);
|
||||
@@ -271,14 +247,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
||||
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
||||
window.removeEventListener(EVENT.UNLOAD, onBlur, false);
|
||||
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [collab.initializeSocketClient]);
|
||||
|
||||
useEffect(() => {
|
||||
languageDetector.cacheUserLanguage(langCode);
|
||||
}, [langCode]);
|
||||
|
||||
const onChange = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@@ -289,80 +260,18 @@ 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 (
|
||||
<>
|
||||
<Excalidraw
|
||||
ref={excalidrawRef}
|
||||
onChange={onChange}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
user={{ name: collab.username }}
|
||||
onCollabButtonClick={collab.onCollabButtonClick}
|
||||
isCollaborating={collab.isCollaborating}
|
||||
onPointerUpdate={collab.onPointerUpdate}
|
||||
onExportToBackend={onExportToBackend}
|
||||
renderFooter={renderFooter}
|
||||
langCode={langCode}
|
||||
/>
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
onClose={() => setErrorMessage("")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<Excalidraw
|
||||
ref={excalidrawRef}
|
||||
onChange={onChange}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
user={{ name: collab.username }}
|
||||
onCollabButtonClick={collab.onCollabButtonClick}
|
||||
isCollaborating={collab.isCollaborating}
|
||||
onPointerUpdate={collab.onPointerUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,15 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
|
||||
Required<Pick<T, RK>>;
|
||||
|
||||
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (
|
||||
x: infer R,
|
||||
) => any
|
||||
? R
|
||||
: never;
|
||||
|
||||
/** Assert K is a subset of T, and returns K */
|
||||
type AssertSubset<T, K extends T> = K;
|
||||
|
||||
// PNG encoding/decoding
|
||||
// -----------------------------------------------------------------------------
|
||||
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
|
||||
|
||||
@@ -1,81 +1,93 @@
|
||||
import fallbackLangData from "./locales/en.json";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { EVENT_CHANGE, trackEvent } from "./analytics";
|
||||
|
||||
import fallbackLanguageData from "./locales/en.json";
|
||||
import percentages from "./locales/percentages.json";
|
||||
|
||||
const COMPLETION_THRESHOLD = 85;
|
||||
const COMPLETION_THRESHOLD_TO_EXCEED = 85;
|
||||
|
||||
export interface Language {
|
||||
code: string;
|
||||
interface Language {
|
||||
lng: string;
|
||||
label: string;
|
||||
rtl?: boolean;
|
||||
}
|
||||
|
||||
export const defaultLang = { code: "en", label: "English" };
|
||||
|
||||
const allLanguages: Language[] = [
|
||||
{ code: "ar-SA", label: "العربية", rtl: true },
|
||||
{ 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]);
|
||||
{ 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[] = allLanguages
|
||||
.sort((left, right) => (left.label > right.label ? 1 : -1))
|
||||
export const languages: Language[] = [{ lng: "en", label: "English" }]
|
||||
.concat(
|
||||
allLanguages.sort((left, right) => (left.label > right.label ? 1 : -1)),
|
||||
)
|
||||
.filter(
|
||||
(lang) =>
|
||||
(percentages as Record<string, number>)[lang.code] >=
|
||||
COMPLETION_THRESHOLD,
|
||||
(percentages as Record<string, number>)[lang.lng] >
|
||||
COMPLETION_THRESHOLD_TO_EXCEED,
|
||||
);
|
||||
|
||||
let currentLang: Language = defaultLang;
|
||||
let currentLangData = {};
|
||||
let currentLanguage = languages[0];
|
||||
let currentLanguageData = {};
|
||||
const fallbackLanguage = languages[0];
|
||||
|
||||
export const setLanguage = async (lang: Language) => {
|
||||
currentLang = lang;
|
||||
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||
export const setLanguage = async (newLng: string | undefined) => {
|
||||
currentLanguage =
|
||||
languages.find((language) => language.lng === newLng) || fallbackLanguage;
|
||||
|
||||
currentLangData = await import(
|
||||
/* 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);
|
||||
trackEvent(EVENT_CHANGE, "language", currentLanguage.lng);
|
||||
};
|
||||
|
||||
export const setLanguageFirstTime = async (lang: Language) => {
|
||||
currentLang = lang;
|
||||
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||
export const setLanguageFirstTime = async () => {
|
||||
const newLng: string | undefined = languageDetector.detect();
|
||||
|
||||
currentLangData = await import(
|
||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
||||
currentLanguage =
|
||||
languages.find((language) => language.lng === newLng) || fallbackLanguage;
|
||||
|
||||
document.documentElement.dir = currentLanguage.rtl ? "rtl" : "ltr";
|
||||
|
||||
currentLanguageData = await import(
|
||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLanguage.lng}.json`
|
||||
);
|
||||
|
||||
languageDetector.cacheUserLanguage(currentLanguage.lng);
|
||||
};
|
||||
|
||||
export const getLanguage = () => currentLang;
|
||||
export const getLanguage = () => currentLanguage;
|
||||
|
||||
const findPartsForData = (data: any, parts: string[]) => {
|
||||
for (let index = 0; index < parts.length; ++index) {
|
||||
@@ -94,8 +106,8 @@ const findPartsForData = (data: any, parts: string[]) => {
|
||||
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||
const parts = path.split(".");
|
||||
let translation =
|
||||
findPartsForData(currentLangData, parts) ||
|
||||
findPartsForData(fallbackLangData, parts);
|
||||
findPartsForData(currentLanguageData, parts) ||
|
||||
findPartsForData(fallbackLanguageData, parts);
|
||||
if (translation === undefined) {
|
||||
throw new Error(`Can't find translation for ${path}`);
|
||||
}
|
||||
@@ -107,3 +119,12 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||
}
|
||||
return translation;
|
||||
};
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {
|
||||
formatLanguageCode: (lng: string) => lng,
|
||||
isWhitelisted: () => true,
|
||||
},
|
||||
checkWhitelist: false,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ export const IsMobileProvider = ({
|
||||
if (!query.current) {
|
||||
query.current = window.matchMedia
|
||||
? window.matchMedia(
|
||||
// keep up to date with _variables.scss
|
||||
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
|
||||
)
|
||||
: (({
|
||||
|
||||
@@ -9,7 +9,6 @@ export const CODES = {
|
||||
BRACKET_RIGHT: "BracketRight",
|
||||
BRACKET_LEFT: "BracketLeft",
|
||||
ONE: "Digit1",
|
||||
TWO: "Digit2",
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
@@ -40,7 +39,6 @@ export const KEYS = {
|
||||
D: "d",
|
||||
E: "e",
|
||||
L: "l",
|
||||
O: "o",
|
||||
P: "p",
|
||||
Q: "q",
|
||||
R: "r",
|
||||
|
||||