Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle
fe973e3513 purify getDefaultAppState by removing name 2021-01-03 23:53:30 +01:00
199 changed files with 10526 additions and 39366 deletions

View File

@@ -1 +1 @@
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
REACT_APP_INCLUDE_GTAG=true

View File

@@ -2,7 +2,6 @@
"extends": ["prettier", "react-app"],
"plugins": ["prettier"],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"curly": "warn",
"dot-notation": "warn",
"import/no-anonymous-default-export": "off",
@@ -23,6 +22,7 @@
],
"no-unneeded-ternary": "warn",
"no-unused-expressions": "warn",
"no-unused-vars": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"object-shorthand": "warn",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -6,7 +6,7 @@ on:
- master
jobs:
build:
build-docker:
runs-on: ubuntu-latest
steps:

View File

@@ -13,10 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 14.x
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 14.x
node-version: 12.x
- name: Install dependencies
run: |

View File

@@ -1,18 +0,0 @@
name: Cancel previous runs
on:
push:
branches:
- master
pull_request:
jobs:
cancel:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: styfle/cancel-workflow-action@0.6.0
with:
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
access_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,10 @@
name: Lint
on: pull_request
on:
push:
branches:
- master
pull_request:
jobs:
lint:
@@ -9,10 +13,10 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 14.x
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 14.x
node-version: 12.x
- name: Install and lint
run: |
@@ -20,3 +24,5 @@ jobs:
npm run test:other
npm run test:code
npm run test:typecheck
env:
CI: true

View File

@@ -14,18 +14,18 @@ jobs:
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 14.x
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 14.x
node-version: 12.x
- name: Create report file
run: |
npm run locales-coverage
FILE_CHANGED=$(git diff src/locales/percentages.json)
if [ ! -z "${FILE_CHANGED}" ]; then
git config --global user.name 'Excalidraw Bot'
git config --global user.email 'bot@excalidraw.com'
git config --global user.name 'Kostas Bariotis'
git config --global user.email 'konmpar@gmail.com'
git add src/locales/percentages.json
git commit -am "Auto commit: Calculate translation coverage"
git push
@@ -43,5 +43,5 @@ jobs:
uses: kt3k/update-pr-description@v1.0.1
with:
pr_body: ${{ steps.getCommentBody.outputs.body }}
pr_title: "chore: Update translations from Crowdin"
pr_title: "chore: New Crowdin updates"
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}

View File

@@ -10,8 +10,7 @@ on:
jobs:
main:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v3.0.0
- uses: amannn/action-semantic-pull-request@v2.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -8,14 +8,13 @@ on:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1.0.0
- name: Setup Node.js 14.x
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 14.x
node-version: 12.x
- name: Install and build
run: |

View File

@@ -1,6 +1,10 @@
name: Tests
on: pull_request
on:
push:
branches:
- master
pull_request:
jobs:
test:
@@ -9,12 +13,14 @@ jobs:
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 14.x
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 14.x
node-version: 12.x
- name: Install and test
run: |
npm ci
npm run test:app
env:
CI: true

View File

@@ -1,4 +1,3 @@
{
"proseWrap": "never",
"trailingComma": "all"
}

View File

@@ -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
@@ -19,45 +20,8 @@
### Option 2 - CodeSandbox
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
1. Connect your GitHub account
1. Connect your Github account
1. Go to Git tab on left side
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
- **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.

View File

@@ -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 . .

122
README.md
View File

@@ -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,76 +10,28 @@
<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>
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
</div>
## Try it now
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/).
## Supporting Excalidraw
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
## 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 thats going to dispatch messages to everyone that knows this number.
The second set of digits is the encryption key. The Excalidraw server doesnt 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).
## Embedding Excalidraw in your App?
Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw). This package allows you to easily embed Excalidraw as a React component into your apps.
## Development
## Run the code
### Code Sandbox
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
- You may need to sign in with GitHub and reload the page
- You may need to sign in with Github and reload the page
- You can start coding instantly, and even send PRs from there!
### Local Installation
@@ -105,15 +57,15 @@ git clone https://github.com/excalidraw/excalidraw.git
#### Docker Compose
You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
```sh
docker-compose up --build -d
```
### Self-hosting
## Self hosting
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self-host your own client under your own domain, on Kubernetes, AWS ECS, etc.
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
```sh
docker build -t excalidraw/excalidraw .
@@ -124,17 +76,63 @@ The Docker image is free of analytics and other tracking libraries.
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
We are working towards providing a full-fledged solution for self-hosting your own Excalidraw.
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
## Contributing
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>

64
analytics.md Normal file
View File

@@ -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` |

View File

@@ -21,5 +21,12 @@
}
]
}
],
"redirects": [
{
"source": "/([^.]+)",
"destination": "/",
"statusCode": 301
}
]
}

34894
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,22 +19,23 @@
]
},
"dependencies": {
"@sentry/browser": "6.1.0",
"@sentry/integrations": "6.1.0",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "11.2.5",
"@types/jest": "26.0.20",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.1",
"@types/socket.io-client": "1.4.35",
"browser-fs-access": "0.13.1",
"@sentry/browser": "5.29.2",
"@sentry/integrations": "5.29.2",
"@testing-library/jest-dom": "5.11.8",
"@testing-library/react": "11.2.2",
"@types/jest": "26.0.19",
"@types/nanoid": "2.1.0",
"@types/react": "17.0.0",
"@types/react-dom": "17.0.0",
"@types/socket.io-client": "1.4.34",
"browser-nativefs": "0.12.0",
"clsx": "1.1.1",
"firebase": "8.2.7",
"firebase": "8.2.1",
"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",
@@ -43,25 +44,23 @@
"pwacompat": "2.0.17",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-scripts": "4.0.2",
"react-scripts": "4.0.1",
"roughjs": "4.3.1",
"socket.io-client": "2.3.1",
"typescript": "4.1.5"
"typescript": "4.1.3"
},
"devDependencies": {
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"@types/resize-observer-browser": "0.1.5",
"eslint-config-prettier": "7.2.0",
"eslint-plugin-prettier": "3.3.1",
"firebase-tools": "9.3.0",
"husky": "4.3.8",
"jest-canvas-mock": "2.3.1",
"lint-staged": "10.5.4",
"eslint-config-prettier": "7.1.0",
"eslint-plugin-prettier": "3.3.0",
"firebase-tools": "9.1.0",
"husky": "4.3.6",
"jest-canvas-mock": "2.3.0",
"lint-staged": "10.5.3",
"pepjs": "0.5.3",
"prettier": "2.2.1",
"rewire": "5.0.0",
"worker-loader": "3.0.8"
"rewire": "5.0.0"
},
"engines": {
"node": ">=12.0.0"
@@ -74,19 +73,16 @@
},
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
],
"moduleNameMapper": {
"^worker-loader!.+": "<rootDir>/src/__mocks__/worker-mock.js"
},
"resetMocks": false
},
"name": "excalidraw",
"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: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:version": "node ./scripts/build-version.js",
"build": "npm run build:app && npm run build:version",
"eject": "react-scripts eject",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -57,7 +57,6 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<link
rel="preload"
href="FG_Virgil.woff2"
@@ -87,10 +86,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 || [];
@@ -98,7 +97,7 @@
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
gtag("config", "UA-387204-13");
</script>
<% } %>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -5,40 +5,23 @@ const path = require("path");
const versionFile = path.join("build", "version.json");
const indexFile = path.join("build", "index.html");
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(),
version: versionDate(now),
},
undefined,
2,
@@ -51,7 +34,7 @@ fs.readFile(indexFile, "utf8", (error, data) => {
if (error) {
return console.error(error);
}
const result = data.replace(/{version}/g, getFullVersion());
const result = data.replace(/{version}/g, versionDate(now));
fs.writeFile(indexFile, result, "utf8", (error) => {
if (error) {

View File

@@ -4,27 +4,25 @@ const THRESSHOLD = 85;
const crowdinMap = {
"ar-SA": "en-ar",
"el-GR": "en-el",
"fi-FI": "en-fi",
"ja-JP": "en-ja",
"bg-BG": "en-bg",
"ca-ES": "en-ca",
"de-DE": "en-de",
"el-GR": "en-el",
"es-ES": "en-es",
"fa-IR": "en-fa",
"fi-FI": "en-fi",
"fr-FR": "en-fr",
"he-IL": "en-he",
"hi-IN": "en-hi",
"hu-HU": "en-hu",
"id-ID": "en-id",
"it-IT": "en-it",
"ja-JP": "en-ja",
"kab-KAB": "en-kab",
"ko-KR": "en-ko",
"my-MM": "en-my",
"nb-NO": "en-nb",
"nl-NL": "en-nl",
"nn-NO": "en-nnno",
"pa-IN": "en-pain",
"pl-PL": "en-pl",
"pt-BR": "en-ptbr",
"pt-PT": "en-pt",
@@ -41,7 +39,7 @@ const crowdinMap = {
const flags = {
"ar-SA": "🇸🇦",
"bg-BG": "🇧🇬",
"ca-ES": "🏳",
"ca-ES": "🇪🇸",
"de-DE": "🇩🇪",
"el-GR": "🇬🇷",
"es-ES": "🇪🇸",
@@ -54,13 +52,11 @@ const flags = {
"id-ID": "🇮🇩",
"it-IT": "🇮🇹",
"ja-JP": "🇯🇵",
"kab-KAB": "🏳",
"ko-KR": "🇰🇷",
"my-MM": "🇲🇲",
"nb-NO": "🇳🇴",
"nl-NL": "🇳🇱",
"nn-NO": "🇳🇴",
"pa-IN": "🇮🇳",
"pl-PL": "🇵🇱",
"pt-BR": "🇧🇷",
"pt-PT": "🇵🇹",
@@ -77,7 +73,7 @@ const flags = {
const languages = {
"ar-SA": "العربية",
"bg-BG": "Български",
"ca-ES": "Català",
"ca-ES": "Catalan",
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"es-ES": "Español",
@@ -90,13 +86,11 @@ const languages = {
"id-ID": "Bahasa Indonesia",
"it-IT": "Italiano",
"ja-JP": "日本語",
"kab-KAB": "Taqbaylit",
"ko-KR": "한국어",
"my-MM": "Burmese",
"nb-NO": "Norsk bokmål",
"nl-NL": "Nederlands",
"nn-NO": "Norsk nynorsk",
"pa-IN": "ਪੰਜਾਬੀ",
"pl-PL": "Polski",
"pt-BR": "Português Brasileiro",
"pt-PT": "Português",
@@ -140,7 +134,7 @@ const printRow = (id, locale, coverage) => {
} else {
result += `${boldIf(language, isOver)} | `;
}
result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
result += `${coverage === 100 ? "" : boldIf(coverage, isOver)} |`;
return result;
};

View File

@@ -1,4 +0,0 @@
module.exports = class {
postMessage() {}
terminate() {}
};

View File

@@ -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,7 +16,9 @@ export const actionAddToLibrary = register({
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
trackEvent(EVENT_LIBRARY, "add");
return false;
},
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary",
});

View File

@@ -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, {

View File

@@ -1,9 +1,10 @@
import React from "react";
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
import { getDefaultAppState } from "../appState";
import colors from "../colors";
import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
@@ -14,12 +15,21 @@ 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 { getNewSceneName, getShortcutKey } from "../utils";
import { register } from "./register";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
perform: (_, appState, value) => {
if (value !== appState.viewBackgroundColor) {
trackEvent(
EVENT_CHANGE,
"canvas color",
colors.canvasBackground.includes(value)
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
: value,
);
}
return {
appState: { ...appState, viewBackgroundColor: value },
commitToHistory: true,
@@ -42,12 +52,14 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
perform: (elements, appState: AppState) => {
trackEvent(EVENT_ACTION, "clear canvas");
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
name: getNewSceneName(),
appearance: appState.appearance,
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
@@ -76,6 +88,8 @@ export const actionClearCanvas = register({
),
});
const ZOOM_STEP = 0.1;
export const actionZoomIn = register({
name: "zoomIn",
perform: (_elements, appState) => {
@@ -85,6 +99,7 @@ export const actionZoomIn = register({
{ left: appState.offsetLeft, top: appState.offsetTop },
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
return {
appState: {
...appState,
@@ -119,6 +134,7 @@ export const actionZoomOut = register({
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
return {
appState: {
...appState,
@@ -146,6 +162,7 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
perform: (_elements, appState) => {
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
return {
appState: {
...appState,
@@ -218,10 +235,12 @@ const zoomToFitElements = (
left: appState.offsetLeft,
top: appState.offsetTop,
});
const action = zoomToSelection ? "selection" : "fit";
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
trackEvent(EVENT_ACTION, "zoom", action, newZoom.value * 100);
return {
appState: {
...appState,

View File

@@ -1,114 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { copyToClipboard } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected";
import { getSelectedElements } from "../scene/selection";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements } from "../element";
import { t } from "../i18n";
export const actionCopy = register({
name: "copy",
perform: (elements, appState) => {
copyToClipboard(getNonDeletedElements(elements), appState);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.copy",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
});
export const actionCut = register({
name: "cut",
perform: (elements, appState, data, app) => {
actionCopy.perform(elements, appState, data, app);
return actionDeleteSelected.perform(elements, appState, data, app);
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsSvg",
});
export const actionCopyAsPng = register({
name: "copyAsPng",
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
};
}
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
try {
await exportCanvas(
"clipboard",
selectedElements.length
? selectedElements
: getNonDeletedElements(elements),
appState,
app.canvas,
appState,
);
return {
appState: {
...appState,
toastMessage: t("toast.copyToClipboardAsPng"),
},
commitToHistory: false,
};
} catch (error) {
console.error(error);
return {
appState: {
...appState,
errorMessage: error.message,
},
commitToHistory: false,
};
}
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
});

View File

@@ -136,6 +136,7 @@ export const actionDeleteSelected = register({
};
},
contextItemLabel: "labels.delete",
contextMenuOrder: 999999,
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton

View File

@@ -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, {

View File

@@ -1,26 +1,29 @@
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 { questionCircle } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { muteFSAbortError } from "../utils";
import { register } from "./register";
import "../components/ToolIcon.scss";
import { SCENE_NAME_FALLBACK } from "../constants";
export const actionChangeProjectName = register({
name: "changeProjectName",
perform: (_elements, appState, value) => {
trackEvent("change", "title");
trackEvent(EVENT_CHANGE, "title");
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData }) => (
<ProjectName
label={t("labels.fileTitle")}
value={appState.name || "Unnamed"}
value={appState.name || SCENE_NAME_FALLBACK}
onChange={(name: string) => updateData(name)}
/>
),
@@ -96,24 +99,10 @@ export const actionChangeShouldAddWatermark = register({
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => {
const fileHandleExists = !!appState.fileHandle;
try {
const { fileHandle } = await saveAsJSON(elements, appState);
return {
commitToHistory: false,
appState: {
...appState,
fileHandle,
toastMessage: fileHandleExists
? fileHandle.name
? t("toast.fileSavedToFilename").replace(
"{filename}",
`"${fileHandle.name}"`,
)
: t("toast.fileSaved")
: null,
},
};
trackEvent(EVENT_IO, "save");
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
@@ -143,6 +132,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") {
@@ -170,29 +160,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"
@@ -200,7 +179,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 });
});
}}
/>
),
});

View File

@@ -83,7 +83,7 @@ export const actionFinalize = register({
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
const isLoop = isPathALoop(multiPointElement.points);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "draw"
@@ -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,

View File

@@ -125,6 +125,7 @@ export const actionGroup = register({
commitToHistory: true,
};
},
contextMenuOrder: 4,
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
@@ -173,6 +174,7 @@ export const actionUngroup = register({
},
keyTest: (event) =>
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
contextMenuOrder: 5,
contextItemLabel: "labels.ungroup",
contextItemPredicate: (elements, appState) =>
getSelectedGroupIds(appState).length > 0,

View File

@@ -6,7 +6,7 @@ import { t } from "../i18n";
import { SceneHistory, HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { isWindows, KEYS } from "../keys";
import { KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
@@ -59,16 +59,16 @@ const writeData = (
return { commitToHistory };
};
const testUndo = (shift: boolean) => (event: KeyboardEvent) =>
event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift;
type ActionCreator = (history: SceneHistory) => Action;
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
keyTest: testUndo(false),
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
@@ -84,11 +84,7 @@ export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
keyTest: testUndo(true),
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"

View File

@@ -7,7 +7,7 @@ import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { CODES, KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon";
import { MiniMap } from "../components/MiniMap";
import { EVENT_DIALOG, trackEvent } from "../analytics";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@@ -72,34 +72,17 @@ export const actionFullScreen = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
perform: (_elements, appState) => {
trackEvent(EVENT_DIALOG, "shortcuts");
return {
appState: {
...appState,
showHelpDialog: !appState.showHelpDialog,
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,
});
export const actionMinimap = register({
name: "toggleMinimap",
perform: (_elements, appState) => {
return {
appState: {
...appState,
isMinimapEnabled: !appState.isMinimapEnabled,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, elements }) =>
appState.isMinimapEnabled ? (
<MiniMap appState={appState} elements={elements} />
) : null,
keyTest: (event) => event.key === KEYS.M,
});

View File

@@ -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 };
}
@@ -42,7 +44,7 @@ export const actionGoToCollaborator = register({
return null;
}
const { background, stroke } = getClientColors(clientId, appState);
const { background, stroke } = getClientColors(clientId);
const shortName = getClientInitials(collaborator.username);
return (

View File

@@ -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") {

View File

@@ -4,7 +4,6 @@ import {
redrawTextBoundingBox,
} from "../element";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
@@ -24,16 +23,13 @@ export const actionCopyStyles = register({
copiedStyles = JSON.stringify(element);
}
return {
appState: {
...appState,
toastMessage: t("toast.copyStyles"),
},
commitToHistory: false,
};
},
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
contextMenuOrder: 0,
});
export const actionPasteStyles = register({
@@ -73,4 +69,5 @@ export const actionPasteStyles = register({
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
contextMenuOrder: 1,
});

View File

@@ -1,22 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
},
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@@ -1,16 +0,0 @@
import { register } from "./register";
export const actionToggleStats = register({
name: "stats",
perform(elements, appState) {
return {
appState: {
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
});

View File

@@ -1,22 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleViewMode = register({
name: "viewMode",
perform(elements, appState) {
trackEvent("view", "mode", "view");
return {
appState: {
...appState,
viewModeEnabled: !this.checked!(appState),
selectedElementIds: {},
},
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});

View File

@@ -1,22 +0,0 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { trackEvent } from "../analytics";
export const actionToggleZenMode = register({
name: "zenMode",
perform(elements, appState) {
trackEvent("view", "mode", "zen");
return {
appState: {
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});

View File

@@ -44,7 +44,6 @@ export {
actionToggleEditMenu,
actionFullScreen,
actionShortcuts,
actionMinimap,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
@@ -66,15 +65,3 @@ export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";
export {
actionCopy,
actionCut,
actionCopyAsPng,
actionCopyAsSvg,
} from "./actionClipboard";
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats";

View File

@@ -3,16 +3,14 @@ import {
Action,
ActionsManagerInterface,
UpdaterFn,
ActionFilterFn,
ActionName,
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types";
import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
import { AppState } from "../types";
import { t } from "../i18n";
import { ShortcutName } from "./shortcuts";
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@@ -20,14 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
@@ -40,7 +37,6 @@ export class ActionManager implements ActionsManagerInterface {
};
this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
}
registerAction(action: Action) {
@@ -67,12 +63,6 @@ export class ActionManager implements ActionsManagerInterface {
if (data.length === 0) {
return false;
}
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (!Object.values(MODES).includes(data[0].name)) {
return false;
}
}
event.preventDefault();
this.updater(
@@ -80,7 +70,6 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
return true;
@@ -92,11 +81,43 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
this.app,
),
);
}
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
@@ -111,7 +132,6 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(),
this.getAppState(),
formState,
this.app,
),
);
};

View File

@@ -9,7 +9,7 @@ export type ShortcutName =
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "deleteSelectedElements"
| "delete"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
@@ -20,10 +20,8 @@ export type ShortcutName =
| "group"
| "ungroup"
| "gridMode"
| "zenMode"
| "stats"
| "addToLibrary"
| "viewMode";
| "addToLibrary";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -32,10 +30,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
deleteSelectedElements: [getShortcutKey("Del")],
delete: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
],
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
bringForward: [getShortcutKey("CtrlOrCmd+]")],
@@ -54,10 +52,8 @@ const shortcutMap: Record<ShortcutName, string[]> = {
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
gridMode: [getShortcutKey("CtrlOrCmd+'")],
zenMode: [getShortcutKey("Alt+Z")],
stats: [],
addToLibrary: [],
viewMode: [getShortcutKey("Alt+R")],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@@ -16,18 +16,12 @@ type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: { canvas: HTMLCanvasElement | null },
) => ActionResult | Promise<ActionResult>;
export type UpdaterFn = (res: ActionResult) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
| "copy"
| "cut"
| "paste"
| "copyAsPng"
| "copyAsSvg"
| "sendBackward"
| "bringForward"
| "sendToBack"
@@ -35,9 +29,6 @@ export type ActionName =
| "copyStyles"
| "selectAll"
| "pasteStyles"
| "gridMode"
| "zenMode"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
| "changeFillStyle"
@@ -84,9 +75,7 @@ export type ActionName =
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically"
| "viewMode"
| "toggleMinimap";
| "distributeVertically";
export interface Action {
name: ActionName;
@@ -104,16 +93,19 @@ export interface Action {
elements: readonly ExcalidrawElement[],
) => boolean;
contextItemLabel?: string;
contextMenuOrder?: number;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
checked?: (appState: Readonly<AppState>) => boolean;
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (
actionFilter: ActionFilterFn,
) => { label: string; action: () => void }[];
renderAction: (name: ActionName) => React.ReactElement | null;
}

View File

@@ -1,8 +1,18 @@
export const EVENT_ACTION = "action";
export const EVENT_ALIGN = "align";
export const EVENT_CHANGE = "change";
export const EVENT_DIALOG = "dialog";
export const EVENT_EXIT = "exit";
export const EVENT_IO = "io";
export const EVENT_LAYER = "layer";
export const EVENT_LIBRARY = "library";
export const EVENT_LOAD = "load";
export const EVENT_SHAPE = "shape";
export const EVENT_SHARE = "share";
export const EVENT_MAGIC = "magic";
export const trackEvent =
typeof process !== "undefined" &&
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
typeof window !== "undefined" &&
window.gtag
typeof window !== "undefined" && window.gtag
? (category: string, name: string, label?: string, value?: number) => {
window.gtag("event", name, {
event_category: category,
@@ -10,9 +20,8 @@ export const trackEvent =
value,
});
}
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
: typeof process !== "undefined" && process?.env?.JEST_WORKER_ID
? (category: string, name: string, label?: string, value?: number) => {}
: (category: string, name: string, label?: string, value?: number) => {
// Uncomment the next line to track locally
// console.info("Track Event", category, name, label, value);
console.info("Track Event", category, name, label, value);
};

View File

@@ -2,16 +2,20 @@ import oc from "open-color";
import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
SCENE_NAME_FALLBACK,
DEFAULT_TEXT_ALIGN,
} from "./constants";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft"
> => {
type DefaultAppState = Omit<AppState, "offsetTop" | "offsetLeft" | "name"> & {
/**
* You should override this with current appState.name, or whatever is
* applicable at a given place where you get default appState.
*/
name: undefined;
};
export const getDefaultAppState = (): DefaultAppState => {
return {
appearance: "light",
collaborators: new Map(),
@@ -42,7 +46,7 @@ export const getDefaultAppState = (): Omit<
exportEmbedScene: false,
fileHandle: null,
gridSize: null,
height: globalThis.innerHeight,
height: window.innerHeight,
isBindingEnabled: true,
isLibraryOpen: false,
isLoading: false,
@@ -50,30 +54,31 @@ export const getDefaultAppState = (): Omit<
isRotating: false,
lastPointerDownWith: "mouse",
multiElement: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
// for safety (because TS mostly doesn't distinguish optional types and
// undefined values), we set `name` to the fallback name, but we cast it to
// `undefined` so that TS forces us to explicitly specify it wherever
// possible
name: (SCENE_NAME_FALLBACK as unknown) as undefined,
openMenu: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
scrolledOutside: false,
scrollX: 0,
scrollY: 0,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
selectedElementIds: {},
selectedGroupIds: {},
selectionElement: null,
shouldAddWatermark: false,
shouldCacheIgnoreZoom: false,
showHelpDialog: false,
showShortcutsDialog: false,
showStats: false,
startBoundElement: null,
suggestedBindings: [],
toastMessage: null,
viewBackgroundColor: oc.white,
width: globalThis.innerWidth,
width: window.innerWidth,
zenModeEnabled: false,
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
viewModeEnabled: false,
isMinimapEnabled: false,
};
};
@@ -144,17 +149,14 @@ 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 },
showShortcutsDialog: { browser: false, export: false },
showStats: { browser: true, export: false },
startBoundElement: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
toastMessage: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
viewModeEnabled: { browser: false, export: false },
isMinimapEnabled: { browser: true, export: false },
});
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(

View File

@@ -1,3 +1,4 @@
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";
@@ -472,6 +473,7 @@ export const renderSpreadsheet = (
x: number,
y: number,
): ChartElements => {
trackEvent(EVENT_MAGIC, "chart", chartType, spreadsheet.values.length);
if (chartType === "line") {
return chartTypeLine(spreadsheet, x, y);
}

View File

@@ -1,13 +1,6 @@
import colors from "./colors";
import { AppState } from "./types";
export const getClientColors = (clientId: string, appState: AppState) => {
if (appState?.collaborators) {
const currentUser = appState.collaborators.get(clientId);
if (currentUser?.color) {
return currentUser.color;
}
}
export const getClientColors = (clientId: string) => {
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);

View File

@@ -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 });
}}
/>

View File

@@ -2,35 +2,18 @@ import { Point, simplify } from "points-on-curve";
import React from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough";
import clsx from "clsx";
import {
actionAddToLibrary,
actionBringForward,
actionBringToFront,
actionCopy,
actionCopyAsPng,
actionCopyAsSvg,
actionCopyStyles,
actionCut,
actionDeleteSelected,
actionDuplicateSelection,
actionFinalize,
actionGroup,
actionPasteStyles,
actionSelectAll,
actionSendBackward,
actionSendToBack,
actionToggleGridMode,
actionToggleStats,
actionToggleZenMode,
actionUngroup,
} from "../actions";
import "../actions";
import { actionDeleteSelected, actionFinalize } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { ActionResult } from "../actions/types";
import { trackEvent } from "../analytics";
import {
EVENT_DIALOG,
EVENT_LIBRARY,
EVENT_SHAPE,
trackEvent,
} from "../analytics";
import { getDefaultAppState } from "../appState";
import {
copyToClipboard,
@@ -40,6 +23,7 @@ import {
} from "../clipboard";
import {
APP_NAME,
CANVAS_ONLY_ACTIONS,
CURSOR_TYPE,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
@@ -51,13 +35,11 @@ import {
LINE_CONFIRM_THRESHOLD,
MIME_TYPES,
POINTER_BUTTON,
SCROLL_TIMEOUT,
TAP_TWICE_TIMEOUT,
TEXT_TO_CENTER_SNAP_THRESHOLD,
TOUCH_CTX_MENU_TIMEOUT,
ZOOM_STEP,
} from "../constants";
import { loadFromBlob } from "../data";
import { exportCanvas, loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json";
import { Library } from "../data/library";
import { restore } from "../data/restore";
@@ -129,7 +111,7 @@ import {
selectGroupsForSelectedElements,
} from "../groups";
import { createHistory, SceneHistory } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import { t, getLanguage, setLanguage, languages, defaultLang } from "../i18n";
import {
CODES,
getResizeCenterPointKey,
@@ -150,6 +132,7 @@ import {
getSelectedElements,
isOverScrollBars,
isSomeElementSelected,
normalizeScroll,
} from "../scene";
import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types";
@@ -165,6 +148,7 @@ import {
import {
debounce,
distance,
getNewSceneName,
isInputLike,
isToolIcon,
isWritableElement,
@@ -177,12 +161,9 @@ import {
viewportCoordsToSceneCoords,
withBatchedUpdates,
} from "../utils";
import { isMobile } from "../is-mobile";
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
import ContextMenu from "./ContextMenu";
import LayerUI from "./LayerUI";
import { Stats } from "./Stats";
import { Toast } from "./Toast";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
const { history } = createHistory();
@@ -272,7 +253,6 @@ export type ExcalidrawImperativeAPI = {
};
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
getSceneElements: InstanceType<typeof App>["getSceneElements"];
getAppState: () => InstanceType<typeof App>["state"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
};
@@ -299,19 +279,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetLeft,
offsetTop,
excalidrawRef,
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
} = props;
this.state = {
...defaultAppState,
name: getNewSceneName(),
isLoading: true,
width,
height,
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
viewModeEnabled,
zenModeEnabled,
gridSize: gridModeEnabled ? GRID_SIZE : null,
};
if (excalidrawRef) {
const readyPromise =
@@ -329,7 +304,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
},
setScrollToCenter: this.setScrollToCenter,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@@ -344,7 +318,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions);
@@ -352,62 +325,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.actionManager.registerAction(createRedoAction(history));
}
private renderCanvas() {
const canvasScale = window.devicePixelRatio;
const {
width: canvasDOMWidth,
height: canvasDOMHeight,
viewModeEnabled,
} = this.state;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
if (viewModeEnabled) {
return (
<canvas
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
cursor: "grabbing",
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
>
{t("labels.drawingCanvas")}
</canvas>
);
}
return (
<canvas
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
>
{t("labels.drawingCanvas")}
</canvas>
);
}
public render() {
const {
zenModeEnabled,
@@ -415,19 +332,20 @@ class App extends React.Component<ExcalidrawProps, AppState> {
height: canvasDOMHeight,
offsetTop,
offsetLeft,
viewModeEnabled,
} = this.state;
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
const canvasScale = window.devicePixelRatio;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
return (
<div
className={clsx("excalidraw", {
"excalidraw--view-mode": viewModeEnabled,
})}
className="excalidraw"
ref={this.excalidrawContainerRef}
style={{
width: canvasDOMWidth,
@@ -457,27 +375,36 @@ class App extends React.Component<ExcalidrawProps, AppState> {
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
}
/>
<div className="excalidraw-textEditorContainer" />
{this.state.showStats && (
<Stats
appState={this.state}
setAppState={this.setAppState}
elements={this.scene.getElements()}
onClose={this.toggleStats}
/>
)}
{this.state.toastMessage !== null && (
<Toast
message={this.state.toastMessage}
clearToast={this.clearToast}
/>
)}
<main>{this.renderCanvas()}</main>
<main>
<canvas
id="canvas"
style={{
width: canvasDOMWidth,
height: canvasDOMHeight,
}}
width={canvasWidth}
height={canvasHeight}
ref={this.handleCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.removePointer}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onDrop={this.handleCanvasOnDrop}
>
{t("labels.drawingCanvas")}
</canvas>
</main>
</div>
);
}
@@ -517,23 +444,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (actionResult.commitToHistory) {
history.resumeRecording();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
let gridSize = actionResult?.appState?.gridSize || null;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
}
if (typeof this.props.zenModeEnabled !== "undefined") {
zenModeEnabled = this.props.zenModeEnabled;
}
if (typeof this.props.gridModeEnabled !== "undefined") {
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
}
this.setState(
(state) => ({
...actionResult.appState,
@@ -543,9 +453,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
height: state.height,
offsetTop: state.offsetTop,
offsetLeft: state.offsetLeft,
viewModeEnabled,
zenModeEnabled,
gridSize,
}),
() => {
if (actionResult.syncHistory) {
@@ -599,6 +506,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
)
) {
await Library.importLibrary(blob);
trackEvent(EVENT_LIBRARY, "import");
this.setState({
isLibraryOpen: true,
});
@@ -622,6 +530,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.scene.replaceAllElements([]);
this.setState((state) => ({
...getDefaultAppState(),
name: getNewSceneName(),
isLoading: opts?.resetLoadingState ? false : state.isLoading,
appearance: this.state.appearance,
}));
@@ -728,6 +637,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
// optim to avoid extra render on init
@@ -794,16 +704,25 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
private addEventListeners() {
this.removeEventListeners();
document.addEventListener(EVENT.COPY, this.onCopy);
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.addEventListener(EVENT.CUT, this.onCut);
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
document.addEventListener(
EVENT.MOUSE_MOVE,
this.updateCurrentCursorPosition,
);
window.addEventListener(EVENT.RESIZE, this.onResize, false);
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
window.addEventListener(EVENT.BLUR, this.onBlur, false);
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
window.addEventListener(EVENT.DROP, this.disableEvent, false);
// rerender text elements on font load to fix #637 && #1553
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
// Safari-only desktop pinch zoom
document.addEventListener(
EVENT.GESTURE_START,
@@ -820,19 +739,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.onGestureEnd as any,
false,
);
if (this.state.viewModeEnabled) {
return;
}
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
document.addEventListener(EVENT.CUT, this.onCut);
document.addEventListener(EVENT.SCROLL, this.onScroll);
window.addEventListener(EVENT.RESIZE, this.onResize, false);
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
window.addEventListener(EVENT.BLUR, this.onBlur, false);
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
window.addEventListener(EVENT.DROP, this.disableEvent, false);
}
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
@@ -855,26 +761,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
});
}
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
this.setState(
{ viewModeEnabled: !!this.props.viewModeEnabled },
this.addEventListeners,
);
}
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
this.addEventListeners();
}
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
}
if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
this.setState({
gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
});
}
document
.querySelector(".excalidraw")
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
@@ -914,7 +800,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) {
@@ -930,9 +815,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (user.username) {
pointerUsernames[socketId] = user.username;
}
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
@@ -967,12 +849,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
},
{
renderOptimizations: true,
renderScrollbars: !isMobile(),
},
);
if (scrollBars) {
@@ -1001,10 +881,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
}
private onScroll = debounce(() => {
this.setState({ ...this.getCanvasOffsets() });
}, SCROLL_TIMEOUT);
// Copy/paste
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
@@ -1032,6 +908,43 @@ class App extends React.Component<ExcalidrawProps, AppState> {
copyToClipboard(this.scene.getElements(), this.state);
};
private copyToClipboardAsPng = async () => {
const elements = this.scene.getElements();
const selectedElements = getSelectedElements(elements, this.state);
try {
await exportCanvas(
"clipboard",
selectedElements.length ? selectedElements : elements,
this.state,
this.canvas!,
this.state,
);
} catch (error) {
console.error(error);
this.setState({ errorMessage: error.message });
}
};
private copyToClipboardAsSvg = async () => {
const selectedElements = getSelectedElements(
this.scene.getElements(),
this.state,
);
try {
await exportCanvas(
"clipboard-svg",
selectedElements.length ? selectedElements : this.scene.getElements(),
this.state,
this.canvas!,
this.state,
);
} catch (error) {
console.error(error);
this.setState({ errorMessage: error.message });
}
};
private static resetTapTwice() {
didTapTwice = false;
}
@@ -1224,6 +1137,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
@@ -1234,14 +1148,24 @@ class App extends React.Component<ExcalidrawProps, AppState> {
};
toggleZenMode = () => {
this.actionManager.executeAction(actionToggleZenMode);
this.setState({
zenModeEnabled: !this.state.zenModeEnabled,
});
};
toggleGridMode = () => {
this.setState({
gridSize: this.state.gridSize ? null : GRID_SIZE,
});
};
toggleStats = () => {
if (!this.state.showStats) {
trackEvent("dialog", "stats");
trackEvent(EVENT_DIALOG, "stats");
}
this.actionManager.executeAction(actionToggleStats);
this.setState({
showStats: !this.state.showStats,
});
};
setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
@@ -1254,10 +1178,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
});
};
clearToast = () => {
this.setState({ toastMessage: null });
};
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
if (sceneData.commitToHistory) {
history.resumeRecording();
@@ -1327,23 +1247,35 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) {
this.setState({
showHelpDialog: true,
showShortcutsDialog: true,
});
}
if (!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z) {
this.toggleZenMode();
}
if (event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE) {
this.toggleGridMode();
}
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
if (event.code === CODES.C && event.altKey && event.shiftKey) {
this.copyToClipboardAsPng();
event.preventDefault();
return;
}
if (this.actionManager.handleKeyDown(event)) {
return;
}
if (this.state.viewModeEnabled) {
return;
}
if (event[KEYS.CTRL_OR_CMD]) {
this.setState({ isBindingEnabled: false });
}
if (event.code === CODES.NINE) {
if (!this.state.isLibraryOpen) {
trackEvent(EVENT_DIALOG, "library");
}
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
}
@@ -1428,6 +1360,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();
@@ -1557,7 +1490,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
textWysiwyg({
id: element.id,
appState: this.state,
canvas: this.canvas,
getViewportCoords: (x, y) => {
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{
@@ -1812,6 +1744,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,
@@ -1848,8 +1781,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const scaleFactor = distance / gesture.initialDistance;
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
scrollX: scrollX + deltaX / zoom.value,
scrollY: scrollY + deltaY / zoom.value,
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
zoom: getNewZoom(
getNormalizedZoom(initialScale * scaleFactor),
zoom,
@@ -1866,11 +1799,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
return;
}
const isPointerOverScrollBars = isOverScrollBars(
currentScrollBars,
event.clientX - this.state.offsetLeft,
event.clientY - this.state.offsetTop,
event.clientX,
event.clientY,
);
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
if (!this.state.draggingElement && !this.state.multiElement) {
@@ -1964,7 +1896,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
points: points.slice(0, -1),
});
} else {
if (isPathALoop(points, this.state.zoom.value)) {
if (isPathALoop(points)) {
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
}
// update last uncommitted point
@@ -2151,16 +2083,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
lastPointerUp = onPointerUp;
if (!this.state.viewModeEnabled) {
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
window.addEventListener(EVENT.KEYUP, onKeyUp);
pointerDownState.eventListeners.onMove = onPointerMove;
pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
}
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
window.addEventListener(EVENT.KEYUP, onKeyUp);
pointerDownState.eventListeners.onMove = onPointerMove;
pointerDownState.eventListeners.onUp = onPointerUp;
pointerDownState.eventListeners.onKeyUp = onKeyUp;
pointerDownState.eventListeners.onKeyDown = onKeyDown;
};
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
@@ -2210,8 +2140,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
!(
gesture.pointers.size === 0 &&
(event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
this.state.viewModeEnabled)
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
)
) {
return false;
@@ -2264,8 +2193,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}
this.setState({
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
scrollX: normalizeScroll(
this.state.scrollX - deltaX / this.state.zoom.value,
),
scrollY: normalizeScroll(
this.state.scrollY - deltaY / this.state.zoom.value,
),
});
});
const teardown = withBatchedUpdates(
@@ -2326,8 +2259,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
),
scrollbars: isOverScrollBars(
currentScrollBars,
event.clientX - this.state.offsetLeft,
event.clientY - this.state.offsetTop,
event.clientX,
event.clientY,
),
// we need to duplicate because we'll be updating this state
lastCoords: { ...origin },
@@ -2636,10 +2569,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const { multiElement } = this.state;
// finalize if completing a loop
if (
multiElement.type === "line" &&
isPathALoop(multiElement.points, this.state.zoom.value)
) {
if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
mutateElement(multiElement, {
lastCommittedPoint:
multiElement.points[multiElement.points.length - 1],
@@ -3082,7 +3012,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x;
this.setState({
scrollX: this.state.scrollX - dx / this.state.zoom.value,
scrollX: normalizeScroll(
this.state.scrollX - dx / this.state.zoom.value,
),
});
pointerDownState.lastCoords.x = x;
return true;
@@ -3092,7 +3024,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y;
this.setState({
scrollY: this.state.scrollY - dy / this.state.zoom.value,
scrollY: normalizeScroll(
this.state.scrollY - dy / this.state.zoom.value,
),
});
pointerDownState.lastCoords.y = y;
return true;
@@ -3210,7 +3144,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,
@@ -3357,7 +3291,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return;
}
if (!elementLocked && elementType !== "draw" && draggingElement) {
if (!elementLocked && draggingElement) {
this.setState((prevState) => ({
selectedElementIds: {
...prevState.selectedElementIds,
@@ -3381,7 +3315,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
);
}
if (!elementLocked && elementType !== "draw") {
if (!elementLocked) {
resetCursor();
this.setState({
draggingElement: null,
@@ -3656,6 +3590,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
transformElements(
pointerDownState,
transformHandleType,
(newTransformHandle) => {
pointerDownState.resize.handleType = newTransformHandle;
},
selectedElements,
pointerDownState.resize.arrowDirection,
getRotateWithDiscreteAngleKey(event),
@@ -3685,90 +3622,46 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.state,
);
const maybeGroupAction = actionGroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
);
const maybeUngroupAction = actionUngroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(),
);
const separator = "separator";
const _isMobile = isMobile();
const elements = this.scene.getElements();
const element = this.getElementAtPosition(x, y);
const options: ContextMenuOption[] = [];
if (probablySupportsClipboardBlob && elements.length > 0) {
options.push(actionCopyAsPng);
}
if (probablySupportsClipboardWriteText && elements.length > 0) {
options.push(actionCopyAsSvg);
}
if (!element) {
const viewModeOptions = [
...options,
typeof this.props.gridModeEnabled === "undefined" &&
actionToggleGridMode,
typeof this.props.zenModeEnabled === "undefined" && actionToggleZenMode,
typeof this.props.viewModeEnabled === "undefined" &&
actionToggleViewMode,
actionToggleStats,
];
ContextMenu.push({
options: viewModeOptions,
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
if (this.state.viewModeEnabled) {
return;
}
ContextMenu.push({
options: [
_isMobile &&
navigator.clipboard && {
name: "paste",
perform: (elements, appStates) => {
this.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
},
_isMobile && navigator.clipboard && separator,
navigator.clipboard && {
shortcutName: "paste",
label: t("labels.paste"),
action: () => this.pasteFromClipboard(null),
},
probablySupportsClipboardBlob &&
elements.length > 0 &&
actionCopyAsPng,
elements.length > 0 && {
shortcutName: "copyAsPng",
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
probablySupportsClipboardWriteText &&
elements.length > 0 &&
actionCopyAsSvg,
((probablySupportsClipboardBlob && elements.length > 0) ||
(probablySupportsClipboardWriteText && elements.length > 0)) &&
separator,
actionSelectAll,
separator,
typeof this.props.gridModeEnabled === "undefined" &&
actionToggleGridMode,
typeof this.props.zenModeEnabled === "undefined" &&
actionToggleZenMode,
typeof this.props.viewModeEnabled === "undefined" &&
actionToggleViewMode,
actionToggleStats,
elements.length > 0 && {
shortcutName: "copyAsSvg",
label: t("labels.copyAsSvg"),
action: this.copyToClipboardAsSvg,
},
...this.actionManager.getContextMenuItems((action) =>
CANVAS_ONLY_ACTIONS.includes(action.name),
),
{
checked: this.state.gridSize !== null,
shortcutName: "gridMode",
label: t("labels.gridMode"),
action: this.toggleGridMode,
},
{
checked: this.state.showStats,
shortcutName: "stats",
label: t("stats.title"),
action: this.toggleStats,
},
],
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
return;
}
@@ -3777,55 +3670,39 @@ class App extends React.Component<ExcalidrawProps, AppState> {
this.setState({ selectedElementIds: { [element.id]: true } });
}
if (this.state.viewModeEnabled) {
ContextMenu.push({
options: [navigator.clipboard && actionCopy, ...options],
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
return;
}
ContextMenu.push({
options: [
_isMobile && actionCut,
_isMobile && navigator.clipboard && actionCopy,
_isMobile &&
navigator.clipboard && {
name: "paste",
perform: (elements, appStates) => {
this.pasteFromClipboard(null);
return {
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
},
_isMobile && separator,
...options,
separator,
actionCopyStyles,
actionPasteStyles,
separator,
maybeGroupAction && actionGroup,
maybeUngroupAction && actionUngroup,
(maybeGroupAction || maybeUngroupAction) && separator,
actionAddToLibrary,
separator,
actionSendBackward,
actionBringForward,
actionSendToBack,
actionBringToFront,
separator,
actionDuplicateSelection,
actionDeleteSelected,
{
shortcutName: "cut",
label: t("labels.cut"),
action: this.cutAll,
},
navigator.clipboard && {
shortcutName: "copy",
label: t("labels.copy"),
action: this.copyAll,
},
navigator.clipboard && {
shortcutName: "paste",
label: t("labels.paste"),
action: () => this.pasteFromClipboard(null),
},
probablySupportsClipboardBlob && {
shortcutName: "copyAsPng",
label: t("labels.copyAsPng"),
action: this.copyToClipboardAsPng,
},
probablySupportsClipboardWriteText && {
shortcutName: "copyAsSvg",
label: t("labels.copyAsSvg"),
action: this.copyToClipboardAsSvg,
},
...this.actionManager.getContextMenuItems(
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
),
],
top: clientY,
left: clientX,
actionManager: this.actionManager,
appState: this.state,
});
};
@@ -3856,15 +3733,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}, 1000);
}
let newZoom = this.state.zoom.value - delta / 100;
// increase zoom steps the more zoomed-in we are (applies to >100% only)
newZoom += Math.log10(Math.max(1, this.state.zoom.value)) * -sign;
// round to nearest step
newZoom = Math.round(newZoom * ZOOM_STEP * 100) / (ZOOM_STEP * 100);
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
zoom: getNewZoom(
getNormalizedZoom(newZoom),
getNormalizedZoom(zoom.value - delta / 100),
zoom,
{ left: offsetLeft, top: offsetTop },
{
@@ -3887,14 +3758,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value),
}));
return;
}
this.setState(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value,
scrollX: normalizeScroll(scrollX - deltaX / zoom.value),
scrollY: normalizeScroll(scrollY - deltaY / zoom.value),
}));
});
@@ -3954,9 +3825,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?: {

View File

@@ -1,4 +1,4 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.Avatar {

View File

@@ -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 });
}}
/>

View File

@@ -1,11 +1,10 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.CollabButton.is-collaborating {
background-color: var(--button-special-active-bg-color);
background-color: var(--button-special-active-background-color);
.ToolIcon__icon svg,
.ToolIcon__label {
.ToolIcon__icon svg {
color: var(--icon-green-fill-color);
}
}

View File

@@ -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,11 +23,14 @@ const CollabButton = ({
className={clsx("CollabButton", {
"is-collaborating": isCollaborating,
})}
onClick={onClick}
onClick={() => {
trackEvent(EVENT_DIALOG, "collaboration");
onClick();
}}
icon={users}
type="button"
title={t("labels.liveCollaboration")}
aria-label={t("labels.liveCollaboration")}
title={t("buttons.roomDialog")}
aria-label={t("buttons.roomDialog")}
showAriaLabel={useIsMobile()}
>
{collaboratorCount > 0 && (

View File

@@ -1,10 +1,10 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.color-picker {
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
background: var(--popup-background-color);
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
border-radius: 4px;
position: absolute;
@@ -24,11 +24,11 @@
}
.color-picker-triangle {
width: 0;
height: 0;
width: 0px;
height: 0px;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
border-width: 0px 9px 10px;
border-color: transparent transparent var(--popup-background-color);
position: absolute;
top: -10px;
@@ -84,12 +84,12 @@
.color-picker-transparent {
border-radius: 4px;
box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
.color-picker-transparent,
@@ -104,11 +104,11 @@
width: 1.875rem;
:root[dir="ltr"] & {
border-radius: 4px 0 0 4px;
border-radius: 4px 0px 0px 4px;
}
:root[dir="rtl"] & {
border-radius: 0 4px 4px 0;
border-radius: 0px 4px 4px 0px;
}
color: var(--input-label-color);
@@ -144,7 +144,7 @@
}
.color-input-container:focus-within .color-picker-hash::after {
background: var(--input-bg-color);
background: var(--input-background-color);
:root[dir="ltr"] & {
right: -2px;
@@ -163,19 +163,19 @@
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
background-color: var(--input-bg-color);
color: var(--text-primary-color);
border: 0;
background-color: var(--input-background-color);
color: var(--text-color-primary);
border: 0px;
outline: none;
height: 1.75em;
box-shadow: var(--input-border-color) 0 0 0 1px inset;
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
:root[dir="ltr"] & {
border-radius: 0 4px 4px 0;
border-radius: 0px 4px 4px 0px;
}
:root[dir="rtl"] & {
border-radius: 4px 0 0 4px;
border-radius: 4px 0px 0px 4px;
}
float: left;
@@ -228,7 +228,7 @@
}
.color-picker-type-elementBackground .color-picker-keybinding {
color: $oc-white;
color: #fff;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
@@ -241,10 +241,10 @@
&.Appearance_dark {
.color-picker-type-elementBackground .color-picker-keybinding {
color: $oc-black;
color: #000;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: $oc-black;
color: #000;
}
}
}

View File

@@ -1,18 +1,17 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.context-menu {
position: relative;
border-radius: 4px;
box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
padding: 0;
list-style: none;
user-select: none;
margin: -0.25rem 0 0 0.125rem;
padding: 0.5rem 0;
background-color: var(--popup-secondary-bg-color);
padding: 0.25rem 0;
background-color: var(--popup-secondary-background-color);
border: 1px solid var(--button-gray-3);
cursor: default;
}
.context-menu button {
@@ -55,18 +54,17 @@
.context-menu-option__shortcut {
justify-self: end;
opacity: 0.6;
font-family: inherit;
font-size: 0.7rem;
}
}
.context-menu-option:hover {
color: var(--popup-bg-color);
color: var(--popup-background-color);
background-color: var(--select-highlight-color);
&.dangerous {
.context-menu-option__label {
color: var(--popup-bg-color);
color: var(--popup-background-color);
}
background-color: $oc-red-6;
}
@@ -89,9 +87,4 @@
}
}
}
.context-menu-option-separator {
border: none;
border-top: 1px solid $oc-gray-5;
}
}

View File

@@ -2,36 +2,28 @@ import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
import { t } from "../i18n";
import "./ContextMenu.scss";
import {
getShortcutFromShortcutName,
ShortcutName,
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import { AppState } from "../types";
export type ContextMenuOption = "separator" | Action;
type ContextMenuOption = {
checked?: boolean;
shortcutName: ShortcutName;
label: string;
action(): void;
};
type ContextMenuProps = {
type Props = {
options: ContextMenuOption[];
onCloseRequest?(): void;
top: number;
left: number;
actionManager: ActionManager;
appState: Readonly<AppState>;
};
const ContextMenu = ({
options,
onCloseRequest,
top,
left,
actionManager,
appState,
}: ContextMenuProps) => {
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("Appearance_dark");
@@ -51,34 +43,23 @@ const ContextMenu = ({
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map((option, idx) => {
if (option === "separator") {
return <hr key={idx} className="context-menu-option-separator" />;
}
const actionName = option.name;
const label = option.contextItemLabel
? t(option.contextItemLabel)
: "";
return (
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
<button
className={clsx("context-menu-option", {
dangerous: actionName === "deleteSelectedElements",
checkmark: option.checked?.(appState),
})}
onClick={() => actionManager.executeAction(option)}
>
<div className="context-menu-option__label">{label}</div>
<kbd className="context-menu-option__shortcut">
{actionName
? getShortcutFromShortcutName(actionName as ShortcutName)
: ""}
</kbd>
</button>
</li>
);
})}
{options.map(({ action, checked, shortcutName, label }, idx) => (
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
<button
className={`context-menu-option
${shortcutName === "delete" ? "dangerous" : ""}
${checked ? "checkmark" : ""}`}
onClick={action}
>
<div className="context-menu-option__label">{label}</div>
<div className="context-menu-option__shortcut">
{shortcutName
? getShortcutFromShortcutName(shortcutName)
: ""}
</div>
</button>
</li>
))}
</ul>
</Popover>
</div>
@@ -97,10 +78,8 @@ const getContextMenuNode = (): HTMLDivElement => {
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
top: ContextMenuProps["top"];
left: ContextMenuProps["left"];
actionManager: ContextMenuProps["actionManager"];
appState: Readonly<AppState>;
top: number;
left: number;
};
const handleClose = () => {
@@ -122,8 +101,6 @@ export default {
left={params.left}
options={options}
onCloseRequest={handleClose}
actionManager={params.actionManager}
appState={params.appState}
/>,
getContextMenuNode(),
);

View File

@@ -1,11 +1,6 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.Dialog {
user-select: text;
cursor: auto;
}
.Dialog__title {
display: grid;
align-items: center;
@@ -15,7 +10,6 @@
padding: calc(var(--space-factor) * 2);
text-align: center;
font-variant: small-caps;
font-size: 1.2em;
}
.Dialog__titleContent {
@@ -45,7 +39,7 @@
position: sticky;
top: 0;
padding: calc(var(--space-factor) * 2);
background: var(--island-bg-color);
background: var(--bg-color-island);
font-size: 1.25em;
box-sizing: border-box;

View File

@@ -1,6 +1,5 @@
import clsx from "clsx";
import React, { useEffect } from "react";
import { useCallbackRefState } from "../hooks/useCallbackRefState";
import React, { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
@@ -9,6 +8,14 @@ import { back, close } from "./icons";
import { Island } from "./Island";
import { Modal } from "./Modal";
const useRefState = <T,>() => {
const [refValue, setRefValue] = useState<T | null>(null);
const refCallback = useCallback((value: T) => {
setRefValue(value);
}, []);
return [refValue, refCallback] as const;
};
export const Dialog = (props: {
children: React.ReactNode;
className?: string;
@@ -17,7 +24,7 @@ export const Dialog = (props: {
title: React.ReactNode;
autofocus?: boolean;
}) => {
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
useEffect(() => {
if (!islandNode) {
@@ -73,7 +80,7 @@ export const Dialog = (props: {
onCloseRequest={props.onCloseRequest}
>
<Island ref={setIslandNode}>
<h2 id="dialog-title" className="Dialog__title">
<h3 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
className="Modal__close"
@@ -82,7 +89,7 @@ export const Dialog = (props: {
>
{useIsMobile() ? back : close}
</button>
</h2>
</h3>
<div className="Dialog__content">{props.children}</div>
</Island>
</Modal>

View File

@@ -1,4 +1,4 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.ExportDialog__preview {

View File

@@ -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";
@@ -101,10 +102,6 @@ const ExportModal = ({
shouldAddWatermark,
});
if (canvas instanceof OffscreenCanvas) {
return;
}
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
canvasToBlob(canvas)
@@ -254,6 +251,7 @@ export const ExportDialog = ({
<>
<ToolButton
onClick={() => {
trackEvent(EVENT_DIALOG, "export");
setModalIsShown(true);
}}
icon={exportFile}

View File

@@ -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"

View File

@@ -1,359 +0,0 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin, isWindows } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
const Header = () => (
<div className="HelpDialog--header">
<a
className="HelpDialog--btn"
href="https://github.com/excalidraw/excalidraw#documentation"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.documentation")}
</a>
<a
className="HelpDialog--btn"
href="https://blog.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.blog")}
</a>
<a
className="HelpDialog--btn"
href="https://github.com/excalidraw/excalidraw/issues"
target="_blank"
rel="noopener noreferrer"
>
{t("helpDialog.github")}
</a>
</div>
);
const Section = (props: { title: string; children: React.ReactNode }) => (
<>
<h3>{props.title}</h3>
{props.children}
</>
);
const Columns = (props: { children: React.ReactNode }) => (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{props.children}
</div>
);
const Column = (props: { children: React.ReactNode }) => (
<div style={{ width: "49%" }}>{props.children}</div>
);
const ShortcutIsland = (props: {
caption: string;
children: React.ReactNode;
}) => (
<div className="HelpDialog--island">
<h3 className="HelpDialog--island-title">{props.caption}</h3>
{props.children}
</div>
);
const Shortcut = (props: {
label: string;
shortcuts: string[];
isOr: boolean;
}) => {
return (
<div className="HelpDialog--shortcut">
<div
style={{
display: "flex",
margin: "0",
padding: "4px 8px",
alignItems: "center",
}}
>
<div
style={{
lineHeight: 1.4,
}}
>
{props.label}
</div>
<div
style={{
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
marginInlineStart: "auto",
minWidth: "30%",
}}
>
{props.shortcuts.map((shortcut, index) => (
<React.Fragment key={index}>
<ShortcutKey>{shortcut}</ShortcutKey>
{props.isOr &&
index !== props.shortcuts.length - 1 &&
t("helpDialog.or")}
</React.Fragment>
))}
</div>
</div>
</div>
);
};
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => (
<kbd className="HelpDialog--key" {...props} />
);
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
}
}, [onClose]);
return (
<>
<Dialog
onCloseRequest={handleClose}
title={t("helpDialog.title")}
className={"HelpDialog"}
>
<Header />
<Section title={t("helpDialog.shortcuts")}>
<Columns>
<Column>
<ShortcutIsland caption={t("helpDialog.shapes")}>
<Shortcut
label={t("toolBar.selection")}
shortcuts={["V", "1"]}
/>
<Shortcut
label={t("toolBar.rectangle")}
shortcuts={["R", "2"]}
/>
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut
label={t("helpDialog.textNewLine")}
shortcuts={[
getShortcutKey("Enter"),
getShortcutKey("Shift+Enter"),
]}
/>
<Shortcut
label={t("helpDialog.textFinish")}
shortcuts={[
getShortcutKey("Esc"),
getShortcutKey("CtrlOrCmd+Enter"),
]}
/>
<Shortcut
label={t("helpDialog.curvedArrow")}
shortcuts={[
"A",
t("helpDialog.click"),
t("helpDialog.click"),
t("helpDialog.click"),
]}
isOr={false}
/>
<Shortcut
label={t("helpDialog.curvedLine")}
shortcuts={[
"L",
t("helpDialog.click"),
t("helpDialog.click"),
t("helpDialog.click"),
]}
isOr={false}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
<Shortcut
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
/>
</ShortcutIsland>
<ShortcutIsland caption={t("helpDialog.view")}>
<Shortcut
label={t("buttons.zoomIn")}
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
/>
<Shortcut
label={t("buttons.zoomOut")}
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
/>
<Shortcut
label={t("buttons.resetZoom")}
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
/>
<Shortcut
label={t("helpDialog.zoomToFit")}
shortcuts={["Shift+1"]}
/>
<Shortcut
label={t("helpDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
<Shortcut
label={t("labels.viewMode")}
shortcuts={[getShortcutKey("Alt+R")]}
/>
</ShortcutIsland>
</Column>
<Column>
<ShortcutIsland caption={t("helpDialog.editor")}>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
getShortcutKey(`Space+${t("helpDialog.drag")}`),
getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
]}
isOr={true}
/>
<Shortcut
label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/>
<Shortcut
label={t("labels.copy")}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/>
<Shortcut
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
<Shortcut
label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
/>
<Shortcut
label={t("labels.pasteStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Del")]}
/>
<Shortcut
label={t("labels.sendToBack")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
]}
/>
<Shortcut
label={t("labels.bringToFront")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
]}
/>
<Shortcut
label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/>
<Shortcut
label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/>
<Shortcut
label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/>
<Shortcut
label={t("labels.alignBottom")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
/>
<Shortcut
label={t("labels.alignLeft")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
/>
<Shortcut
label={t("labels.alignRight")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
/>
<Shortcut
label={t("labels.duplicateSelection")}
shortcuts={[
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
/>
<Shortcut
label={t("buttons.redo")}
shortcuts={
isWindows
? [
getShortcutKey("CtrlOrCmd+Y"),
getShortcutKey("CtrlOrCmd+Shift+Z"),
]
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
}
/>
<Shortcut
label={t("labels.group")}
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/>
<Shortcut
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
</ShortcutIsland>
</Column>
</Columns>
</Section>
</Dialog>
</>
);
};

View File

@@ -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>
);

View File

@@ -1,4 +1,4 @@
@import "../css/variables.module";
@import "../css/_variables";
// this is loosely based on the longest hint text
$wide-viewport-width: 1000px;
@@ -26,7 +26,7 @@ $wide-viewport-width: 1000px;
> span {
padding: 0.2rem 0.4rem;
background-color: var(--overlay-bg-color);
background-color: var(--overlay-background-color);
border-radius: 4px;
}
}

View File

@@ -1,4 +1,4 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.picker-container {
@@ -8,9 +8,9 @@
}
.picker {
background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
background: var(--popup-background-color);
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
border-radius: 4px;
position: absolute;
}
@@ -56,8 +56,8 @@
}
.picker-triangle {
width: 0;
height: 0;
width: 0px;
height: 0px;
position: relative;
top: -10px;
:root[dir="ltr"] & {
@@ -73,7 +73,7 @@
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-width: 0px 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
@@ -82,8 +82,8 @@
content: "";
position: absolute;
border-style: solid;
border-width: 0 9px 10px;
border-color: transparent transparent var(--popup-bg-color);
border-width: 0px 9px 10px;
border-color: transparent transparent var(--popup-background-color);
}
}
@@ -102,7 +102,6 @@
position: absolute;
bottom: 2px;
font-size: 0.7em;
color: var(--keybinding-color);
:root[dir="ltr"] & {
right: 2px;
@@ -121,7 +120,7 @@
}
.picker-type-elementBackground .picker-keybinding {
color: $oc-white;
color: #fff;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
@@ -134,10 +133,10 @@
&.Appearance_dark {
.picker-type-elementBackground .picker-keybinding {
color: $oc-black;
color: #000;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
color: $oc-black;
color: #000;
}
}
}

View File

@@ -1,7 +1,12 @@
import React from "react";
import { LoadingMessage } from "./LoadingMessage";
import { defaultLang, Language, languages, setLanguage } from "../i18n";
import {
defaultLang,
Language,
languages,
setLanguageFirstTime,
} from "../i18n";
interface Props {
langCode: Language["code"];
@@ -18,7 +23,7 @@ export class InitializeApp extends React.Component<Props, State> {
const currentLang =
languages.find((lang) => lang.code === this.props.langCode) ||
defaultLang;
await setLanguage(currentLang);
await setLanguageFirstTime(currentLang);
this.setState({
isLoading: false,
});

View File

@@ -1,7 +1,7 @@
.excalidraw {
.Island {
--padding: 0;
background-color: var(--island-bg-color);
background-color: var(--bg-color-island);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: 4px;

View File

@@ -19,9 +19,9 @@
}
a {
margin-inline-start: auto;
margin-left: auto;
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
padding-inline-end: 18px;
padding-right: 18px;
white-space: nowrap;
}
}
@@ -38,8 +38,6 @@
}
.layer-ui__wrapper {
z-index: var(--zIndex-layerUI);
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
@@ -73,7 +71,7 @@
&__footer {
position: absolute;
z-index: 100;
bottom: 0;
bottom: 0px;
:root[dir="ltr"] & {
right: 0;
@@ -94,7 +92,7 @@
}
:root[dir="ltr"] &.transition-right {
transform: translate(999px, 0);
transform: translate(999px, 0px);
}
:root[dir="rtl"] &.transition-left {

View File

@@ -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 { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n";
import useIsMobile from "../is-mobile";
import { showSelectedShapeActions } from "../element";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { exportCanvas } from "../data";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { Island } from "./Island";
import Stack from "./Stack";
import { FixedSideContainer } from "./FixedSideContainer";
import { UserList } from "./UserList";
import { LockIcon } from "./LockIcon";
import { ExportDialog, ExportCB } from "./ExportDialog";
import { Language, t } 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, trash } 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";
import { PasteChartDialog } from "./PasteChartDialog";
interface LayerUIProps {
actionManager: ActionManager;
@@ -52,7 +62,6 @@ interface LayerUIProps {
onLockToggle: () => void;
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
zenModeEnabled: boolean;
showExitZenModeBtn: boolean;
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
@@ -62,7 +71,6 @@ interface LayerUIProps {
canvas: HTMLCanvasElement | null,
) => void;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
}
const useOnClickOutside = (
@@ -100,7 +108,6 @@ const LibraryMenuItems = ({
onInsertShape,
pendingElements,
setAppState,
setLibraryItems,
}: {
library: LibraryItems;
pendingElements: LibraryItem;
@@ -108,7 +115,6 @@ const LibraryMenuItems = ({
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
setAppState: React.Component<any, AppState>["setState"];
setLibraryItems: (library: LibraryItems) => void;
}) => {
const isMobile = useIsMobile();
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
@@ -152,21 +158,14 @@ const LibraryMenuItems = ({
});
}}
/>
<ToolButton
key="reset"
type="button"
title={t("buttons.resetLibrary")}
aria-label={t("buttons.resetLibrary")}
icon={trash}
onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) {
Library.resetLibrary();
setLibraryItems([]);
}
}}
/>
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
<a
href="https://libraries.excalidraw.com"
target="_excalidraw_libraries"
onClick={() => {
trackEvent(EVENT_EXIT, "libraries");
}}
>
{t("labels.libraries")}
</a>
</div>,
@@ -268,6 +267,7 @@ const LibraryMenu = ({
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
Library.saveLibrary(nextItems);
trackEvent(EVENT_LIBRARY, "remove");
setLibraryItems(nextItems);
}, []);
@@ -276,6 +276,7 @@ const LibraryMenu = ({
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
trackEvent(EVENT_LIBRARY, "add");
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
@@ -296,7 +297,6 @@ const LibraryMenu = ({
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
setLibraryItems={setLibraryItems}
/>
)}
</Island>
@@ -313,12 +313,10 @@ const LayerUI = ({
onLockToggle,
onInsertElements,
zenModeEnabled,
showExitZenModeBtn,
toggleZenMode,
isCollaborating,
onExportToBackend,
renderCustomFooter,
viewModeEnabled,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@@ -330,6 +328,9 @@ 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}
@@ -378,28 +379,6 @@ const LayerUI = ({
);
};
const renderViewModeCanvasActions = () => {
return (
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": zenModeEnabled,
})}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
</Stack.Row>
</Stack.Col>
</Island>
</Section>
);
};
const renderCanvasActions = () => (
<Section
heading="canvasActions"
@@ -442,15 +421,7 @@ const LayerUI = ({
"transition-left": zenModeEnabled,
})}
>
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so substracting 200
// which is approximately height of zoom footer and top left menu items with some buffer
maxHeight: `${appState.height - 200}px`,
}}
>
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
<SelectedShapeActions
appState={appState}
elements={elements}
@@ -498,59 +469,54 @@ const LayerUI = ({
gap={4}
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
>
{viewModeEnabled
? renderViewModeCanvasActions()
: renderCanvasActions()}
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
{!viewModeEnabled && (
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
)}
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
zenModeEnabled={zenModeEnabled}
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
})}
>
{appState.collaborators.size > 0 &&
Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
</div>
</FixedSideContainer>
@@ -579,20 +545,6 @@ const LayerUI = ({
);
};
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
@@ -602,16 +554,28 @@ const LayerUI = ({
>
{renderCustomFooter?.(false)}
{actionManager.renderAction("toggleShortcuts")}
{actionManager.renderAction("toggleMinimap")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": showExitZenModeBtn,
"disable-zen-mode--visible": zenModeEnabled,
})}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
</button>
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
);
@@ -624,8 +588,10 @@ const LayerUI = ({
onClose={() => setAppState({ errorMessage: null })}
/>
)}
{appState.showHelpDialog && (
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
{appState.showShortcutsDialog && (
<ShortcutsDialog
onClose={() => setAppState({ showShortcutsDialog: false })}
/>
)}
{appState.pasteDialog.shown && (
<PasteChartDialog
@@ -657,35 +623,26 @@ const LayerUI = ({
canvas={canvas}
isCollaborating={isCollaborating}
renderCustomFooter={renderCustomFooter}
viewModeEnabled={viewModeEnabled}
/>
</>
) : (
<div
className={clsx("layer-ui__wrapper", {
"disable-pointerEvents":
appState.draggingElement ||
appState.resizingElement ||
(appState.editingElement && !isTextElement(appState.editingElement)),
})}
>
<div className="layer-ui__wrapper">
{dialogs}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{renderGitHubCorner()}
{renderFooter()}
{appState.scrolledOutside && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
{
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
{t("buttons.scrollBackToContent")}
</button>
)}
<GitHubCorner appearance={appState.appearance} />
</aside>
}
{renderFooter()}
</div>
);
};

View File

@@ -1,5 +0,0 @@
.Island.MiniMap {
position: absolute;
bottom: 50px;
right: calc(var(--space-factor) * 4);
}

View File

@@ -1,151 +0,0 @@
import "./MiniMap.scss";
import React, { useEffect, useRef, useMemo, useState } from "react";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { distance, viewportCoordsToSceneCoords } from "../utils";
import { Island } from "./Island";
// eslint-disable-next-line import/no-webpack-loader-syntax
import MinimapWorker from "worker-loader!../renderer/minimapWorker";
const RATIO = 1.2;
const MINIMAP_HEIGHT = 150;
const MINIMAP_WIDTH = MINIMAP_HEIGHT * RATIO;
const MinimapViewport = ({
elements,
appState,
}: {
elements: readonly ExcalidrawElement[];
appState: AppState;
}) => {
const [minX, minY, maxX, maxY] = useMemo(
() => getCommonBounds(getNonDeletedElements(elements)),
[elements],
);
const minimapScale = Math.min(
MINIMAP_WIDTH / distance(minX, maxX),
MINIMAP_HEIGHT / distance(minY, maxY),
);
const leftTop = viewportCoordsToSceneCoords(
{ clientX: 0, clientY: 0 },
appState,
);
const rightBot = viewportCoordsToSceneCoords(
{ clientX: appState.width, clientY: appState.height },
appState,
);
const top = (leftTop.y - minY) * minimapScale;
const left = (leftTop.x - minX) * minimapScale;
const width = (rightBot.x - leftTop.x) * minimapScale;
const height = (rightBot.y - leftTop.y) * minimapScale;
// Set viewport boundaries
const viewportTop = Math.min(Math.max(0, top), MINIMAP_HEIGHT);
const viewportLeft = Math.min(Math.max(0, left), MINIMAP_WIDTH);
const viewportWidth = Math.min(
MINIMAP_WIDTH - viewportLeft,
width,
width + left,
);
const viewportHeight = Math.min(
MINIMAP_HEIGHT - viewportTop,
height,
height + top,
);
if (
Number.isNaN(viewportTop) ||
Number.isNaN(viewportLeft) ||
Number.isNaN(viewportWidth) ||
Number.isNaN(viewportHeight)
) {
return null;
}
return (
<div
style={{
border: "2px solid orange",
boxSizing: "border-box",
position: "absolute",
pointerEvents: "none",
top: viewportTop,
left: viewportLeft,
width: viewportWidth,
height: viewportHeight,
}}
/>
);
};
export function MiniMap({
appState,
elements,
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
}) {
const [minimapWorker] = useState(() => new MinimapWorker());
const canvasRef = useRef<HTMLCanvasElement>(null);
const elementsRef = useRef(elements);
elementsRef.current = elements;
const appStateRef = useRef(appState);
appStateRef.current = appState;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const offscreenCanvas = canvas.transferControlToOffscreen();
minimapWorker.postMessage({ type: "INIT", canvas: offscreenCanvas }, [
offscreenCanvas,
]);
minimapWorker.postMessage({
type: "DRAW",
elements: elementsRef.current,
appState: appStateRef.current,
width: MINIMAP_WIDTH,
height: MINIMAP_HEIGHT,
});
setInterval(() => {
minimapWorker.postMessage({
type: "DRAW",
elements: elementsRef.current,
appState: appStateRef.current,
width: MINIMAP_WIDTH,
height: MINIMAP_HEIGHT,
});
}, 1000);
return () => {
minimapWorker.terminate();
};
}, [minimapWorker]);
return (
<Island padding={1} className="MiniMap">
<div
style={{
width: MINIMAP_WIDTH,
height: MINIMAP_HEIGHT,
position: "relative",
overflow: "hidden",
backgroundColor: appState.viewBackgroundColor,
}}
>
<canvas ref={canvasRef} />
<MinimapViewport elements={elements} appState={appState} />
</div>
</Island>
);
}

View File

@@ -16,6 +16,7 @@ import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
import { LockIcon } from "./LockIcon";
import { UserList } from "./UserList";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import { EVENT_ACTION, trackEvent } from "../analytics";
type MobileMenuProps = {
appState: AppState;
@@ -29,7 +30,6 @@ type MobileMenuProps = {
canvas: HTMLCanvasElement | null;
isCollaborating: boolean;
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
};
export const MobileMenu = ({
@@ -44,166 +44,122 @@ export const MobileMenu = ({
canvas,
isCollaborating,
renderCustomFooter,
viewModeEnabled,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
<FixedSideContainer side="top" className="App-top-bar">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
);
};
const renderAppToolbar = () => {
if (viewModeEnabled) {
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
</div>
);
}
return (
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
{actionManager.renderAction("deleteSelectedElements")}
</div>
);
};
const renderCanvasActions = () => {
if (viewModeEnabled) {
return (
<>
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
</>
);
}
return (
<>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
{
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/>
}
</>
);
};
return (
<>
{!viewModeEnabled && renderToolbar()}
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{renderCanvasActions()}
{renderCustomFooter?.(true)}
{appState.collaborators.size > 0 && (
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(
([_, client]) => Object.keys(client).length !== 0,
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
)}
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
!viewModeEnabled &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
}: MobileMenuProps) => (
<>
<FixedSideContainer side="top">
<Section heading="shapes">
{(heading) => (
<Stack.Col gap={4} align="center">
<Stack.Row gap={1}>
<Island padding={1}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
<LockIcon
checked={appState.elementLocked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
</Section>
) : null}
<footer className="App-toolbar">
{renderAppToolbar()}
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
<HintViewer appState={appState} elements={elements} />
</FixedSideContainer>
<div
className="App-bottom-bar"
style={{
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">
<Stack.Col gap={4}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/>
{renderCustomFooter?.(true)}
<fieldset>
<legend>{t("labels.collaborators")}</legend>
<UserList mobile>
{Array.from(appState.collaborators)
// Collaborator is either not initialized or is actually the current user.
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
</React.Fragment>
))}
</UserList>
</fieldset>
</Stack.Col>
</div>
</Section>
) : appState.openMenu === "shape" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions
appState={appState}
elements={elements}
renderAction={actionManager.renderAction}
elementType={appState.elementType}
/>
</Section>
) : null}
<footer className="App-toolbar">
<div className="App-toolbar-content">
{actionManager.renderAction("toggleCanvasMenu")}
{actionManager.renderAction("toggleEditMenu")}
{actionManager.renderAction("undo")}
{actionManager.renderAction("redo")}
{actionManager.renderAction(
appState.multiElement ? "finalize" : "duplicateSelection",
)}
</footer>
</Island>
</div>
</>
);
};
{actionManager.renderAction("deleteSelectedElements")}
</div>
{appState.scrolledOutside && !appState.openMenu && (
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</footer>
</Island>
</div>
</>
);

View File

@@ -1,13 +1,8 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
&.excalidraw-modal-container {
position: absolute;
z-index: 10;
}
.Modal {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
@@ -20,7 +15,7 @@
}
.Modal__background {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
@@ -44,10 +39,10 @@
overflow-y: auto;
// for modals, reset blurry bg
background: var(--island-bg-color);
background: var(--bg-color-island);
backdrop-filter: none;
border: 1px solid var(--dialog-border-color);
border: 1px solid var(--dialog-border);
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
border-radius: 6px;
@@ -87,7 +82,7 @@
}
.Modal__content {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;

View File

@@ -54,7 +54,7 @@ const useBodyRoot = () => {
?.classList.contains("Appearance_dark");
const div = document.createElement("div");
div.classList.add("excalidraw", "excalidraw-modal-container");
div.classList.add("excalidraw");
if (isDarkTheme) {
div.classList.add("Appearance_dark");

View File

@@ -1,4 +1,4 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.PasteChartDialog {

View File

@@ -1,6 +1,5 @@
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";
@@ -87,7 +86,6 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertChart(elements);
trackEvent("magic", "chart", chartType);
setAppState({
currentChartType: chartType,
pasteDialog: {

View File

@@ -1,6 +1,14 @@
.excalidraw {
.popover {
position: fixed;
position: absolute;
z-index: 10;
}
.popover .cover {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}

View File

@@ -1,28 +1,23 @@
@import "../css/variables.module";
@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;
}
}

View File

@@ -0,0 +1,338 @@
import React from "react";
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./ShortcutsDialog.scss";
import { EVENT_EXIT, trackEvent } from "../analytics";
const Columns = (props: { children: React.ReactNode }) => (
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{props.children}
</div>
);
const Column = (props: { children: React.ReactNode }) => (
<div style={{ width: "49%" }}>{props.children}</div>
);
const ShortcutIsland = (props: {
caption: string;
children: React.ReactNode;
}) => (
<div className="ShortcutsDialog-island">
<h3 className="ShortcutsDialog-island-title">{props.caption}</h3>
{props.children}
</div>
);
const Shortcut = (props: {
label: string;
shortcuts: string[];
isOr: boolean;
}) => {
return (
<div className="ShorcutsDialog-shortcut">
<div
style={{
display: "flex",
margin: "0",
padding: "4px 8px",
alignItems: "center",
}}
>
<div
style={{
lineHeight: 1.4,
}}
>
{props.label}
</div>
<div
style={{
display: "flex",
flex: "0 0 auto",
justifyContent: "flex-end",
marginInlineStart: "auto",
minWidth: "30%",
}}
>
{props.shortcuts.map((shortcut, index) => (
<React.Fragment key={index}>
<ShortcutKey>{shortcut}</ShortcutKey>
{props.isOr &&
index !== props.shortcuts.length - 1 &&
t("shortcutsDialog.or")}
</React.Fragment>
))}
</div>
</div>
</div>
);
};
Shortcut.defaultProps = {
isOr: true,
};
const ShortcutKey = (props: { children: React.ReactNode }) => (
<span className="ShorcutsDialog-key" {...props} />
);
const Footer = () => (
<div className="ShortcutsDialog-footer">
<a
href="https://blog.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "blog");
}}
>
{t("shortcutsDialog.blog")}
</a>
<a
href="https://howto.excalidraw.com"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "guides");
}}
>
{t("shortcutsDialog.howto")}
</a>
<a
href="https://github.com/excalidraw/excalidraw/issues"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "issues");
}}
>
{t("shortcutsDialog.github")}
</a>
</div>
);
export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
const handleClose = React.useCallback(() => {
if (onClose) {
onClose();
}
}, [onClose]);
return (
<>
<Dialog onCloseRequest={handleClose} title={t("shortcutsDialog.title")}>
<Columns>
<Column>
<ShortcutIsland caption={t("shortcutsDialog.shapes")}>
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
<Shortcut
label={t("toolBar.draw")}
shortcuts={["Shift+P", "7"]}
/>
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
<Shortcut
label={t("shortcutsDialog.textNewLine")}
shortcuts={[
getShortcutKey("Enter"),
getShortcutKey("Shift+Enter"),
]}
/>
<Shortcut
label={t("shortcutsDialog.textFinish")}
shortcuts={[
getShortcutKey("Esc"),
getShortcutKey("CtrlOrCmd+Enter"),
]}
/>
<Shortcut
label={t("shortcutsDialog.curvedArrow")}
shortcuts={[
"A",
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
]}
isOr={false}
/>
<Shortcut
label={t("shortcutsDialog.curvedLine")}
shortcuts={[
"L",
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
t("shortcutsDialog.click"),
]}
isOr={false}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
<Shortcut
label={t("shortcutsDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
/>
</ShortcutIsland>
<ShortcutIsland caption={t("shortcutsDialog.view")}>
<Shortcut
label={t("buttons.zoomIn")}
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
/>
<Shortcut
label={t("buttons.zoomOut")}
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
/>
<Shortcut
label={t("buttons.resetZoom")}
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
/>
<Shortcut
label={t("shortcutsDialog.zoomToFit")}
shortcuts={["Shift+1"]}
/>
<Shortcut
label={t("shortcutsDialog.zoomToSelection")}
shortcuts={["Shift+2"]}
/>
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
<Shortcut
label={t("buttons.zenMode")}
shortcuts={[getShortcutKey("Alt+Z")]}
/>
<Shortcut
label={t("labels.gridMode")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
</ShortcutIsland>
</Column>
<Column>
<ShortcutIsland caption={t("shortcutsDialog.editor")}>
<Shortcut
label={t("labels.selectAll")}
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
/>
<Shortcut
label={t("labels.multiSelect")}
shortcuts={[
getShortcutKey(`Shift+${t("shortcutsDialog.click")}`),
]}
/>
<Shortcut
label={t("labels.moveCanvas")}
shortcuts={[
getShortcutKey(`Space+${t("shortcutsDialog.drag")}`),
getShortcutKey(`Wheel+${t("shortcutsDialog.drag")}`),
]}
isOr={true}
/>
<Shortcut
label={t("labels.cut")}
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
/>
<Shortcut
label={t("labels.copy")}
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
/>
<Shortcut
label={t("labels.paste")}
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
/>
<Shortcut
label={t("labels.copyAsPng")}
shortcuts={[getShortcutKey("Shift+Alt+C")]}
/>
<Shortcut
label={t("labels.copyStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
/>
<Shortcut
label={t("labels.pasteStyles")}
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
/>
<Shortcut
label={t("labels.delete")}
shortcuts={[getShortcutKey("Del")]}
/>
<Shortcut
label={t("labels.sendToBack")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
]}
/>
<Shortcut
label={t("labels.bringToFront")}
shortcuts={[
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
]}
/>
<Shortcut
label={t("labels.sendBackward")}
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
/>
<Shortcut
label={t("labels.bringForward")}
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
/>
<Shortcut
label={t("labels.alignTop")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
/>
<Shortcut
label={t("labels.alignBottom")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
/>
<Shortcut
label={t("labels.alignLeft")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
/>
<Shortcut
label={t("labels.alignRight")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
/>
<Shortcut
label={t("labels.duplicateSelection")}
shortcuts={[
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
]}
/>
<Shortcut
label={t("buttons.undo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
/>
<Shortcut
label={t("buttons.redo")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
/>
<Shortcut
label={t("labels.group")}
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
/>
<Shortcut
label={t("labels.ungroup")}
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
/>
</ShortcutIsland>
</Column>
</Columns>
<Footer />
</Dialog>
</>
);
};

View File

@@ -1,53 +1,51 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.Stats {
position: absolute;
top: 64px;
right: 12px;
font-size: 12px;
z-index: 999;
.Stats {
position: fixed;
top: 64px;
right: 12px;
font-size: 12px;
z-index: 999;
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
h3 {
margin: 0 24px 8px 0;
white-space: nowrap;
}
.close {
float: right;
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
height: 100%;
}
}
table {
.close {
float: right;
height: 16px;
width: 16px;
cursor: pointer;
svg {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
height: 100%;
}
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
table {
width: 100%;
th {
border-bottom: 1px solid var(--input-border-color);
padding: 4px;
}
tr {
td:nth-child(2) {
min-width: 24px;
text-align: right;
}
}
}
:root[dir="rtl"] & {
left: 12px;
right: initial;
h3 {
margin: 0 0 8px 24px;
}
.close {
float: left;
}
}
}

View File

@@ -1,6 +1,4 @@
import React, { useEffect, useState } from "react";
import { copyTextToSystemClipboard } from "../clipboard";
import { DEFAULT_VERSION } from "../constants";
import { getCommonBounds } from "../element/bounds";
import { NonDeletedExcalidrawElement } from "../element/types";
import {
@@ -11,7 +9,7 @@ import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { getTargetElements } from "../scene";
import { AppState } from "../types";
import { debounce, getVersion, nFormatter } from "../utils";
import { debounce, nFormatter } from "../utils";
import { close } from "./icons";
import { Island } from "./Island";
import "./Stats.scss";
@@ -27,7 +25,6 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
export const Stats = (props: {
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
elements: readonly NonDeletedExcalidrawElement[];
onClose: () => void;
}) => {
@@ -53,17 +50,6 @@ export const Stats = (props: {
return null;
}
const version = getVersion();
let hash;
let timestamp;
if (version !== DEFAULT_VERSION) {
timestamp = version.slice(0, 16).replace("T", " ");
hash = version.slice(21);
} else {
timestamp = t("stats.versionNotAvailable");
}
return (
<div className="Stats">
<Island padding={2}>
@@ -170,28 +156,6 @@ export const Stats = (props: {
</td>
</tr>
)}
<tr>
<th colSpan={2}>{t("stats.version")}</th>
</tr>
<tr>
<td
colSpan={2}
style={{ textAlign: "center", cursor: "pointer" }}
onClick={async () => {
try {
await copyTextToSystemClipboard(getVersion());
props.setAppState({
toastMessage: t("toast.copyToClipboard"),
});
} catch {}
}}
title={t("stats.versionCopy")}
>
{timestamp}
<br />
{hash}
</td>
</tr>
</tbody>
</table>
</Island>

View File

@@ -1,19 +1,19 @@
@import "../css/variables.module";
@import "../css/_variables.scss";
.excalidraw {
.TextInput {
color: var(--text-primary-color);
color: var(--text-color-primary);
display: inline-block;
border: 1.5px solid var(--button-gray-1);
line-height: 1;
padding: 0.75rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: var(--input-bg-color);
background-color: var(--input-background-color);
&:not(:focus) {
&:hover {
background-color: var(--input-hover-bg-color);
background-color: var(--input-hover-background-color);
}
}

View File

@@ -1,32 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.Toast {
animation: fade-in 0.5s;
background-color: var(--button-gray-1);
border-radius: 4px;
bottom: 10px;
box-sizing: border-box;
cursor: default;
left: 50%;
margin-left: -150px;
padding: 4px 0;
position: absolute;
text-align: center;
width: 300px;
z-index: 999999;
}
.Toast__message {
color: var(--popup-text-color);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}

View File

@@ -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>
);
};

View File

@@ -1,5 +1,5 @@
@import "open-color/open-color.scss";
@import "../css/variables.module";
@import "../css/variables";
.excalidraw {
.ToolIcon {

View File

@@ -1,4 +1,4 @@
@import "../css/variables.module";
@import "../css/_variables";
.excalidraw {
.Tooltip {
position: relative;
@@ -48,7 +48,15 @@
}
}
.Tooltip:hover .Tooltip__label {
// the following 3 rules ensure that the tooltip doesn't show (nor affect
// the cursor) when you drag over when you draw on canvas, but at the same
// time it still works when clicking on the link/shield
body:active & .Tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) & .Tooltip:hover .Tooltip__label {
visibility: visible;
}

View File

@@ -2,8 +2,8 @@ import { FontFamily } from "./element/types";
export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // px
export const LINE_CONFIRM_THRESHOLD = 8; // px
export const DRAGGING_THRESHOLD = 10; // 10px
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
@@ -46,8 +46,6 @@ export enum EVENT {
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
SCROLL = "scroll",
}
export const ENV = {
@@ -72,7 +70,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"];
@@ -91,19 +88,5 @@ export const STORAGE_KEYS = {
export const TAP_TWICE_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 500;
export const ZOOM_STEP = 0.1;
// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
export const MODES = {
VIEW: "viewMode",
ZEN: "zenMode",
GRID: "gridMode",
};
export const SCENE_NAME_FALLBACK = "Untitled";

View File

@@ -1,42 +0,0 @@
import React from "react";
export const createInverseContext = <T extends unknown = null>(
initialValue: T,
) => {
const Context = React.createContext(initialValue) as React.Context<T> & {
_updateProviderValue?: (value: T) => void;
};
class InverseConsumer extends React.Component {
state = { value: initialValue };
constructor(props: any) {
super(props);
Context._updateProviderValue = (value: T) => this.setState({ value });
}
render() {
return (
<Context.Provider value={this.state.value}>
{this.props.children}
</Context.Provider>
);
}
}
class InverseProvider extends React.Component<{ value: T }> {
componentDidMount() {
Context._updateProviderValue?.(this.props.value);
}
componentDidUpdate() {
Context._updateProviderValue?.(this.props.value);
}
render() {
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
}
}
return {
Context,
Consumer: InverseConsumer,
Provider: InverseProvider,
};
};

View File

@@ -2,7 +2,3 @@
// keep up to date with is-mobile.tsx
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
:export {
isMobileQuery: unquote($is-mobile-query);
}

View File

@@ -1,15 +1,10 @@
@import "./variables.module";
@import "./_variables";
@import "./theme";
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
}
.excalidraw {
color: var(--text-primary-color);
color: var(--text-color-primary);
display: flex;
position: fixed;
top: 0;
bottom: 0;
left: 0;
@@ -18,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;
@@ -35,8 +30,6 @@
image-rendering: pixelated; // chromium
// NOTE: must be declared *after* the above
image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
}
&.Appearance_dark {
@@ -50,10 +43,10 @@
}
.FixedSideContainer {
padding-top: var(--sat, 0);
padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0);
padding-top: var(--sat, 0px);
padding-right: var(--sar, 0px);
padding-bottom: var(--sab, 0px);
padding-left: var(--sal, 0px);
}
.panelRow {
@@ -71,7 +64,7 @@
margin-top: 0.333rem;
margin-bottom: 0.333rem;
font-size: 0.75rem;
color: var(--text-primary-color);
color: var(--text-color-primary);
font-weight: bold;
display: block;
}
@@ -223,13 +216,6 @@
}
}
.App-top-bar {
z-index: var(--zIndex-layerUI);
display: flex;
flex-direction: column;
align-items: center;
}
.App-bottom-bar {
position: absolute;
top: 0;
@@ -237,10 +223,10 @@
left: 0;
right: 0;
--bar-padding: calc(4 * var(--space-factor));
padding-top: #{"max(var(--bar-padding), var(--sat,0))"};
padding-right: var(--sar, 0);
padding-bottom: var(--sab, 0);
padding-left: var(--sal, 0);
padding-top: #{"max(var(--bar-padding), var(--sat, 0px))"};
padding-right: var(--sar, 0px);
padding-bottom: var(--sab, 0px);
padding-left: var(--sal, 0px);
z-index: 4;
display: flex;
align-items: flex-end;
@@ -257,7 +243,7 @@
pointer-events: initial;
.panelColumn {
padding: 8px 8px 0 8px;
padding: 8px 8px 0px 8px;
}
}
}
@@ -296,7 +282,7 @@
pointer-events: none !important;
}
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
.App-menu_top > * {
pointer-events: all;
}
@@ -337,7 +323,7 @@
}
}
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
.App-menu_bottom > * {
pointer-events: all;
}
@@ -361,6 +347,7 @@
.App-menu__left {
overflow-y: auto;
max-height: calc(100vh - 236px);
}
.dropdown-select {
@@ -432,7 +419,7 @@
.scroll-back-to-content {
color: var(--popup-text-color);
position: absolute;
position: fixed;
left: 50%;
bottom: 30px;
transform: translateX(-50%);
@@ -444,7 +431,6 @@
cursor: pointer;
fill: $oc-gray-6;
bottom: 14px;
width: 1.5rem;
:root[dir="ltr"] & {
right: 14px;
@@ -460,7 +446,7 @@
display: none;
}
.scroll-back-to-content {
bottom: calc(80px + var(--sab, 0));
bottom: calc(80px + var(--sab, 0px));
z-index: -1;
}
}
@@ -505,13 +491,6 @@
pointer-events: none !important;
}
&.excalidraw--view-mode {
.App-menu {
display: flex;
justify-content: space-between;
}
}
@media print {
.App-bottom-bar,
.FixedSideContainer,

View File

@@ -1,43 +1,42 @@
@import "open-color/open-color.scss";
:root {
--appearance-filter: none;
--button-destructive-bg-color: #{$oc-red-1};
--button-destructive-color: #{$oc-red-9};
--bg-color-island: rgba(255, 255, 255, 0.9);
--popup-background-color: #{$oc-white};
--space-factor: 0.25rem;
--button-gray-1: #{$oc-gray-2};
--button-gray-2: #{$oc-gray-4};
--button-gray-3: #{$oc-gray-5};
--button-special-active-bg-color: #{$oc-green-0};
--dialog-border-color: #{$oc-gray-6};
--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};
--input-border-color: #{$oc-gray-3};
--input-background-color: #{$oc-white};
--input-hover-background-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--icon-fill-color: #{$oc-black};
--icon-green-fill-color: #{$oc-green-9};
--input-bg-color: #{$oc-white};
--input-border-color: #{$oc-gray-3};
--input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7};
--island-bg-color: rgba(255, 255, 255, 0.9);
--keybinding-color: #{$oc-gray-5};
--link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
--popup-bg-color: #{$oc-white};
--popup-secondary-bg-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--sat: env(safe-area-inset-top);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
--sar: env(safe-area-inset-right);
--sat: env(safe-area-inset-top);
--select-highlight-color: #{$oc-blue-5};
--text-color-primary: #{$oc-gray-8};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
--space-factor: 0.25rem;
--text-primary-color: #{$oc-gray-8};
--overlay-background-color: #{transparentize($oc-white, 0.12)};
--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};
--appearance-filter: none;
--button-special-active-background-color: #{$oc-green-0};
--button-destructive-color: #{$oc-red-9};
--button-destructive-background-color: #{$oc-red-1};
--popup-secondary-background-color: #{$oc-gray-1};
--popup-text-color: #{$oc-black};
--popup-text-inverted-color: #{$oc-white};
--dialog-border: #{$oc-gray-6};
}
.excalidraw {
&.Appearance_dark {
background: $oc-black;
background: #000;
&.Appearance_dark-background-none {
background: none;
@@ -45,31 +44,31 @@
}
&.Appearance_dark {
--appearance-filter: invert(93%) hue-rotate(180deg);
--button-destructive-bg-color: #5a0000;
--button-destructive-color: #{$oc-red-3};
--text-color-primary: #{$oc-gray-4};
--bg-color-island: #1e1e1e;
--popup-background-color: #2c2c2c;
--button-gray-1: #363636;
--button-gray-2: #272727;
--button-gray-3: #222;
--button-special-active-bg-color: #204624;
--dialog-border-color: #{$oc-gray-9};
--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};
--input-border-color: #2e2e2e;
--input-background-color: #121212;
--input-hover-background-color: #181818;
--input-label-color: #{$oc-gray-2};
--icon-fill-color: #{$oc-gray-4};
--icon-green-fill-color: #{$oc-green-4};
--input-bg-color: #121212;
--input-border-color: #2e2e2e;
--input-hover-bg-color: #181818;
--input-label-color: #{$oc-gray-2};
--island-bg-color: #1e1e1e;
--keybinding-color: #{$oc-gray-6};
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
--popup-bg-color: #2c2c2c;
--popup-secondary-bg-color: #222;
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--overlay-background-color: rgba(30, 30, 30, 0.88);
--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};
--appearance-filter: invert(93%) hue-rotate(180deg);
--button-special-active-background-color: #204624;
--button-destructive-color: #{$oc-red-3};
--button-destructive-background-color: #5a0000;
--popup-secondary-background-color: #222;
--popup-text-color: #{$oc-gray-4};
--popup-text-inverted-color: #2c2c2c;
--select-highlight-color: #{$oc-blue-4};
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
--text-primary-color: #{$oc-gray-4};
--dialog-border: #{$oc-gray-9};
}
}

View File

@@ -1,3 +1,4 @@
import { EVENT_IO, trackEvent } from "../analytics";
import { cleanAppStateForExport } from "../appState";
import { MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
@@ -110,6 +111,7 @@ export const loadFromBlob = async (
localAppState,
);
trackEvent(EVENT_IO, "load", getMimeType(blob));
return result;
} catch (error) {
console.error(error.message);

View File

@@ -1,4 +1,5 @@
import { fileSave } from "browser-fs-access";
import { fileSave } from "browser-nativefs";
import { EVENT_IO, trackEvent } from "../analytics";
import {
copyCanvasToClipboardAsPng,
copyTextToSystemClipboard,
@@ -7,8 +8,8 @@ import { 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";
@@ -36,7 +37,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 +60,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;
}
@@ -73,11 +76,6 @@ export const exportCanvas = async (
scale,
shouldAddWatermark,
});
if (tempCanvas instanceof OffscreenCanvas) {
return;
}
tempCanvas.style.display = "none";
document.body.appendChild(tempCanvas);
@@ -97,9 +95,11 @@ 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;

View File

@@ -1,11 +1,13 @@
import { fileOpen, fileSave } from "browser-fs-access";
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);
};

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