Compare commits
203 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b20fe944e7 | |||
| 1156ef6b96 | |||
| dc25fe06d0 | |||
| 1e17c1967b | |||
| 23a8891e0e | |||
| 6c81a32d62 | |||
| f0f5430313 | |||
| e18e945cd3 | |||
| bd0c6e63ff | |||
| dbae33e4f8 | |||
| 0f4a053759 | |||
| 1837147c55 | |||
| 15f698dc21 | |||
| ce507b0a0b | |||
| 02598c6163 | |||
| 3e2890bd21 | |||
| 210649f383 | |||
| f8087e01c8 | |||
| e2522645f7 | |||
| d46a9166be | |||
| 675da16ca4 | |||
| 2b1b62d8f2 | |||
| 627c56ef1c | |||
| 0d0fe32485 | |||
| 6576b9442e | |||
| ee4cb2d4a9 | |||
| cdcc91faa5 | |||
| 9093341dc1 | |||
| 1973ae9444 | |||
| 10cd6a24b0 | |||
| 6abf4f52ff | |||
| 4624ec2bd6 | |||
| e8685c5236 | |||
| 6e9df2bae7 | |||
| ed0bec41dc | |||
| d4e12a2962 | |||
| 978e85a33b | |||
| b5e26ba81f | |||
| 4392a4644a | |||
| b1c8c538ee | |||
| f6492895df | |||
| e75f5f20e7 | |||
| 0a0be839b9 | |||
| 03f6d9c783 | |||
| df745c1098 | |||
| b888f7e7ba | |||
| 3010253f72 | |||
| 0815f3282e | |||
| 9dd58288de | |||
| c2e2bb495c | |||
| a31cfe1f07 | |||
| d3367bfe12 | |||
| 70791dfa7b | |||
| d63ec678db | |||
| 26acebcdb6 | |||
| 9dc930b447 | |||
| 6e767fc949 | |||
| 49bd683401 | |||
| 3922ee8c11 | |||
| 8c2bc94336 | |||
| fb4d97ef78 | |||
| a8e28afbad | |||
| 68347ba476 | |||
| ce2c341910 | |||
| 07cc858926 | |||
| a7a2936f7c | |||
| e72ff6be66 | |||
| 0ea29675df | |||
| da2ad4f37c | |||
| 33a7cf0d3f | |||
| f8e890df7b | |||
| 12337a54a3 | |||
| 7a9ed2cfa1 | |||
| 553bf2956f | |||
| 1e16a6e5bd | |||
| e26f374ca6 | |||
| c799b28a0e | |||
| ee703206b0 | |||
| 543c624405 | |||
| 0bf6830373 | |||
| af79461f41 | |||
| fd699c0447 | |||
| 6a16caf13c | |||
| 511eb62228 | |||
| 04c46fc01a | |||
| 49e792649d | |||
| 4e1caf2417 | |||
| 034f2e470b | |||
| adcd28f348 | |||
| 62f1ed74f1 | |||
| 8fa4273969 | |||
| 672068ce7e | |||
| f1fc308a5d | |||
| 001880ba88 | |||
| 3a130cb102 | |||
| e682cf9bf6 | |||
| 2a169924d0 | |||
| eb6e75b806 | |||
| 38857b9e9d | |||
| 342289f261 | |||
| 095d7de618 | |||
| f57d52028a | |||
| 60557df23a | |||
| bafbe9bbc8 | |||
| eb71e571e0 | |||
| b608ab74cc | |||
| a13c4f72f5 | |||
| 629341da4d | |||
| 778e4b08af | |||
| e16266ce5d | |||
| b6708fb73f | |||
| cdffed285d | |||
| 3aa01ad272 | |||
| 4acdc47ef0 | |||
| ade2565f49 | |||
| c35d983fef | |||
| 69878167c2 | |||
| eb1f717d35 | |||
| 8e9af5c51b | |||
| afe0c760f6 | |||
| a231cefac0 | |||
| cb4c9d16fc | |||
| 7366f089ba | |||
| 7c3513b9df | |||
| aef3644c93 | |||
| 0cf58adb4c | |||
| 3aab81bc35 | |||
| 3b0fb1562d | |||
| 0488b7b5c6 | |||
| b8d13c98b5 | |||
| 6f82a88b79 | |||
| 022f349dc6 | |||
| c1e2146d78 | |||
| 8091ac6c08 | |||
| bc414ccaaf | |||
| 0cf5f1ac1f | |||
| e9cb7ee77c | |||
| 86c036505b | |||
| 39e7b8cf4f | |||
| e0ece680a6 | |||
| 8dfea49ec1 | |||
| d7f314cda8 | |||
| 6adb45ef5a | |||
| b0eeb8e6e6 | |||
| c3c20b6087 | |||
| 0faec7efb6 | |||
| aff817c667 | |||
| 9a3a3ecb44 | |||
| 81f8039ec7 | |||
| 14759d5b72 | |||
| b997e69ebc | |||
| 4a89aba682 | |||
| 34dcf998bd | |||
| 325d1bec91 | |||
| b917e42694 | |||
| eb9e67e36a | |||
| f14ae52e94 | |||
| f93eb658d6 | |||
| 9bac44ee75 | |||
| 91b4109f67 | |||
| 6e45cb95db | |||
| 5edf82898b | |||
| 60b82e3055 | |||
| 5d6590c200 | |||
| d3bebbc68d | |||
| 4ff8f3b006 | |||
| abbc756887 | |||
| 10e07e434c | |||
| fb582b45db | |||
| 23f21434ff | |||
| 7e9fdf85a0 | |||
| 98c26642d0 | |||
| 2b434db062 | |||
| f919907855 | |||
| bfeb3c7dfd | |||
| 8729ab3c54 | |||
| 37f53bccbf | |||
| c783763307 | |||
| f151f45df8 | |||
| f33880e005 | |||
| 29ed50f6ea | |||
| ce52c18382 | |||
| 7c3e1d8d1b | |||
| 0d15934a96 | |||
| a0069d04f0 | |||
| 3b86944365 | |||
| 4c7b1a2269 | |||
| 60864ace54 | |||
| 4b32c03994 | |||
| 222dbdcc00 | |||
| 1346227d30 | |||
| ecbddd214c | |||
| 4999ca5c82 | |||
| 174638889d | |||
| 480998582e | |||
| 94544e458c | |||
| f664ba9e1e | |||
| 015a60638e | |||
| 7abb80530f | |||
| 5abe9b93e8 | |||
| 59cff0f219 | |||
| 81c17a56fb | |||
| 802b8c50d5 |
@@ -1,5 +1,5 @@
|
|||||||
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
|
||||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
|
REACT_APP_SOCKET_SERVER_URL=https://portal.excalidraw.com
|
||||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
REACT_APP_INCLUDE_GTAG=true
|
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- lipis
|
||||||
|
assignees:
|
||||||
|
- lipis
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/src/packages/excalidraw/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
|
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: "/src/packages/utils/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: sunday
|
||||||
|
time: "01:00"
|
||||||
|
open-pull-requests-limit: 99
|
||||||
|
reviewers:
|
||||||
|
- ad1992
|
||||||
|
assignees:
|
||||||
|
- ad1992
|
||||||
@@ -4,10 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docker:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
- name: Setup Node.js 14.x
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 12.x
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Cancel previous runs
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cancel:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
timeout-minutes: 3
|
||||||
|
steps:
|
||||||
|
- uses: styfle/cancel-workflow-action@0.6.0
|
||||||
|
with:
|
||||||
|
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
|
||||||
|
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: "CodeQL"
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
schedule:
|
|
||||||
- cron: "18 7 * * 0"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analyze:
|
|
||||||
name: Analyze
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
language: ["typescript"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
|
||||||
uses: github/codeql-action/init@v1
|
|
||||||
with:
|
|
||||||
languages: ${{ matrix.language }}
|
|
||||||
- name: Autobuild
|
|
||||||
uses: github/codeql-action/autobuild@v1
|
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
|
||||||
uses: github/codeql-action/analyze@v1
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on:
|
on: pull_request
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -13,10 +9,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
- name: Setup Node.js 14.x
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 12.x
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Install and lint
|
- name: Install and lint
|
||||||
run: |
|
run: |
|
||||||
@@ -24,5 +20,3 @@ jobs:
|
|||||||
npm run test:other
|
npm run test:other
|
||||||
npm run test:code
|
npm run test:code
|
||||||
npm run test:typecheck
|
npm run test:typecheck
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
|
|||||||
@@ -14,19 +14,34 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
- name: Setup Node.js 14.x
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 12.x
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Create report file
|
- name: Create report file
|
||||||
run: |
|
run: |
|
||||||
npm run locales-coverage
|
npm run locales-coverage
|
||||||
FILE_CHANGED=$(git diff src/locales/percentages.json)
|
FILE_CHANGED=$(git diff src/locales/percentages.json)
|
||||||
if [ ! -z "${FILE_CHANGED}" ]; then
|
if [ ! -z "${FILE_CHANGED}" ]; then
|
||||||
git config --global user.name 'Kostas Bariotis'
|
git config --global user.name 'Excalidraw Bot'
|
||||||
git config --global user.email 'konmpar@gmail.com'
|
git config --global user.email 'bot@excalidraw.com'
|
||||||
git add src/locales/percentages.json
|
git add src/locales/percentages.json
|
||||||
git commit -am "Auto commit: Calculate translation coverage"
|
git commit -am "Auto commit: Calculate translation coverage"
|
||||||
git push
|
git push
|
||||||
fi
|
fi
|
||||||
|
- name: Construct comment body
|
||||||
|
id: getCommentBody
|
||||||
|
run: |
|
||||||
|
body=$(npm run locales-coverage:description | grep '^[^>]')
|
||||||
|
body="${body//'%'/'%25'}"
|
||||||
|
body="${body//$'\n'/'%0A'}"
|
||||||
|
body="${body//$'\r'/'%0D'}"
|
||||||
|
echo ::set-output name=body::$body
|
||||||
|
|
||||||
|
- name: Update description with coverage
|
||||||
|
uses: kt3k/update-pr-description@v1.0.1
|
||||||
|
with:
|
||||||
|
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||||
|
pr_title: "chore: Update translations from Crowdin"
|
||||||
|
github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: "Semantic PR title"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@v3.0.0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -8,13 +8,14 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1.0.0
|
- uses: actions/checkout@v1.0.0
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
- name: Setup Node.js 14.x
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 12.x
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Install and build
|
- name: Install and build
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on: pull_request
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -13,14 +9,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
|
|
||||||
- name: Setup Node.js 12.x
|
- name: Setup Node.js 14.x
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 12.x
|
node-version: 14.x
|
||||||
|
|
||||||
- name: Install and test
|
- name: Install and test
|
||||||
run: |
|
run: |
|
||||||
npm ci
|
npm ci
|
||||||
npm run test:app
|
npm run test:app
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
|
"proseWrap": "never",
|
||||||
"trailingComma": "all"
|
"trailingComma": "all"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
1. Run `npm install` to install dependencies
|
1. Run `npm install` to install dependencies
|
||||||
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
1. Create a branch for your PR with `git checkout -b your-branch-name`
|
||||||
|
|
||||||
> To keep `master` branch pointing to remote repository and make
|
> To keep `master` branch pointing to remote repository and make pull requests from branches on your fork. To do this, run:
|
||||||
> pull requests from branches on your fork. To do this, run:
|
|
||||||
>
|
>
|
||||||
> ```sh
|
> ```sh
|
||||||
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
> git remote add upstream https://github.com/excalidraw/excalidraw.git
|
||||||
@@ -25,3 +24,41 @@
|
|||||||
1. Tap on `Fork Sandbox`
|
1. Tap on `Fork Sandbox`
|
||||||
1. Write your code
|
1. Write your code
|
||||||
1. Commit and PR automatically
|
1. Commit and PR automatically
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
Don't worry if you get any of the below wrong, or if you don't know how. We'll gladly help out.
|
||||||
|
|
||||||
|
### Title
|
||||||
|
|
||||||
|
Make sure the title starts with a semantic prefix:
|
||||||
|
|
||||||
|
- **feat**: A new feature
|
||||||
|
- **fix**: A bug fix
|
||||||
|
- **improvement**: An improvement to a current feature
|
||||||
|
- **docs**: Documentation only changes
|
||||||
|
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
||||||
|
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||||
|
- **perf**: A code change that improves performance
|
||||||
|
- **test**: Adding missing tests or correcting existing tests
|
||||||
|
- **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
|
||||||
|
- **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
|
||||||
|
- **chore**: Other changes that don't modify src or test files
|
||||||
|
- **revert**: Reverts a previous commit
|
||||||
|
|
||||||
|
### Changelog
|
||||||
|
|
||||||
|
Add a brief description of your pull request to the changelog located here: [`src/packages/excalidraw/CHANGELOG.md`](src/packages/excalidraw/CHANGELOG.md)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Make sure to prepend to the section corresponding with the semantic prefix you selected in the title
|
||||||
|
- Link to your pull request - this will require updating the CHANGELOG _after_ creating the pull request
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Once you submit your pull request it will automatically be tested. Be sure to check the results of the test and fix any issues that arise.
|
||||||
|
|
||||||
|
It's also a good idea to consider if your change should include additional tests. This is highly recommended for new features or bug-fixes. For example, it's good practice to create a test for each bug you fix which ensures that we don't regress the code in the future.
|
||||||
|
|
||||||
|
Finally - always manually test your changes using the convenient staging environment deployed for each pull request. As much as local development attempts to replicate production, there can still be subtle differences in behavior. For larger features consider testing your change in multiple browsers as well.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ WORKDIR /opt/node_app
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm i --no-optional
|
RUN npm i --no-optional
|
||||||
|
|
||||||
ARG REACT_APP_INCLUDE_GTAG=false
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div align="center" style="display:flex;flex-direction:column;">
|
<div align="center" style="display:flex;flex-direction:column;">
|
||||||
<a href="https://excalidraw.com">
|
<a href="https://excalidraw.com">
|
||||||
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
|
||||||
</a>
|
</a>
|
||||||
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
|
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br>Collaborative and end to end encrypted.</h3>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://twitter.com/Excalidraw">
|
<a href="https://twitter.com/Excalidraw">
|
||||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
|
||||||
@@ -10,9 +10,6 @@
|
|||||||
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
<a target="_blank" href="https://crowdin.com/project/excalidraw">
|
||||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||||
</a>
|
</a>
|
||||||
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
|
|
||||||
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,13 +17,51 @@
|
|||||||
|
|
||||||
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
||||||
|
|
||||||
Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively.
|
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### Shortcuts
|
||||||
|
|
||||||
|
You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all.
|
||||||
|
|
||||||
|
### Curved lines and arrows
|
||||||
|
|
||||||
|
Choose line or arrow and click click click instead of drag.
|
||||||
|
|
||||||
|
### Charts
|
||||||
|
|
||||||
|
You can easily create charts by copy pasting data from Excel or just plain comma separated text.
|
||||||
|
|
||||||
|
### Translating
|
||||||
|
|
||||||
|
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
||||||
|
|
||||||
|
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
||||||
|
|
||||||
|
### Create a collaboration session manually
|
||||||
|
|
||||||
|
In order to create a session manually you just need to generate a link of this form:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA
|
||||||
|
```
|
||||||
|
|
||||||
|
The first set of digits is the room. This is visible from the server that’s going to dispatch messages to everyone that knows this number.
|
||||||
|
|
||||||
|
The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages.
|
||||||
|
|
||||||
## Shape libraries
|
## Shape libraries
|
||||||
|
|
||||||
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
|
||||||
|
|
||||||
## Run the code
|
## Developement
|
||||||
|
|
||||||
### Code Sandbox
|
### Code Sandbox
|
||||||
|
|
||||||
@@ -63,7 +98,7 @@ You can use docker-compose to work on excalidraw locally if you don't want to se
|
|||||||
docker-compose up --build -d
|
docker-compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Self hosting
|
### Self hosting
|
||||||
|
|
||||||
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc.
|
||||||
|
|
||||||
@@ -82,57 +117,11 @@ We are working towards providing a full-fledged solution for self hosting your o
|
|||||||
|
|
||||||
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
|
Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change.
|
||||||
|
|
||||||
## Translating
|
## Notable used tools
|
||||||
|
|
||||||
To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first.
|
- [Create React App](https://github.com/facebook/create-react-app)
|
||||||
|
|
||||||
Translations will be available on the app if they exceed a certain threshold of completion (currently 85%).
|
|
||||||
|
|
||||||
## Excalidraw is built using these awesome tools
|
|
||||||
|
|
||||||
- [React](https://reactjs.org)
|
|
||||||
- [Rough.js](https://roughjs.com)
|
- [Rough.js](https://roughjs.com)
|
||||||
- [TypeScript](https://www.typescriptlang.org)
|
- [TypeScript](https://www.typescriptlang.org)
|
||||||
- [Vercel](https://vercel.com)
|
- [Vercel](https://vercel.com)
|
||||||
|
|
||||||
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app.
|
||||||
|
|
||||||
## Testimonials
|
|
||||||
|
|
||||||
<a href="https://twitter.com/Lissy_Sykes/status/1213813117177729026"><img width="398" src="https://user-images.githubusercontent.com/197597/71783813-dbf8a600-2fa0-11ea-9c0d-bb3cc45969e6.png"></a>
|
|
||||||
<a href="https://twitter.com/dan_abramov/status/1213762494428262400"><img width="398" src="https://user-images.githubusercontent.com/197597/71783990-4d395880-2fa3-11ea-9ad7-186138db5003.png"></a>
|
|
||||||
|
|
||||||
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
|
|
||||||
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
|
|
||||||
|
|
||||||
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
|
|
||||||
|
|
||||||
## Contributors
|
|
||||||
|
|
||||||
### Code Contributors
|
|
||||||
|
|
||||||
This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
|
|
||||||
<a href="https://github.com/excalidraw/excalidraw/graphs/contributors"><img src="https://opencollective.com/excalidraw/contributors.svg?width=890&button=false" /></a>
|
|
||||||
|
|
||||||
### Financial Contributors
|
|
||||||
|
|
||||||
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
|
||||||
|
|
||||||
#### Individuals
|
|
||||||
|
|
||||||
<a href="https://opencollective.com/excalidraw"><img src="https://opencollective.com/excalidraw/individuals.svg?width=890"></a>
|
|
||||||
|
|
||||||
#### Organizations
|
|
||||||
|
|
||||||
Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)]
|
|
||||||
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/0/website"><img src="https://opencollective.com/excalidraw/organization/0/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/1/website"><img src="https://opencollective.com/excalidraw/organization/1/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/2/website"><img src="https://opencollective.com/excalidraw/organization/2/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/3/website"><img src="https://opencollective.com/excalidraw/organization/3/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/4/website"><img src="https://opencollective.com/excalidraw/organization/4/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/5/website"><img src="https://opencollective.com/excalidraw/organization/5/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/6/website"><img src="https://opencollective.com/excalidraw/organization/6/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/7/website"><img src="https://opencollective.com/excalidraw/organization/7/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/8/website"><img src="https://opencollective.com/excalidraw/organization/8/avatar.svg"></a>
|
|
||||||
<a href="https://opencollective.com/excalidraw/organization/9/website"><img src="https://opencollective.com/excalidraw/organization/9/avatar.svg"></a>
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
| Excalidraw | Category | Name | Label | Value |
|
|
||||||
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
|
|
||||||
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
|
|
||||||
| Text on double click | shape | text | `double-click` |
|
|
||||||
| Lock selection | shape | lock | `on` or `off` |
|
|
||||||
| Clear canvas | action | clear canvas |
|
|
||||||
| Zoom in | action | zoom | in | `zoom` |
|
|
||||||
| Zoom out | action | zoom | out | `zoom` |
|
|
||||||
| Zoom fit | action | zoom | fit | `zoom` |
|
|
||||||
| Zoom reset | action | zoom | reset | `zoom` |
|
|
||||||
| Scroll back to content | action | scroll to content |
|
|
||||||
| Load file | io | load | `MIME type` |
|
|
||||||
| Import from URL | io | import |
|
|
||||||
| Save | io | save |
|
|
||||||
| Save as | io | save as |
|
|
||||||
| Export to backend | io | export | backend |
|
|
||||||
| Export as SVG | io | export | `svg` or `clipboard-svg` |
|
|
||||||
| Export to PNG | io | export | `png` or `clipboard-png` |
|
|
||||||
| Canvas color | change | canvas color | `color` |
|
|
||||||
| Background color | change | background color | `color` |
|
|
||||||
| Stroke color | change | stroke color | `color` |
|
|
||||||
| Stroke width | change | stroke | width | `width` |
|
|
||||||
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
|
|
||||||
| Stroke sloppiness | change | stroke | sloppiness | `value` |
|
|
||||||
| Fill | change | fill | `value` |
|
|
||||||
| Edge | change | edge | `value` |
|
|
||||||
| Opacity | change | opacity | value | `opacity` |
|
|
||||||
| Project name | change | title |
|
|
||||||
| Theme | change | theme | `light` or `dark` |
|
|
||||||
| Change language | change | language | `language` |
|
|
||||||
| Send to back | layer | move | `back` |
|
|
||||||
| Send backward | layer | move | `down` |
|
|
||||||
| Bring to front | layer | move | `front` |
|
|
||||||
| Bring forward | layer | move | `up` |
|
|
||||||
| Align left | align | align | `left` |
|
|
||||||
| Align right | align | align | `right` |
|
|
||||||
| Align top | align | align | `top` |
|
|
||||||
| Align bottom | align | align | `bottom` |
|
|
||||||
| Center horizontally | align | horizontally | `center` |
|
|
||||||
| Center vertically | align | vertically | `center` |
|
|
||||||
| Distribute horizontally | align | distribute | `horizontally` |
|
|
||||||
| Distribute vertically | align | distribute | `vertically` |
|
|
||||||
| Start session | share | session start |
|
|
||||||
| Join session | share | session join |
|
|
||||||
| Start end | share | session end |
|
|
||||||
| Copy room link | share | copy link |
|
|
||||||
| Go to collaborator | share | go to collaborator |
|
|
||||||
| Change name | share | name |
|
|
||||||
| Add to library | library | add |
|
|
||||||
| Remove from library | library | remove |
|
|
||||||
| Load library | library | load |
|
|
||||||
| Save library | library | save |
|
|
||||||
| Import library | library | import |
|
|
||||||
| Shortcuts dialog | dialog | shortcuts |
|
|
||||||
| Collaboration dialog | dialog | collaboration |
|
|
||||||
| Export dialog | dialog | export |
|
|
||||||
| Library dialog | dialog | library |
|
|
||||||
| E2EE shield | exit | e2ee shield |
|
|
||||||
| GitHub corner | exit | github |
|
|
||||||
| Excalidraw blog | exit | blog |
|
|
||||||
| Excalidraw guides | exit | guides |
|
|
||||||
| File issues | exit | issues |
|
|
||||||
| First load | load | first load |
|
|
||||||
| Load from stroage | load | storage | size | `bytes` |
|
|
||||||
@@ -19,23 +19,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "5.28.0",
|
"@sentry/browser": "6.0.3",
|
||||||
"@sentry/integrations": "5.28.0",
|
"@sentry/integrations": "6.0.3",
|
||||||
"@testing-library/jest-dom": "5.11.6",
|
"@testing-library/jest-dom": "5.11.9",
|
||||||
"@testing-library/react": "11.2.2",
|
"@testing-library/react": "11.2.3",
|
||||||
"@types/jest": "26.0.16",
|
"@types/jest": "26.0.20",
|
||||||
"@types/nanoid": "2.1.0",
|
|
||||||
"@types/react": "17.0.0",
|
"@types/react": "17.0.0",
|
||||||
"@types/react-dom": "17.0.0",
|
"@types/react-dom": "17.0.0",
|
||||||
"@types/socket.io-client": "1.4.34",
|
"@types/socket.io-client": "1.4.35",
|
||||||
"browser-nativefs": "0.11.1",
|
"browser-fs-access": "0.13.0",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"firebase": "8.1.2",
|
"firebase": "8.2.5",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "2.1.11",
|
"nanoid": "3.1.20",
|
||||||
"node-sass": "4.14.1",
|
"node-sass": "4.14.1",
|
||||||
"open-color": "1.7.0",
|
"open-color": "1.8.0",
|
||||||
"pako": "1.0.11",
|
"pako": "1.0.11",
|
||||||
"png-chunk-text": "1.0.0",
|
"png-chunk-text": "1.0.0",
|
||||||
"png-chunks-encode": "1.0.0",
|
"png-chunks-encode": "1.0.0",
|
||||||
@@ -47,19 +46,18 @@
|
|||||||
"react-scripts": "4.0.1",
|
"react-scripts": "4.0.1",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.3.1",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.0.5"
|
"typescript": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.1",
|
||||||
"asar": "3.0.3",
|
"eslint-config-prettier": "7.2.0",
|
||||||
"eslint-config-prettier": "7.0.0",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"eslint-plugin-prettier": "3.1.4",
|
"firebase-tools": "9.2.2",
|
||||||
"firebase-tools": "8.17.0",
|
"husky": "4.3.8",
|
||||||
"husky": "4.3.0",
|
|
||||||
"jest-canvas-mock": "2.3.0",
|
"jest-canvas-mock": "2.3.0",
|
||||||
"lint-staged": "10.5.3",
|
"lint-staged": "10.5.3",
|
||||||
"pepjs": "0.5.2",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0"
|
||||||
},
|
},
|
||||||
@@ -74,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||||
],
|
],
|
||||||
"resetMocks": false
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
@@ -82,15 +80,16 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||||
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
|
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||||
"build:zip": "node ./scripts/build-version.js",
|
"build:version": "node ./scripts/build-version.js",
|
||||||
"build": "npm run build:app && npm run build:zip",
|
"build": "npm run build:app && npm run build:version",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"fix:code": "npm run test:code -- --fix",
|
"fix:code": "npm run test:code -- --fix",
|
||||||
"fix:other": "npm run prettier -- --write",
|
"fix:other": "npm run prettier -- --write",
|
||||||
"fix": "npm run fix:other && npm run fix:code",
|
"fix": "npm run fix:other && npm run fix:code",
|
||||||
"locales-coverage": "node scripts/build-locales-coverage.js",
|
"locales-coverage": "node scripts/build-locales-coverage.js",
|
||||||
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -55,6 +55,9 @@
|
|||||||
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
|
||||||
|
|
||||||
|
<!-- Excalidraw version -->
|
||||||
|
<meta name="version" content="{version}" />
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="FG_Virgil.woff2"
|
href="FG_Virgil.woff2"
|
||||||
@@ -84,10 +87,10 @@
|
|||||||
|
|
||||||
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
<link rel="stylesheet" href="fonts.css" type="text/css" />
|
||||||
|
|
||||||
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
|
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||||
<script
|
<script
|
||||||
async
|
async
|
||||||
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
|
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||||
></script>
|
></script>
|
||||||
<script>
|
<script>
|
||||||
window.dataLayer = window.dataLayer || [];
|
window.dataLayer = window.dataLayer || [];
|
||||||
@@ -95,7 +98,7 @@
|
|||||||
dataLayer.push(arguments);
|
dataLayer.push(arguments);
|
||||||
}
|
}
|
||||||
gtag("js", new Date());
|
gtag("js", new Date());
|
||||||
gtag("config", "UA-387204-13");
|
gtag("config", "%REACT_APP_GOOGLE_ANALYTICS_ID%");
|
||||||
</script>
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -25,5 +25,6 @@
|
|||||||
"application/vnd.excalidraw+json": [".excalidraw"]
|
"application/vnd.excalidraw+json": [".excalidraw"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"capture_links": "new_client"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 70 KiB |
@@ -2,36 +2,60 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const asar = require("asar");
|
const versionFile = path.join("build", "version.json");
|
||||||
|
const indexFile = path.join("build", "index.html");
|
||||||
|
|
||||||
const zero = (digit) => `0${digit}`.slice(-2);
|
const versionDate = (date) => date.toISOString().replace(".000", "");
|
||||||
|
|
||||||
const versionDate = (date) => {
|
const commitHash = () => {
|
||||||
const date_ = `${date.getFullYear()}-${zero(date.getMonth() + 1)}-${zero(
|
try {
|
||||||
date.getDate(),
|
return require("child_process")
|
||||||
)}`;
|
.execSync("git rev-parse --short HEAD")
|
||||||
const time = `${zero(date.getHours())}-${zero(date.getMinutes())}-${zero(
|
.toString()
|
||||||
date.getSeconds(),
|
.trim();
|
||||||
)}`;
|
} catch {
|
||||||
return `${date_}-${time}`;
|
return "none";
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const now = new Date();
|
const commitDate = (hash) => {
|
||||||
|
try {
|
||||||
|
const unix = require("child_process")
|
||||||
|
.execSync(`git show -s --format=%ct ${hash}`)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const date = new Date(parseInt(unix) * 1000);
|
||||||
|
return versionDate(date);
|
||||||
|
} catch {
|
||||||
|
return versionDate(new Date());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFullVersion = () => {
|
||||||
|
const hash = commitHash();
|
||||||
|
return `${commitDate(hash)}-${hash}`;
|
||||||
|
};
|
||||||
|
|
||||||
const data = JSON.stringify(
|
const data = JSON.stringify(
|
||||||
{
|
{
|
||||||
asar: "excalidraw.asar",
|
version: getFullVersion(),
|
||||||
version: versionDate(now),
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
2,
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(path.join("build", "version.json"), data);
|
fs.writeFileSync(versionFile, data);
|
||||||
|
|
||||||
(async () => {
|
// https://stackoverflow.com/a/14181136/8418
|
||||||
const src = "build/";
|
fs.readFile(indexFile, "utf8", (error, data) => {
|
||||||
const dest = path.join("build", `excalidraw.asar`);
|
if (error) {
|
||||||
|
return console.error(error);
|
||||||
|
}
|
||||||
|
const result = data.replace(/{version}/g, getFullVersion());
|
||||||
|
|
||||||
await asar.createPackage(src, dest);
|
fs.writeFile(indexFile, result, "utf8", (error) => {
|
||||||
})();
|
if (error) {
|
||||||
|
return console.error(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const THRESSHOLD = 85;
|
||||||
|
|
||||||
|
const crowdinMap = {
|
||||||
|
"ar-SA": "en-ar",
|
||||||
|
"bg-BG": "en-bg",
|
||||||
|
"ca-ES": "en-ca",
|
||||||
|
"de-DE": "en-de",
|
||||||
|
"el-GR": "en-el",
|
||||||
|
"es-ES": "en-es",
|
||||||
|
"fa-IR": "en-fa",
|
||||||
|
"fi-FI": "en-fi",
|
||||||
|
"fr-FR": "en-fr",
|
||||||
|
"he-IL": "en-he",
|
||||||
|
"hi-IN": "en-hi",
|
||||||
|
"hu-HU": "en-hu",
|
||||||
|
"id-ID": "en-id",
|
||||||
|
"it-IT": "en-it",
|
||||||
|
"ja-JP": "en-ja",
|
||||||
|
"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",
|
||||||
|
"ro-RO": "en-ro",
|
||||||
|
"ru-RU": "en-ru",
|
||||||
|
"sk-SK": "en-sk",
|
||||||
|
"sv-SE": "en-sv",
|
||||||
|
"tr-TR": "en-tr",
|
||||||
|
"uk-UA": "en-uk",
|
||||||
|
"zh-CN": "en-zhcn",
|
||||||
|
"zh-TW": "en-zhtw",
|
||||||
|
};
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
"ar-SA": "🇸🇦",
|
||||||
|
"bg-BG": "🇧🇬",
|
||||||
|
"ca-ES": "🏳",
|
||||||
|
"de-DE": "🇩🇪",
|
||||||
|
"el-GR": "🇬🇷",
|
||||||
|
"es-ES": "🇪🇸",
|
||||||
|
"fa-IR": "🇮🇷",
|
||||||
|
"fi-FI": "🇫🇮",
|
||||||
|
"fr-FR": "🇫🇷",
|
||||||
|
"he-IL": "🇮🇱",
|
||||||
|
"hi-IN": "🇮🇳",
|
||||||
|
"hu-HU": "🇭🇺",
|
||||||
|
"id-ID": "🇮🇩",
|
||||||
|
"it-IT": "🇮🇹",
|
||||||
|
"ja-JP": "🇯🇵",
|
||||||
|
"kab-KAB": "🏳",
|
||||||
|
"ko-KR": "🇰🇷",
|
||||||
|
"my-MM": "🇲🇲",
|
||||||
|
"nb-NO": "🇳🇴",
|
||||||
|
"nl-NL": "🇳🇱",
|
||||||
|
"nn-NO": "🇳🇴",
|
||||||
|
"pa-IN": "🇮🇳",
|
||||||
|
"pl-PL": "🇵🇱",
|
||||||
|
"pt-BR": "🇧🇷",
|
||||||
|
"pt-PT": "🇵🇹",
|
||||||
|
"ro-RO": "🇷🇴",
|
||||||
|
"ru-RU": "🇷🇺",
|
||||||
|
"sk-SK": "🇸🇰",
|
||||||
|
"sv-SE": "🇸🇪",
|
||||||
|
"tr-TR": "🇹🇷",
|
||||||
|
"uk-UA": "🇺🇦",
|
||||||
|
"zh-CN": "🇨🇳",
|
||||||
|
"zh-TW": "🇹🇼",
|
||||||
|
};
|
||||||
|
|
||||||
|
const languages = {
|
||||||
|
"ar-SA": "العربية",
|
||||||
|
"bg-BG": "Български",
|
||||||
|
"ca-ES": "Català",
|
||||||
|
"de-DE": "Deutsch",
|
||||||
|
"el-GR": "Ελληνικά",
|
||||||
|
"es-ES": "Español",
|
||||||
|
"fa-IR": "فارسی",
|
||||||
|
"fi-FI": "Suomi",
|
||||||
|
"fr-FR": "Français",
|
||||||
|
"he-IL": "עברית",
|
||||||
|
"hi-IN": "हिन्दी",
|
||||||
|
"hu-HU": "Magyar",
|
||||||
|
"id-ID": "Bahasa Indonesia",
|
||||||
|
"it-IT": "Italiano",
|
||||||
|
"ja-JP": "日本語",
|
||||||
|
"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",
|
||||||
|
"ro-RO": "Română",
|
||||||
|
"ru-RU": "Русский",
|
||||||
|
"sk-SK": "Slovenčina",
|
||||||
|
"sv-SE": "Svenska",
|
||||||
|
"tr-TR": "Türkçe",
|
||||||
|
"uk-UA": "Українська",
|
||||||
|
"zh-CN": "简体中文",
|
||||||
|
"zh-TW": "繁體中文",
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentages = fs.readFileSync(
|
||||||
|
`${__dirname}/../src/locales/percentages.json`,
|
||||||
|
);
|
||||||
|
const rowData = JSON.parse(percentages);
|
||||||
|
|
||||||
|
const coverages = Object.entries(rowData)
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {});
|
||||||
|
|
||||||
|
const boldIf = (text, condition) => (condition ? `**${text}**` : text);
|
||||||
|
|
||||||
|
const printHeader = () => {
|
||||||
|
let result = "| | Flag | Locale | % |\n";
|
||||||
|
result += "| :--: | :--: | -- | :--: |";
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const printRow = (id, locale, coverage) => {
|
||||||
|
const isOver = coverage >= THRESSHOLD;
|
||||||
|
let result = `| ${isOver ? id : "..."} | `;
|
||||||
|
result += `${locale in flags ? flags[locale] : ""} | `;
|
||||||
|
const language = locale in languages ? languages[locale] : locale;
|
||||||
|
if (locale in crowdinMap && crowdinMap[locale]) {
|
||||||
|
result += `[${boldIf(
|
||||||
|
language,
|
||||||
|
isOver,
|
||||||
|
)}](https://crowdin.com/translate/excalidraw/10/${crowdinMap[locale]}) | `;
|
||||||
|
} else {
|
||||||
|
result += `${boldIf(language, isOver)} | `;
|
||||||
|
}
|
||||||
|
result += `${coverage === 100 ? "💯" : boldIf(coverage, isOver)} |`;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`Each language must be at least **${THRESSHOLD}%** translated in order to appear on Excalidraw. Join us on [Crowdin](https://crowdin.com/project/excalidraw) and help us translate your own language. **Can't find yours yet?** Open an [issue](https://github.com/excalidraw/excalidraw/issues/new) and we'll add it to the list.`,
|
||||||
|
);
|
||||||
|
console.info("\n\r");
|
||||||
|
console.info(printHeader());
|
||||||
|
let index = 1;
|
||||||
|
for (const coverage in coverages) {
|
||||||
|
if (coverage === "en") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.info(printRow(index, coverage, coverages[coverage]));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
console.info("\n\r");
|
||||||
|
console.info("\\* Languages in **bold** are going to appear on production.");
|
||||||
@@ -3,7 +3,6 @@ import { getSelectedElements } from "../scene";
|
|||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { deepCopyElement } from "../element/newElement";
|
import { deepCopyElement } from "../element/newElement";
|
||||||
import { Library } from "../data/library";
|
import { Library } from "../data/library";
|
||||||
import { EVENT_LIBRARY, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionAddToLibrary = register({
|
export const actionAddToLibrary = register({
|
||||||
name: "addToLibrary",
|
name: "addToLibrary",
|
||||||
@@ -16,9 +15,7 @@ export const actionAddToLibrary = register({
|
|||||||
Library.loadLibrary().then((items) => {
|
Library.loadLibrary().then((items) => {
|
||||||
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_LIBRARY, "add");
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
contextMenuOrder: 6,
|
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { KEYS } from "../keys";
|
import { alignElements, Alignment } from "../align";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import {
|
import {
|
||||||
AlignBottomIcon,
|
AlignBottomIcon,
|
||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
@@ -10,14 +8,15 @@ import {
|
|||||||
CenterHorizontallyIcon,
|
CenterHorizontallyIcon,
|
||||||
CenterVerticallyIcon,
|
CenterVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { alignElements, Alignment } from "../align";
|
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { trackEvent, EVENT_ALIGN } from "../analytics";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -44,7 +43,6 @@ const alignSelectedElements = (
|
|||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "top");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -74,7 +72,6 @@ export const actionAlignTop = register({
|
|||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "bottom");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -104,7 +101,6 @@ export const actionAlignBottom = register({
|
|||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "left");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -134,7 +130,6 @@ export const actionAlignLeft = register({
|
|||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "align", "right");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -164,7 +159,6 @@ export const actionAlignRight = register({
|
|||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "vertically", "center");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
@@ -190,7 +184,6 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "horizontally", "center");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: alignSelectedElements(elements, appState, {
|
elements: alignSelectedElements(elements, appState, {
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { ZOOM_STEP } from "../constants";
|
||||||
import { getNormalizedZoom } from "../scene";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { CODES, KEYS } from "../keys";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import useIsMobile from "../is-mobile";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { AppState, NormalizedZoomValue } from "../types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element";
|
import { t } from "../i18n";
|
||||||
import { getNewZoom } from "../scene/zoom";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
|
import { getNewZoom } from "../scene/zoom";
|
||||||
import colors from "../colors";
|
import { AppState, NormalizedZoomValue } from "../types";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value) => {
|
||||||
if (value !== appState.viewBackgroundColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"canvas color",
|
|
||||||
colors.canvasBackground.includes(value)
|
|
||||||
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
appState: { ...appState, viewBackgroundColor: value },
|
appState: { ...appState, viewBackgroundColor: value },
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
@@ -51,7 +42,6 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
export const actionClearCanvas = register({
|
export const actionClearCanvas = register({
|
||||||
name: "clearCanvas",
|
name: "clearCanvas",
|
||||||
perform: (elements, appState: AppState) => {
|
perform: (elements, appState: AppState) => {
|
||||||
trackEvent(EVENT_ACTION, "clear canvas");
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) =>
|
elements: elements.map((element) =>
|
||||||
newElementWith(element, { isDeleted: true }),
|
newElementWith(element, { isDeleted: true }),
|
||||||
@@ -65,6 +55,7 @@ export const actionClearCanvas = register({
|
|||||||
gridSize: appState.gridSize,
|
gridSize: appState.gridSize,
|
||||||
shouldAddWatermark: appState.shouldAddWatermark,
|
shouldAddWatermark: appState.shouldAddWatermark,
|
||||||
showStats: appState.showStats,
|
showStats: appState.showStats,
|
||||||
|
pasteDialog: appState.pasteDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@@ -85,17 +76,15 @@ export const actionClearCanvas = register({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZOOM_STEP = 0.1;
|
|
||||||
|
|
||||||
export const actionZoomIn = register({
|
export const actionZoomIn = register({
|
||||||
name: "zoomIn",
|
name: "zoomIn",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
const zoom = getNewZoom(
|
const zoom = getNewZoom(
|
||||||
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
);
|
);
|
||||||
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -126,10 +115,10 @@ export const actionZoomOut = register({
|
|||||||
const zoom = getNewZoom(
|
const zoom = getNewZoom(
|
||||||
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
{ x: appState.width / 2, y: appState.height / 2 },
|
{ x: appState.width / 2, y: appState.height / 2 },
|
||||||
);
|
);
|
||||||
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -157,14 +146,18 @@ export const actionZoomOut = register({
|
|||||||
export const actionResetZoom = register({
|
export const actionResetZoom = register({
|
||||||
name: "resetZoom",
|
name: "resetZoom",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
|
zoom: getNewZoom(
|
||||||
x: appState.width / 2,
|
1 as NormalizedZoomValue,
|
||||||
y: appState.height / 2,
|
appState.zoom,
|
||||||
}),
|
{ left: appState.offsetLeft, top: appState.offsetTop },
|
||||||
|
{
|
||||||
|
x: appState.width / 2,
|
||||||
|
y: appState.height / 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -204,38 +197,61 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const zoomToFitElements = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
zoomToSelection: boolean,
|
||||||
|
) => {
|
||||||
|
const nonDeletedElements = getNonDeletedElements(elements);
|
||||||
|
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||||
|
|
||||||
|
const commonBounds =
|
||||||
|
zoomToSelection && selectedElements.length > 0
|
||||||
|
? getCommonBounds(selectedElements)
|
||||||
|
: getCommonBounds(nonDeletedElements);
|
||||||
|
|
||||||
|
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
});
|
||||||
|
const newZoom = getNewZoom(zoomValue, appState.zoom, {
|
||||||
|
left: appState.offsetLeft,
|
||||||
|
top: appState.offsetTop,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = commonBounds;
|
||||||
|
const centerX = (x1 + x2) / 2;
|
||||||
|
const centerY = (y1 + y2) / 2;
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...centerScrollOn({
|
||||||
|
scenePoint: { x: centerX, y: centerY },
|
||||||
|
viewportDimensions: {
|
||||||
|
width: appState.width,
|
||||||
|
height: appState.height,
|
||||||
|
},
|
||||||
|
zoom: newZoom,
|
||||||
|
}),
|
||||||
|
zoom: newZoom,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actionZoomToSelected = register({
|
||||||
|
name: "zoomToSelection",
|
||||||
|
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||||
|
keyTest: (event) =>
|
||||||
|
event.code === CODES.TWO &&
|
||||||
|
event.shiftKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event[KEYS.CTRL_OR_CMD],
|
||||||
|
});
|
||||||
|
|
||||||
export const actionZoomToFit = register({
|
export const actionZoomToFit = register({
|
||||||
name: "zoomToFit",
|
name: "zoomToFit",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||||
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
|
|
||||||
const commonBounds = getCommonBounds(nonDeletedElements);
|
|
||||||
|
|
||||||
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
});
|
|
||||||
const newZoom = getNewZoom(zoomValue, appState.zoom);
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
|
||||||
const centerX = (x1 + x2) / 2;
|
|
||||||
const centerY = (y1 + y2) / 2;
|
|
||||||
trackEvent(EVENT_ACTION, "zoom", "fit", newZoom.value * 100);
|
|
||||||
return {
|
|
||||||
appState: {
|
|
||||||
...appState,
|
|
||||||
...centerScrollOn({
|
|
||||||
scenePoint: { x: centerX, y: centerY },
|
|
||||||
viewportDimensions: {
|
|
||||||
width: appState.width,
|
|
||||||
height: appState.height,
|
|
||||||
},
|
|
||||||
zoom: newZoom,
|
|
||||||
}),
|
|
||||||
zoom: newZoom,
|
|
||||||
},
|
|
||||||
commitToHistory: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { copyToClipboard } from "../clipboard";
|
||||||
|
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||||
|
import { getSelectedElements } from "../scene/selection";
|
||||||
|
import { exportCanvas } from "../data/index";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
export const actionCopy = register({
|
||||||
|
name: "copy",
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
copyToClipboard(getNonDeletedElements(elements), appState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.copy",
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.C,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionCut = register({
|
||||||
|
name: "cut",
|
||||||
|
perform: (elements, appState, data, app) => {
|
||||||
|
actionCopy.perform(elements, appState, data, app);
|
||||||
|
return actionDeleteSelected.perform(elements, appState, data, app);
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.cut",
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.X,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionCopyAsSvg = register({
|
||||||
|
name: "copyAsSvg",
|
||||||
|
perform: async (elements, appState, _data, app) => {
|
||||||
|
if (!app.canvas) {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await exportCanvas(
|
||||||
|
"clipboard-svg",
|
||||||
|
selectedElements.length
|
||||||
|
? selectedElements
|
||||||
|
: getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
app.canvas,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.copyAsSvg",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionCopyAsPng = register({
|
||||||
|
name: "copyAsPng",
|
||||||
|
perform: async (elements, appState, _data, app) => {
|
||||||
|
if (!app.canvas) {
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const selectedElements = getSelectedElements(
|
||||||
|
getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await exportCanvas(
|
||||||
|
"clipboard",
|
||||||
|
selectedElements.length
|
||||||
|
? selectedElements
|
||||||
|
: getNonDeletedElements(elements),
|
||||||
|
appState,
|
||||||
|
app.canvas,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
toastMessage: t("toast.copyToClipboardAsPng"),
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.copyAsPng",
|
||||||
|
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||||
|
});
|
||||||
@@ -136,7 +136,6 @@ export const actionDeleteSelected = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.delete",
|
contextItemLabel: "labels.delete",
|
||||||
contextMenuOrder: 3,
|
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { CODES } from "../keys";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import {
|
import {
|
||||||
DistributeHorizontallyIcon,
|
DistributeHorizontallyIcon,
|
||||||
DistributeVerticallyIcon,
|
DistributeVerticallyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
|
||||||
import { getElementMap, getNonDeletedElements } from "../element";
|
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { AppState } from "../types";
|
|
||||||
import { distributeElements, Distribution } from "../disitrubte";
|
import { distributeElements, Distribution } from "../disitrubte";
|
||||||
|
import { getElementMap, getNonDeletedElements } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { CODES } from "../keys";
|
||||||
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import { AppState } from "../types";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import { EVENT_ALIGN, trackEvent } from "../analytics";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -40,7 +39,6 @@ const distributeSelectedElements = (
|
|||||||
export const distributeHorizontally = register({
|
export const distributeHorizontally = register({
|
||||||
name: "distributeHorizontally",
|
name: "distributeHorizontally",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: distributeSelectedElements(elements, appState, {
|
elements: distributeSelectedElements(elements, appState, {
|
||||||
@@ -69,7 +67,6 @@ export const distributeHorizontally = register({
|
|||||||
export const distributeVertically = register({
|
export const distributeVertically = register({
|
||||||
name: "distributeVertically",
|
name: "distributeVertically",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
trackEvent(EVENT_ALIGN, "distribute", "vertically");
|
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
elements: distributeSelectedElements(elements, appState, {
|
elements: distributeSelectedElements(elements, appState, {
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
|
import { trackEvent } from "../analytics";
|
||||||
import { load, save, saveAs } from "../components/icons";
|
import { load, questionCircle, save, saveAs } from "../components/icons";
|
||||||
import { ProjectName } from "../components/ProjectName";
|
import { ProjectName } from "../components/ProjectName";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import "../components/ToolIcon.scss";
|
||||||
|
import { Tooltip } from "../components/Tooltip";
|
||||||
import { loadFromJSON, saveAsJSON } from "../data";
|
import { loadFromJSON, saveAsJSON } from "../data";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { muteFSAbortError } from "../utils";
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionChangeProjectName = register({
|
export const actionChangeProjectName = register({
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "title");
|
trackEvent("change", "title");
|
||||||
return { appState: { ...appState, name: value }, commitToHistory: false };
|
return { appState: { ...appState, name: value }, commitToHistory: false };
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
@@ -54,13 +55,20 @@ export const actionChangeExportEmbedScene = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ appState, updateData }) => (
|
PanelComponent: ({ appState, updateData }) => (
|
||||||
<label title={t("labels.exportEmbedScene_details")}>
|
<label style={{ display: "flex" }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={appState.exportEmbedScene}
|
checked={appState.exportEmbedScene}
|
||||||
onChange={(event) => updateData(event.target.checked)}
|
onChange={(event) => updateData(event.target.checked)}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
{t("labels.exportEmbedScene")}
|
{t("labels.exportEmbedScene")}
|
||||||
|
<Tooltip
|
||||||
|
label={t("labels.exportEmbedScene_details")}
|
||||||
|
position="above"
|
||||||
|
long={true}
|
||||||
|
>
|
||||||
|
<div className="TooltipIcon">{questionCircle}</div>
|
||||||
|
</Tooltip>
|
||||||
</label>
|
</label>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -90,7 +98,6 @@ export const actionSaveScene = register({
|
|||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
trackEvent(EVENT_IO, "save");
|
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
@@ -121,7 +128,6 @@ export const actionSaveAsScene = register({
|
|||||||
...appState,
|
...appState,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
});
|
});
|
||||||
trackEvent(EVENT_IO, "save as");
|
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
@@ -149,18 +155,29 @@ export const actionSaveAsScene = register({
|
|||||||
|
|
||||||
export const actionLoadScene = register({
|
export const actionLoadScene = register({
|
||||||
name: "loadScene",
|
name: "loadScene",
|
||||||
perform: (
|
perform: async (elements, appState) => {
|
||||||
elements,
|
try {
|
||||||
appState,
|
const {
|
||||||
{ elements: loadedElements, appState: loadedAppState, error },
|
elements: loadedElements,
|
||||||
) => ({
|
appState: loadedAppState,
|
||||||
elements: loadedElements,
|
} = await loadFromJSON(appState);
|
||||||
appState: {
|
return {
|
||||||
...loadedAppState,
|
elements: loadedElements,
|
||||||
errorMessage: error,
|
appState: loadedAppState,
|
||||||
},
|
commitToHistory: true,
|
||||||
commitToHistory: true,
|
};
|
||||||
}),
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState: { ...appState, errorMessage: error.message },
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||||
PanelComponent: ({ updateData, appState }) => (
|
PanelComponent: ({ updateData, appState }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -168,16 +185,7 @@ export const actionLoadScene = register({
|
|||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={updateData}
|
||||||
loadFromJSON(appState)
|
|
||||||
.then(({ elements, appState }) => {
|
|
||||||
updateData({ elements, appState });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
updateData({ error: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,11 +118,14 @@ export const actionFinalize = register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!appState.elementLocked) {
|
if (!appState.elementLocked && appState.elementType !== "draw") {
|
||||||
appState.selectedElementIds[multiPointElement.id] = true;
|
appState.selectedElementIds[multiPointElement.id] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!appState.elementLocked || !multiPointElement) {
|
if (
|
||||||
|
(!appState.elementLocked && appState.elementType !== "draw") ||
|
||||||
|
!multiPointElement
|
||||||
|
) {
|
||||||
resetCursor();
|
resetCursor();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -130,7 +133,8 @@ export const actionFinalize = register({
|
|||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
elementType:
|
elementType:
|
||||||
appState.elementLocked && multiPointElement
|
(appState.elementLocked || appState.elementType === "draw") &&
|
||||||
|
multiPointElement
|
||||||
? appState.elementType
|
? appState.elementType
|
||||||
: "selection",
|
: "selection",
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
@@ -139,7 +143,9 @@ export const actionFinalize = register({
|
|||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement && !appState.elementLocked
|
multiPointElement &&
|
||||||
|
!appState.elementLocked &&
|
||||||
|
appState.elementType !== "draw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[multiPointElement.id]: true,
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ export const actionGroup = register({
|
|||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextMenuOrder: 4,
|
|
||||||
contextItemLabel: "labels.group",
|
contextItemLabel: "labels.group",
|
||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
enableActionGroup(elements, appState),
|
enableActionGroup(elements, appState),
|
||||||
@@ -174,7 +173,6 @@ export const actionUngroup = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
|
||||||
contextMenuOrder: 5,
|
|
||||||
contextItemLabel: "labels.ungroup",
|
contextItemLabel: "labels.ungroup",
|
||||||
contextItemPredicate: (elements, appState) =>
|
contextItemPredicate: (elements, appState) =>
|
||||||
getSelectedGroupIds(appState).length > 0,
|
getSelectedGroupIds(appState).length > 0,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { t } from "../i18n";
|
|||||||
import { SceneHistory, HistoryEntry } from "../history";
|
import { SceneHistory, HistoryEntry } from "../history";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { KEYS } from "../keys";
|
import { isWindows, KEYS } from "../keys";
|
||||||
import { getElementMap } from "../element";
|
import { getElementMap } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||||
@@ -59,16 +59,16 @@ const writeData = (
|
|||||||
return { commitToHistory };
|
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;
|
type ActionCreator = (history: SceneHistory) => Action;
|
||||||
|
|
||||||
export const createUndoAction: ActionCreator = (history) => ({
|
export const createUndoAction: ActionCreator = (history) => ({
|
||||||
name: "undo",
|
name: "undo",
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.undoOnce()),
|
writeData(elements, appState, () => history.undoOnce()),
|
||||||
keyTest: testUndo(false),
|
keyTest: (event) =>
|
||||||
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.key.toLowerCase() === KEYS.Z &&
|
||||||
|
!event.shiftKey,
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
@@ -84,7 +84,11 @@ export const createRedoAction: ActionCreator = (history) => ({
|
|||||||
name: "redo",
|
name: "redo",
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
writeData(elements, appState, () => history.redoOnce()),
|
writeData(elements, appState, () => history.redoOnce()),
|
||||||
keyTest: testUndo(true),
|
keyTest: (event) =>
|
||||||
|
(event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.key.toLowerCase() === KEYS.Z) ||
|
||||||
|
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { register } from "./register";
|
|||||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { HelpIcon } from "../components/HelpIcon";
|
import { HelpIcon } from "../components/HelpIcon";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
@@ -72,17 +71,16 @@ export const actionFullScreen = register({
|
|||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
name: "toggleShortcuts",
|
name: "toggleShortcuts",
|
||||||
perform: (_elements, appState) => {
|
perform: (_elements, appState) => {
|
||||||
trackEvent(EVENT_DIALOG, "shortcuts");
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showShortcutsDialog: true,
|
showHelpDialog: !appState.showHelpDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData }) => (
|
PanelComponent: ({ updateData }) => (
|
||||||
<HelpIcon title={t("shortcutsDialog.title")} onClick={updateData} />
|
<HelpIcon title={t("helpDialog.title")} onClick={updateData} />
|
||||||
),
|
),
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Avatar } from "../components/Avatar";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { getClientColors, getClientInitials } from "../clients";
|
import { getClientColors, getClientInitials } from "../clients";
|
||||||
import { Collaborator } from "../types";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { centerScrollOn } from "../scene/scroll";
|
import { centerScrollOn } from "../scene/scroll";
|
||||||
import { EVENT_SHARE, trackEvent } from "../analytics";
|
import { Collaborator } from "../types";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
const point = value as Collaborator["pointer"];
|
const point = value as Collaborator["pointer"];
|
||||||
trackEvent(EVENT_SHARE, "go to collaborator");
|
|
||||||
if (!point) {
|
if (!point) {
|
||||||
return { appState, commitToHistory: false };
|
return { appState, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,53 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { getLanguage } from "../i18n";
|
import { AppState } from "../../src/types";
|
||||||
import {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawTextElement,
|
|
||||||
TextAlign,
|
|
||||||
FontFamily,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
Arrowhead,
|
|
||||||
} from "../element/types";
|
|
||||||
import {
|
|
||||||
getCommonAttributeOfSelectedElements,
|
|
||||||
isSomeElementSelected,
|
|
||||||
getTargetElements,
|
|
||||||
canChangeSharpness,
|
|
||||||
canHaveArrowheads,
|
|
||||||
} from "../scene";
|
|
||||||
import { ButtonSelect } from "../components/ButtonSelect";
|
|
||||||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||||
|
import { ButtonSelect } from "../components/ButtonSelect";
|
||||||
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { IconPicker } from "../components/IconPicker";
|
import { IconPicker } from "../components/IconPicker";
|
||||||
import {
|
import {
|
||||||
isTextElement,
|
|
||||||
redrawTextBoundingBox,
|
|
||||||
getNonDeletedElements,
|
|
||||||
} from "../element";
|
|
||||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
|
||||||
import { ColorPicker } from "../components/ColorPicker";
|
|
||||||
import { AppState } from "../../src/types";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { register } from "./register";
|
|
||||||
import { newElementWith } from "../element/mutateElement";
|
|
||||||
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
|
|
||||||
import { randomInteger } from "../random";
|
|
||||||
import {
|
|
||||||
FillHachureIcon,
|
|
||||||
FillCrossHatchIcon,
|
|
||||||
FillSolidIcon,
|
|
||||||
StrokeWidthIcon,
|
|
||||||
StrokeStyleSolidIcon,
|
|
||||||
StrokeStyleDashedIcon,
|
|
||||||
StrokeStyleDottedIcon,
|
|
||||||
EdgeSharpIcon,
|
|
||||||
EdgeRoundIcon,
|
|
||||||
SloppinessArchitectIcon,
|
|
||||||
SloppinessArtistIcon,
|
|
||||||
SloppinessCartoonistIcon,
|
|
||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
ArrowheadBarIcon,
|
ArrowheadBarIcon,
|
||||||
ArrowheadDotIcon,
|
ArrowheadDotIcon,
|
||||||
ArrowheadNoneIcon,
|
ArrowheadNoneIcon,
|
||||||
|
EdgeRoundIcon,
|
||||||
|
EdgeSharpIcon,
|
||||||
|
FillCrossHatchIcon,
|
||||||
|
FillHachureIcon,
|
||||||
|
FillSolidIcon,
|
||||||
|
SloppinessArchitectIcon,
|
||||||
|
SloppinessArtistIcon,
|
||||||
|
SloppinessCartoonistIcon,
|
||||||
|
StrokeStyleDashedIcon,
|
||||||
|
StrokeStyleDottedIcon,
|
||||||
|
StrokeStyleSolidIcon,
|
||||||
|
StrokeWidthIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
|
||||||
import colors from "../colors";
|
import {
|
||||||
|
getNonDeletedElements,
|
||||||
|
isTextElement,
|
||||||
|
redrawTextBoundingBox,
|
||||||
|
} from "../element";
|
||||||
|
import { newElementWith } from "../element/mutateElement";
|
||||||
|
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
Arrowhead,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
FontFamily,
|
||||||
|
TextAlign,
|
||||||
|
} from "../element/types";
|
||||||
|
import { getLanguage, t } from "../i18n";
|
||||||
|
import { randomInteger } from "../random";
|
||||||
|
import {
|
||||||
|
canChangeSharpness,
|
||||||
|
canHaveArrowheads,
|
||||||
|
getCommonAttributeOfSelectedElements,
|
||||||
|
getTargetElements,
|
||||||
|
isSomeElementSelected,
|
||||||
|
} from "../scene";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
const changeProperty = (
|
const changeProperty = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@@ -92,15 +89,6 @@ const getFormValue = function <T>(
|
|||||||
export const actionChangeStrokeColor = register({
|
export const actionChangeStrokeColor = register({
|
||||||
name: "changeStrokeColor",
|
name: "changeStrokeColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
if (value !== appState.currentItemStrokeColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"stroke color",
|
|
||||||
colors.elementStroke.includes(value)
|
|
||||||
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -132,16 +120,6 @@ export const actionChangeStrokeColor = register({
|
|||||||
export const actionChangeBackgroundColor = register({
|
export const actionChangeBackgroundColor = register({
|
||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
if (value !== appState.currentItemBackgroundColor) {
|
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
"background color",
|
|
||||||
colors.elementBackground.includes(value)
|
|
||||||
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
|
|
||||||
: value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -173,7 +151,6 @@ export const actionChangeBackgroundColor = register({
|
|||||||
export const actionChangeFillStyle = register({
|
export const actionChangeFillStyle = register({
|
||||||
name: "changeFillStyle",
|
name: "changeFillStyle",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "fill", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -223,7 +200,6 @@ export const actionChangeFillStyle = register({
|
|||||||
export const actionChangeStrokeWidth = register({
|
export const actionChangeStrokeWidth = register({
|
||||||
name: "changeStrokeWidth",
|
name: "changeStrokeWidth",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "stroke", "width", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -286,7 +262,6 @@ export const actionChangeStrokeWidth = register({
|
|||||||
export const actionChangeSloppiness = register({
|
export const actionChangeSloppiness = register({
|
||||||
name: "changeSloppiness",
|
name: "changeSloppiness",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -335,7 +310,6 @@ export const actionChangeSloppiness = register({
|
|||||||
export const actionChangeStrokeStyle = register({
|
export const actionChangeStrokeStyle = register({
|
||||||
name: "changeStrokeStyle",
|
name: "changeStrokeStyle",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "style", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -383,7 +357,6 @@ export const actionChangeStrokeStyle = register({
|
|||||||
export const actionChangeOpacity = register({
|
export const actionChangeOpacity = register({
|
||||||
name: "changeOpacity",
|
name: "changeOpacity",
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value) => {
|
||||||
trackEvent(EVENT_CHANGE, "opacity", "value", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -580,7 +553,6 @@ export const actionChangeSharpness = register({
|
|||||||
const shouldUpdateForLinearElements = targetElements.length
|
const shouldUpdateForLinearElements = targetElements.length
|
||||||
? targetElements.every(isLinearElement)
|
? targetElements.every(isLinearElement)
|
||||||
: isLinearElementType(appState.elementType);
|
: isLinearElementType(appState.elementType);
|
||||||
trackEvent(EVENT_CHANGE, "edge", value);
|
|
||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
elements: changeProperty(elements, appState, (el) =>
|
||||||
newElementWith(el, {
|
newElementWith(el, {
|
||||||
@@ -642,12 +614,6 @@ export const actionChangeArrowhead = register({
|
|||||||
return {
|
return {
|
||||||
elements: changeProperty(elements, appState, (el) => {
|
elements: changeProperty(elements, appState, (el) => {
|
||||||
if (isLinearElement(el)) {
|
if (isLinearElement(el)) {
|
||||||
trackEvent(
|
|
||||||
EVENT_CHANGE,
|
|
||||||
`arrowhead ${value.position}`,
|
|
||||||
value.type || "none",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { position, type } = value;
|
const { position, type } = value;
|
||||||
|
|
||||||
if (position === "start") {
|
if (position === "start") {
|
||||||
|
|||||||
@@ -1,109 +1,32 @@
|
|||||||
import {
|
import {
|
||||||
isTextElement,
|
isTextElement,
|
||||||
|
isExcalidrawElement,
|
||||||
redrawTextBoundingBox,
|
redrawTextBoundingBox,
|
||||||
getNonDeletedElements,
|
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { t } from "../i18n";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||||
import {
|
import {
|
||||||
ExcalidrawElement,
|
DEFAULT_FONT_SIZE,
|
||||||
ExcalidrawElementPossibleProps,
|
DEFAULT_FONT_FAMILY,
|
||||||
} from "../element/types";
|
DEFAULT_TEXT_ALIGN,
|
||||||
import { AppState } from "../types";
|
} from "../constants";
|
||||||
import {
|
|
||||||
canChangeSharpness,
|
|
||||||
getSelectedElements,
|
|
||||||
hasBackground,
|
|
||||||
hasStroke,
|
|
||||||
hasText,
|
|
||||||
} from "../scene";
|
|
||||||
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
|
|
||||||
|
|
||||||
type AppStateStyles = {
|
|
||||||
[K in AssertSubset<
|
|
||||||
keyof AppState,
|
|
||||||
typeof copyableStyles[number][0]
|
|
||||||
>]: AppState[K];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ElementStyles = {
|
|
||||||
[K in AssertSubset<
|
|
||||||
keyof ExcalidrawElementPossibleProps,
|
|
||||||
typeof copyableStyles[number][1]
|
|
||||||
>]: ExcalidrawElementPossibleProps[K];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ElemelementStylesByType = Record<ExcalidrawElement["type"], ElementStyles>;
|
|
||||||
|
|
||||||
// `copiedStyles` is exported only for tests.
|
// `copiedStyles` is exported only for tests.
|
||||||
let COPIED_STYLES: {
|
export let copiedStyles: string = "{}";
|
||||||
appStateStyles: Partial<AppStateStyles>;
|
|
||||||
elementStyles: Partial<ElementStyles>;
|
|
||||||
elementStylesByType: Partial<ElemelementStylesByType>;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
/* [AppState prop, ExcalidrawElement prop, predicate] */
|
|
||||||
const copyableStyles = [
|
|
||||||
["currentItemOpacity", "opacity", () => true],
|
|
||||||
["currentItemStrokeColor", "strokeColor", () => true],
|
|
||||||
["currentItemStrokeStyle", "strokeStyle", hasStroke],
|
|
||||||
["currentItemStrokeWidth", "strokeWidth", hasStroke],
|
|
||||||
["currentItemRoughness", "roughness", hasStroke],
|
|
||||||
["currentItemBackgroundColor", "backgroundColor", hasBackground],
|
|
||||||
["currentItemFillStyle", "fillStyle", hasBackground],
|
|
||||||
["currentItemStrokeSharpness", "strokeSharpness", canChangeSharpness],
|
|
||||||
["currentItemLinearStrokeSharpness", "strokeSharpness", isLinearElementType],
|
|
||||||
["currentItemStartArrowhead", "startArrowhead", isLinearElementType],
|
|
||||||
["currentItemEndArrowhead", "endArrowhead", isLinearElementType],
|
|
||||||
["currentItemFontFamily", "fontFamily", hasText],
|
|
||||||
["currentItemFontSize", "fontSize", hasText],
|
|
||||||
["currentItemTextAlign", "textAlign", hasText],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const getCommonStyleProps = (
|
|
||||||
elements: readonly ExcalidrawElement[],
|
|
||||||
): Exclude<typeof COPIED_STYLES, null> => {
|
|
||||||
const appStateStyles = {} as AppStateStyles;
|
|
||||||
const elementStyles = {} as ElementStyles;
|
|
||||||
|
|
||||||
const elementStylesByType = elements.reduce((acc, element) => {
|
|
||||||
// only use the first element of given type
|
|
||||||
if (!acc[element.type]) {
|
|
||||||
acc[element.type] = {} as ElementStyles;
|
|
||||||
copyableStyles.forEach(([appStateProp, prop, predicate]) => {
|
|
||||||
const value = (element as any)[prop];
|
|
||||||
if (value !== undefined && predicate(element.type)) {
|
|
||||||
if (appStateStyles[appStateProp] === undefined) {
|
|
||||||
(appStateStyles as any)[appStateProp] = value;
|
|
||||||
}
|
|
||||||
if (elementStyles[prop] === undefined) {
|
|
||||||
(elementStyles as any)[prop] = value;
|
|
||||||
}
|
|
||||||
(acc as any)[element.type][prop] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as ElemelementStylesByType);
|
|
||||||
|
|
||||||
// clone in case we ever make some of the props into non-primitives
|
|
||||||
return JSON.parse(
|
|
||||||
JSON.stringify({ appStateStyles, elementStyles, elementStylesByType }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actionCopyStyles = register({
|
export const actionCopyStyles = register({
|
||||||
name: "copyStyles",
|
name: "copyStyles",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
COPIED_STYLES = getCommonStyleProps(
|
const element = elements.find((el) => appState.selectedElementIds[el.id]);
|
||||||
getSelectedElements(getNonDeletedElements(elements), appState),
|
if (element) {
|
||||||
);
|
copiedStyles = JSON.stringify(element);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...COPIED_STYLES.appStateStyles,
|
toastMessage: t("toast.copyStyles"),
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -111,55 +34,36 @@ export const actionCopyStyles = register({
|
|||||||
contextItemLabel: "labels.copyStyles",
|
contextItemLabel: "labels.copyStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
|
||||||
contextMenuOrder: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionPasteStyles = register({
|
export const actionPasteStyles = register({
|
||||||
name: "pasteStyles",
|
name: "pasteStyles",
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
if (!COPIED_STYLES) {
|
const pastedElement = JSON.parse(copiedStyles);
|
||||||
|
if (!isExcalidrawElement(pastedElement)) {
|
||||||
return { elements, commitToHistory: false };
|
return { elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
const getStyle = <T extends ExcalidrawElement, K extends keyof T>(
|
|
||||||
element: T,
|
|
||||||
prop: K,
|
|
||||||
) => {
|
|
||||||
return (COPIED_STYLES?.elementStylesByType[element.type]?.[
|
|
||||||
prop as keyof ElementStyles
|
|
||||||
] ??
|
|
||||||
COPIED_STYLES?.elementStyles[prop as keyof ElementStyles] ??
|
|
||||||
element[prop]) as T[K];
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
elements: elements.map((element) => {
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (appState.selectedElementIds[element.id]) {
|
||||||
const commonProps = {
|
const newElement = newElementWith(element, {
|
||||||
backgroundColor: getStyle(element, "backgroundColor"),
|
backgroundColor: pastedElement?.backgroundColor,
|
||||||
strokeWidth: getStyle(element, "strokeWidth"),
|
strokeWidth: pastedElement?.strokeWidth,
|
||||||
strokeColor: getStyle(element, "strokeColor"),
|
strokeColor: pastedElement?.strokeColor,
|
||||||
strokeStyle: getStyle(element, "strokeStyle"),
|
strokeStyle: pastedElement?.strokeStyle,
|
||||||
fillStyle: getStyle(element, "fillStyle"),
|
fillStyle: pastedElement?.fillStyle,
|
||||||
opacity: getStyle(element, "opacity"),
|
opacity: pastedElement?.opacity,
|
||||||
roughness: getStyle(element, "roughness"),
|
roughness: pastedElement?.roughness,
|
||||||
strokeSharpness: getStyle(element, "strokeSharpness"),
|
});
|
||||||
};
|
if (isTextElement(newElement)) {
|
||||||
if (isTextElement(element)) {
|
mutateElement(newElement, {
|
||||||
const newElement = newElementWith(element, {
|
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
|
||||||
...commonProps,
|
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
|
||||||
fontSize: getStyle(element, "fontSize"),
|
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
|
||||||
fontFamily: getStyle(element, "fontFamily"),
|
|
||||||
textAlign: getStyle(element, "textAlign"),
|
|
||||||
});
|
});
|
||||||
redrawTextBoundingBox(newElement);
|
redrawTextBoundingBox(newElement);
|
||||||
return newElement;
|
|
||||||
} else if (isLinearElement(element)) {
|
|
||||||
return newElementWith(element, {
|
|
||||||
...commonProps,
|
|
||||||
startArrowhead: getStyle(element, "startArrowhead"),
|
|
||||||
endArrowhead: getStyle(element, "endArrowhead"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return newElementWith(element, commonProps);
|
return newElement;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
}),
|
}),
|
||||||
@@ -169,5 +73,4 @@ export const actionPasteStyles = register({
|
|||||||
contextItemLabel: "labels.pasteStyles",
|
contextItemLabel: "labels.pasteStyles",
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||||
contextMenuOrder: 1,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { GRID_SIZE } from "../constants";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
export const actionToggleGridMode = register({
|
||||||
|
name: "gridMode",
|
||||||
|
perform(elements, appState) {
|
||||||
|
trackEvent("view", "mode", "grid");
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
gridSize: this.checked!(appState) ? null : GRID_SIZE,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
checked: (appState: AppState) => appState.gridSize !== null,
|
||||||
|
contextItemLabel: "labels.gridMode",
|
||||||
|
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
export const actionToggleStats = register({
|
||||||
|
name: "stats",
|
||||||
|
perform(elements, appState) {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
showStats: !this.checked!(appState),
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
checked: (appState) => appState.showStats,
|
||||||
|
contextItemLabel: "stats.title",
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
export const actionToggleViewMode = register({
|
||||||
|
name: "viewMode",
|
||||||
|
perform(elements, appState) {
|
||||||
|
trackEvent("view", "mode", "view");
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
viewModeEnabled: !this.checked!(appState),
|
||||||
|
selectedElementIds: {},
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
checked: (appState) => appState.viewModeEnabled,
|
||||||
|
contextItemLabel: "labels.viewMode",
|
||||||
|
keyTest: (event) =>
|
||||||
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { CODES, KEYS } from "../keys";
|
||||||
|
import { register } from "./register";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
|
||||||
|
export const actionToggleZenMode = register({
|
||||||
|
name: "zenMode",
|
||||||
|
perform(elements, appState) {
|
||||||
|
trackEvent("view", "mode", "zen");
|
||||||
|
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
zenModeEnabled: !this.checked!(appState),
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
checked: (appState) => appState.zenModeEnabled,
|
||||||
|
contextItemLabel: "buttons.zenMode",
|
||||||
|
keyTest: (event) =>
|
||||||
|
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||||
|
});
|
||||||
@@ -65,3 +65,15 @@ export {
|
|||||||
distributeHorizontally,
|
distributeHorizontally,
|
||||||
distributeVertically,
|
distributeVertically,
|
||||||
} from "./actionDistribute";
|
} from "./actionDistribute";
|
||||||
|
|
||||||
|
export {
|
||||||
|
actionCopy,
|
||||||
|
actionCut,
|
||||||
|
actionCopyAsPng,
|
||||||
|
actionCopyAsSvg,
|
||||||
|
} from "./actionClipboard";
|
||||||
|
|
||||||
|
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||||
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import {
|
|||||||
Action,
|
Action,
|
||||||
ActionsManagerInterface,
|
ActionsManagerInterface,
|
||||||
UpdaterFn,
|
UpdaterFn,
|
||||||
ActionFilterFn,
|
|
||||||
ActionName,
|
ActionName,
|
||||||
ActionResult,
|
ActionResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState, ExcalidrawProps } from "../types";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { ShortcutName } from "./shortcuts";
|
// This is the <App> component, but for now we don't care about anything but its
|
||||||
|
// `canvas` state.
|
||||||
|
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
|
||||||
|
|
||||||
export class ActionManager implements ActionsManagerInterface {
|
export class ActionManager implements ActionsManagerInterface {
|
||||||
actions = {} as ActionsManagerInterface["actions"];
|
actions = {} as ActionsManagerInterface["actions"];
|
||||||
@@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||||
|
|
||||||
getAppState: () => Readonly<AppState>;
|
getAppState: () => Readonly<AppState>;
|
||||||
|
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
|
||||||
|
app: App;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
updater: UpdaterFn,
|
updater: UpdaterFn,
|
||||||
getAppState: () => AppState,
|
getAppState: () => AppState,
|
||||||
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
|
||||||
|
app: App,
|
||||||
) {
|
) {
|
||||||
this.updater = (actionResult) => {
|
this.updater = (actionResult) => {
|
||||||
if (actionResult && "then" in actionResult) {
|
if (actionResult && "then" in actionResult) {
|
||||||
@@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
};
|
};
|
||||||
this.getAppState = getAppState;
|
this.getAppState = getAppState;
|
||||||
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
|
||||||
|
this.app = app;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerAction(action: Action) {
|
registerAction(action: Action) {
|
||||||
@@ -63,6 +66,12 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const { viewModeEnabled } = this.getAppState();
|
||||||
|
if (viewModeEnabled) {
|
||||||
|
if (data[0].name !== "viewMode") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.updater(
|
this.updater(
|
||||||
@@ -70,6 +79,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
null,
|
||||||
|
this.app,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -81,43 +91,11 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
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.
|
// Id is an attribute that we can use to pass in data like keys.
|
||||||
// This is needed for dynamically generated action components
|
// This is needed for dynamically generated action components
|
||||||
// like the user list. We can use this key to extract more
|
// like the user list. We can use this key to extract more
|
||||||
@@ -132,6 +110,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
formState,
|
formState,
|
||||||
|
this.app,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type ShortcutName =
|
|||||||
| "copyStyles"
|
| "copyStyles"
|
||||||
| "pasteStyles"
|
| "pasteStyles"
|
||||||
| "selectAll"
|
| "selectAll"
|
||||||
| "delete"
|
| "deleteSelectedElements"
|
||||||
| "duplicateSelection"
|
| "duplicateSelection"
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
@@ -19,9 +19,11 @@ export type ShortcutName =
|
|||||||
| "copyAsSvg"
|
| "copyAsSvg"
|
||||||
| "group"
|
| "group"
|
||||||
| "ungroup"
|
| "ungroup"
|
||||||
| "toggleGridMode"
|
| "gridMode"
|
||||||
| "toggleStats"
|
| "zenMode"
|
||||||
| "addToLibrary";
|
| "stats"
|
||||||
|
| "addToLibrary"
|
||||||
|
| "viewMode";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -30,10 +32,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||||
delete: [getShortcutKey("Del")],
|
deleteSelectedElements: [getShortcutKey("Del")],
|
||||||
duplicateSelection: [
|
duplicateSelection: [
|
||||||
getShortcutKey("CtrlOrCmd+D"),
|
getShortcutKey("CtrlOrCmd+D"),
|
||||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||||
],
|
],
|
||||||
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
|
||||||
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
bringForward: [getShortcutKey("CtrlOrCmd+]")],
|
||||||
@@ -51,9 +53,11 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
copyAsSvg: [],
|
copyAsSvg: [],
|
||||||
group: [getShortcutKey("CtrlOrCmd+G")],
|
group: [getShortcutKey("CtrlOrCmd+G")],
|
||||||
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
|
||||||
toggleGridMode: [getShortcutKey("CtrlOrCmd+'")],
|
gridMode: [getShortcutKey("CtrlOrCmd+'")],
|
||||||
toggleStats: [],
|
zenMode: [getShortcutKey("Alt+Z")],
|
||||||
|
stats: [],
|
||||||
addToLibrary: [],
|
addToLibrary: [],
|
||||||
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ type ActionFn = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
formData: any,
|
formData: any,
|
||||||
|
app: { canvas: HTMLCanvasElement | null },
|
||||||
) => ActionResult | Promise<ActionResult>;
|
) => ActionResult | Promise<ActionResult>;
|
||||||
|
|
||||||
export type UpdaterFn = (res: ActionResult) => void;
|
export type UpdaterFn = (res: ActionResult) => void;
|
||||||
export type ActionFilterFn = (action: Action) => void;
|
export type ActionFilterFn = (action: Action) => void;
|
||||||
|
|
||||||
export type ActionName =
|
export type ActionName =
|
||||||
|
| "copy"
|
||||||
|
| "cut"
|
||||||
|
| "paste"
|
||||||
|
| "copyAsPng"
|
||||||
|
| "copyAsSvg"
|
||||||
| "sendBackward"
|
| "sendBackward"
|
||||||
| "bringForward"
|
| "bringForward"
|
||||||
| "sendToBack"
|
| "sendToBack"
|
||||||
@@ -29,6 +35,9 @@ export type ActionName =
|
|||||||
| "copyStyles"
|
| "copyStyles"
|
||||||
| "selectAll"
|
| "selectAll"
|
||||||
| "pasteStyles"
|
| "pasteStyles"
|
||||||
|
| "gridMode"
|
||||||
|
| "zenMode"
|
||||||
|
| "stats"
|
||||||
| "changeStrokeColor"
|
| "changeStrokeColor"
|
||||||
| "changeBackgroundColor"
|
| "changeBackgroundColor"
|
||||||
| "changeFillStyle"
|
| "changeFillStyle"
|
||||||
@@ -58,6 +67,7 @@ export type ActionName =
|
|||||||
| "zoomOut"
|
| "zoomOut"
|
||||||
| "resetZoom"
|
| "resetZoom"
|
||||||
| "zoomToFit"
|
| "zoomToFit"
|
||||||
|
| "zoomToSelection"
|
||||||
| "changeFontFamily"
|
| "changeFontFamily"
|
||||||
| "changeTextAlign"
|
| "changeTextAlign"
|
||||||
| "toggleFullScreen"
|
| "toggleFullScreen"
|
||||||
@@ -74,7 +84,8 @@ export type ActionName =
|
|||||||
| "alignVerticallyCentered"
|
| "alignVerticallyCentered"
|
||||||
| "alignHorizontallyCentered"
|
| "alignHorizontallyCentered"
|
||||||
| "distributeHorizontally"
|
| "distributeHorizontally"
|
||||||
| "distributeVertically";
|
| "distributeVertically"
|
||||||
|
| "viewMode";
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
@@ -92,19 +103,16 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?: string;
|
||||||
contextMenuOrder?: number;
|
|
||||||
contextItemPredicate?: (
|
contextItemPredicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
checked?: (appState: Readonly<AppState>) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: Record<ActionName, Action>;
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
getContextMenuItems: (
|
|
||||||
actionFilter: ActionFilterFn,
|
|
||||||
) => { label: string; action: () => void }[];
|
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
export const EVENT_ACTION = "action";
|
export const trackEvent =
|
||||||
export const EVENT_ALIGN = "align";
|
typeof process !== "undefined" &&
|
||||||
export const EVENT_CHANGE = "change";
|
process.env?.REACT_APP_GOOGLE_ANALYTICS_ID &&
|
||||||
export const EVENT_DIALOG = "dialog";
|
typeof window !== "undefined" &&
|
||||||
export const EVENT_EXIT = "exit";
|
window.gtag
|
||||||
export const EVENT_IO = "io";
|
? (category: string, name: string, label?: string, value?: number) => {
|
||||||
export const EVENT_LAYER = "layer";
|
window.gtag("event", name, {
|
||||||
export const EVENT_LIBRARY = "library";
|
event_category: category,
|
||||||
export const EVENT_LOAD = "load";
|
event_label: label,
|
||||||
export const EVENT_SHAPE = "shape";
|
value,
|
||||||
export const EVENT_SHARE = "share";
|
});
|
||||||
export const EVENT_MAGIC = "magic";
|
}
|
||||||
|
: typeof process !== "undefined" && process.env?.JEST_WORKER_ID
|
||||||
export const trackEvent = window.gtag
|
? (category: string, name: string, label?: string, value?: number) => {}
|
||||||
? (category: string, name: string, label?: string, value?: number) => {
|
: (category: string, name: string, label?: string, value?: number) => {
|
||||||
window.gtag("event", name, {
|
// Uncomment the next line to track locally
|
||||||
event_category: category,
|
// console.info("Track Event", category, name, label, value);
|
||||||
event_label: label,
|
};
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
: (category: string, name: string, label?: string, value?: number) => {
|
|
||||||
console.info("Track Event", category, name, label, value);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
|
||||||
import { getDateTime } from "./utils";
|
|
||||||
import { t } from "./i18n";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
|
||||||
DEFAULT_FONT_FAMILY,
|
DEFAULT_FONT_FAMILY,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
import { t } from "./i18n";
|
||||||
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
AppState,
|
AppState,
|
||||||
@@ -14,66 +14,65 @@ export const getDefaultAppState = (): Omit<
|
|||||||
> => {
|
> => {
|
||||||
return {
|
return {
|
||||||
appearance: "light",
|
appearance: "light",
|
||||||
isLoading: false,
|
collaborators: new Map(),
|
||||||
errorMessage: null,
|
currentChartType: "bar",
|
||||||
|
currentItemBackgroundColor: "transparent",
|
||||||
|
currentItemEndArrowhead: "arrow",
|
||||||
|
currentItemFillStyle: "hachure",
|
||||||
|
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||||
|
currentItemLinearStrokeSharpness: "round",
|
||||||
|
currentItemOpacity: 100,
|
||||||
|
currentItemRoughness: 1,
|
||||||
|
currentItemStartArrowhead: null,
|
||||||
|
currentItemStrokeColor: oc.black,
|
||||||
|
currentItemStrokeSharpness: "sharp",
|
||||||
|
currentItemStrokeStyle: "solid",
|
||||||
|
currentItemStrokeWidth: 1,
|
||||||
|
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||||
|
cursorButton: "up",
|
||||||
draggingElement: null,
|
draggingElement: null,
|
||||||
resizingElement: null,
|
|
||||||
multiElement: null,
|
|
||||||
editingElement: null,
|
editingElement: null,
|
||||||
startBoundElement: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
editingLinearElement: null,
|
||||||
elementType: "selection",
|
|
||||||
elementLocked: false,
|
elementLocked: false,
|
||||||
|
elementType: "selection",
|
||||||
|
errorMessage: null,
|
||||||
exportBackground: true,
|
exportBackground: true,
|
||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
shouldAddWatermark: false,
|
fileHandle: null,
|
||||||
currentItemStrokeColor: oc.black,
|
gridSize: null,
|
||||||
currentItemBackgroundColor: "transparent",
|
height: window.innerHeight,
|
||||||
currentItemFillStyle: "hachure",
|
|
||||||
currentItemStrokeWidth: 1,
|
|
||||||
currentItemStrokeStyle: "solid",
|
|
||||||
currentItemRoughness: 1,
|
|
||||||
currentItemOpacity: 100,
|
|
||||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
|
||||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
|
||||||
currentItemStrokeSharpness: "sharp",
|
|
||||||
currentItemLinearStrokeSharpness: "round",
|
|
||||||
currentItemStartArrowhead: null,
|
|
||||||
currentItemEndArrowhead: "arrow",
|
|
||||||
viewBackgroundColor: oc.white,
|
|
||||||
scrollX: 0 as FlooredNumber,
|
|
||||||
scrollY: 0 as FlooredNumber,
|
|
||||||
cursorX: 0,
|
|
||||||
cursorY: 0,
|
|
||||||
cursorButton: "up",
|
|
||||||
scrolledOutside: false,
|
|
||||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
|
isLibraryOpen: false,
|
||||||
|
isLoading: false,
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
isRotating: false,
|
isRotating: false,
|
||||||
selectionElement: null,
|
|
||||||
zoom: {
|
|
||||||
value: 1 as NormalizedZoomValue,
|
|
||||||
translation: { x: 0, y: 0 },
|
|
||||||
},
|
|
||||||
openMenu: null,
|
|
||||||
lastPointerDownWith: "mouse",
|
lastPointerDownWith: "mouse",
|
||||||
selectedElementIds: {},
|
multiElement: null,
|
||||||
|
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||||
|
openMenu: null,
|
||||||
|
pasteDialog: { shown: false, data: null },
|
||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
shouldCacheIgnoreZoom: false,
|
resizingElement: null,
|
||||||
showShortcutsDialog: false,
|
scrolledOutside: false,
|
||||||
suggestedBindings: [],
|
scrollX: 0,
|
||||||
zenModeEnabled: false,
|
scrollY: 0,
|
||||||
gridSize: null,
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
width: window.innerWidth,
|
selectionElement: null,
|
||||||
height: window.innerHeight,
|
shouldAddWatermark: false,
|
||||||
isLibraryOpen: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
fileHandle: null,
|
showHelpDialog: false,
|
||||||
collaborators: new Map(),
|
|
||||||
showStats: false,
|
showStats: false,
|
||||||
|
startBoundElement: null,
|
||||||
|
suggestedBindings: [],
|
||||||
|
toastMessage: null,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
width: window.innerWidth,
|
||||||
|
zenModeEnabled: false,
|
||||||
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
|
viewModeEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,26 +92,25 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
|
||||||
) => config)({
|
) => config)({
|
||||||
appearance: { browser: true, export: false },
|
appearance: { browser: true, export: false },
|
||||||
|
collaborators: { browser: false, export: false },
|
||||||
|
currentChartType: { browser: true, export: false },
|
||||||
currentItemBackgroundColor: { browser: true, export: false },
|
currentItemBackgroundColor: { browser: true, export: false },
|
||||||
|
currentItemEndArrowhead: { browser: true, export: false },
|
||||||
currentItemFillStyle: { browser: true, export: false },
|
currentItemFillStyle: { browser: true, export: false },
|
||||||
currentItemFontFamily: { browser: true, export: false },
|
currentItemFontFamily: { browser: true, export: false },
|
||||||
currentItemFontSize: { browser: true, export: false },
|
currentItemFontSize: { browser: true, export: false },
|
||||||
|
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
||||||
currentItemOpacity: { browser: true, export: false },
|
currentItemOpacity: { browser: true, export: false },
|
||||||
currentItemRoughness: { browser: true, export: false },
|
currentItemRoughness: { browser: true, export: false },
|
||||||
|
currentItemStartArrowhead: { browser: true, export: false },
|
||||||
currentItemStrokeColor: { browser: true, export: false },
|
currentItemStrokeColor: { browser: true, export: false },
|
||||||
|
currentItemStrokeSharpness: { browser: true, export: false },
|
||||||
currentItemStrokeStyle: { browser: true, export: false },
|
currentItemStrokeStyle: { browser: true, export: false },
|
||||||
currentItemStrokeWidth: { browser: true, export: false },
|
currentItemStrokeWidth: { browser: true, export: false },
|
||||||
currentItemTextAlign: { browser: true, export: false },
|
currentItemTextAlign: { browser: true, export: false },
|
||||||
currentItemStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemLinearStrokeSharpness: { browser: true, export: false },
|
|
||||||
currentItemStartArrowhead: { browser: true, export: false },
|
|
||||||
currentItemEndArrowhead: { browser: true, export: false },
|
|
||||||
cursorButton: { browser: true, export: false },
|
cursorButton: { browser: true, export: false },
|
||||||
cursorX: { browser: true, export: false },
|
|
||||||
cursorY: { browser: true, export: false },
|
|
||||||
draggingElement: { browser: false, export: false },
|
draggingElement: { browser: false, export: false },
|
||||||
editingElement: { browser: false, export: false },
|
editingElement: { browser: false, export: false },
|
||||||
startBoundElement: { browser: false, export: false },
|
|
||||||
editingGroupId: { browser: true, export: false },
|
editingGroupId: { browser: true, export: false },
|
||||||
editingLinearElement: { browser: false, export: false },
|
editingLinearElement: { browser: false, export: false },
|
||||||
elementLocked: { browser: true, export: false },
|
elementLocked: { browser: true, export: false },
|
||||||
@@ -120,6 +118,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
errorMessage: { browser: false, export: false },
|
errorMessage: { browser: false, export: false },
|
||||||
exportBackground: { browser: true, export: false },
|
exportBackground: { browser: true, export: false },
|
||||||
exportEmbedScene: { browser: true, export: false },
|
exportEmbedScene: { browser: true, export: false },
|
||||||
|
fileHandle: { browser: false, export: false },
|
||||||
gridSize: { browser: true, export: true },
|
gridSize: { browser: true, export: true },
|
||||||
height: { browser: false, export: false },
|
height: { browser: false, export: false },
|
||||||
isBindingEnabled: { browser: false, export: false },
|
isBindingEnabled: { browser: false, export: false },
|
||||||
@@ -130,7 +129,10 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
lastPointerDownWith: { browser: true, export: false },
|
lastPointerDownWith: { browser: true, export: false },
|
||||||
multiElement: { browser: false, export: false },
|
multiElement: { browser: false, export: false },
|
||||||
name: { browser: true, export: false },
|
name: { browser: true, export: false },
|
||||||
|
offsetLeft: { browser: false, export: false },
|
||||||
|
offsetTop: { browser: false, export: false },
|
||||||
openMenu: { browser: true, export: false },
|
openMenu: { browser: true, export: false },
|
||||||
|
pasteDialog: { browser: false, export: false },
|
||||||
previousSelectedElementIds: { browser: true, export: false },
|
previousSelectedElementIds: { browser: true, export: false },
|
||||||
resizingElement: { browser: false, export: false },
|
resizingElement: { browser: false, export: false },
|
||||||
scrolledOutside: { browser: true, export: false },
|
scrolledOutside: { browser: true, export: false },
|
||||||
@@ -141,17 +143,16 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
selectionElement: { browser: false, export: false },
|
selectionElement: { browser: false, export: false },
|
||||||
shouldAddWatermark: { browser: true, export: false },
|
shouldAddWatermark: { browser: true, export: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false },
|
||||||
showShortcutsDialog: { browser: false, export: false },
|
showHelpDialog: { browser: false, export: false },
|
||||||
|
showStats: { browser: true, export: false },
|
||||||
|
startBoundElement: { browser: false, export: false },
|
||||||
suggestedBindings: { browser: false, export: false },
|
suggestedBindings: { browser: false, export: false },
|
||||||
|
toastMessage: { browser: false, export: false },
|
||||||
viewBackgroundColor: { browser: true, export: true },
|
viewBackgroundColor: { browser: true, export: true },
|
||||||
width: { browser: false, export: false },
|
width: { browser: false, export: false },
|
||||||
zenModeEnabled: { browser: true, export: false },
|
zenModeEnabled: { browser: true, export: false },
|
||||||
zoom: { browser: true, export: false },
|
zoom: { browser: true, export: false },
|
||||||
offsetTop: { browser: false, export: false },
|
viewModeEnabled: { browser: false, export: false },
|
||||||
offsetLeft: { browser: false, export: false },
|
|
||||||
fileHandle: { browser: false, export: false },
|
|
||||||
collaborators: { browser: false, export: false },
|
|
||||||
showStats: { browser: true, export: false },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||||
@@ -166,11 +167,6 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
|||||||
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
|
||||||
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
|
||||||
const propConfig = APP_STATE_STORAGE_CONF[key];
|
const propConfig = APP_STATE_STORAGE_CONF[key];
|
||||||
if (!propConfig) {
|
|
||||||
console.error(
|
|
||||||
`_clearAppStateForStorage: appState key "${key}" config doesn't exist for "${exportType}" export type`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (propConfig?.[exportType]) {
|
if (propConfig?.[exportType]) {
|
||||||
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
|
||||||
stateForExport[key] = appState[key];
|
stateForExport[key] = appState[key];
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { EVENT_MAGIC, trackEvent } from "./analytics";
|
|
||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
|
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants";
|
||||||
import { newElement, newTextElement, newLinearElement } from "./element";
|
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||||
import { ExcalidrawElement } from "./element/types";
|
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||||
import { randomId } from "./random";
|
import { randomId } from "./random";
|
||||||
|
|
||||||
|
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||||
|
|
||||||
const BAR_WIDTH = 32;
|
const BAR_WIDTH = 32;
|
||||||
const BAR_GAP = 12;
|
const BAR_GAP = 12;
|
||||||
const BAR_HEIGHT = 256;
|
const BAR_HEIGHT = 256;
|
||||||
|
const GRID_OPACITY = 50;
|
||||||
|
|
||||||
export interface Spreadsheet {
|
export interface Spreadsheet {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -19,15 +21,15 @@ export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
|||||||
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
|
||||||
|
|
||||||
type ParseSpreadsheetResult =
|
type ParseSpreadsheetResult =
|
||||||
| { type: typeof NOT_SPREADSHEET }
|
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||||
|
|
||||||
const tryParseNumber = (s: string): number | null => {
|
const tryParseNumber = (s: string): number | null => {
|
||||||
const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
|
const match = /^[$€£¥₩]?([0-9,]+(\.[0-9]+)?)$/.exec(s);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parseFloat(match[1]);
|
return parseFloat(match[1].replace(/,/g, ""));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
const isNumericColumn = (lines: string[][], columnIndex: number) =>
|
||||||
@@ -37,12 +39,12 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
const numCols = cells[0].length;
|
const numCols = cells[0].length;
|
||||||
|
|
||||||
if (numCols > 2) {
|
if (numCols > 2) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "More than 2 columns" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (numCols === 1) {
|
if (numCols === 1) {
|
||||||
if (!isNumericColumn(cells, 0)) {
|
if (!isNumericColumn(cells, 0)) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
const hasHeader = tryParseNumber(cells[0][0]) === null;
|
||||||
@@ -51,7 +53,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (values.length < 2) {
|
if (values.length < 2) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Less than two rows" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -67,7 +69,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
|
||||||
|
|
||||||
if (!isNumericColumn(cells, valueColumnIndex)) {
|
if (!isNumericColumn(cells, valueColumnIndex)) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Value is not numeric" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
const labelColumnIndex = (valueColumnIndex + 1) % 2;
|
||||||
@@ -75,7 +77,7 @@ const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
|
|||||||
const rows = hasHeader ? cells.slice(1) : cells;
|
const rows = hasHeader ? cells.slice(1) : cells;
|
||||||
|
|
||||||
if (rows.length < 2) {
|
if (rows.length < 2) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "Less than 2 rows" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -104,13 +106,13 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
// Copy/paste from excel, spreadhseets, tsv, csv.
|
// Copy/paste from excel, spreadhseets, tsv, csv.
|
||||||
// For now we only accept 2 columns with an optional header
|
// For now we only accept 2 columns with an optional header
|
||||||
|
|
||||||
// Check for tab separeted values
|
// Check for tab separated values
|
||||||
let lines = text
|
let lines = text
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim().split("\t"));
|
.map((line) => line.trim().split("\t"));
|
||||||
|
|
||||||
// Check for comma separeted files
|
// Check for comma separated files
|
||||||
if (lines.length && lines[0].length !== 2) {
|
if (lines.length && lines[0].length !== 2) {
|
||||||
lines = text
|
lines = text
|
||||||
.trim()
|
.trim()
|
||||||
@@ -119,14 +121,17 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return { type: NOT_SPREADSHEET, reason: "No values" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const numColsFirstLine = lines[0].length;
|
const numColsFirstLine = lines[0].length;
|
||||||
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
|
||||||
|
|
||||||
if (!isSpreadsheet) {
|
if (!isSpreadsheet) {
|
||||||
return { type: NOT_SPREADSHEET };
|
return {
|
||||||
|
type: NOT_SPREADSHEET,
|
||||||
|
reason: "All rows don't have same number of columns",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = tryParseCells(lines);
|
const result = tryParseCells(lines);
|
||||||
@@ -136,111 +141,48 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
|||||||
return transposedResults;
|
return transposedResults;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
const bgColors = colors.elementBackground.slice(
|
||||||
export const renderSpreadsheet = (
|
2,
|
||||||
|
colors.elementBackground.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put all the common properties here so when the whole chart is selected
|
||||||
|
// the properties dialog shows the correct selected values
|
||||||
|
const commonProps = {
|
||||||
|
fillStyle: "hachure",
|
||||||
|
fontFamily: DEFAULT_FONT_FAMILY,
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
opacity: 100,
|
||||||
|
roughness: 1,
|
||||||
|
strokeColor: colors.elementStroke[0],
|
||||||
|
strokeSharpness: "sharp",
|
||||||
|
strokeStyle: "solid",
|
||||||
|
strokeWidth: 1,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||||
|
const chartWidth =
|
||||||
|
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||||
|
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||||
|
return { chartWidth, chartHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartXLabels = (
|
||||||
spreadsheet: Spreadsheet,
|
spreadsheet: Spreadsheet,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): ExcalidrawElement[] => {
|
groupId: string,
|
||||||
const values = spreadsheet.values;
|
backgroundColor: string,
|
||||||
const max = Math.max(...values);
|
): ChartElements => {
|
||||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
return (
|
||||||
const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
|
|
||||||
const maxColors = colors.elementBackground.length;
|
|
||||||
const bgColors = colors.elementBackground.slice(2, maxColors);
|
|
||||||
|
|
||||||
// Put all the common properties here so when the whole chart is selected
|
|
||||||
// the properties dialog shows the correct selected values
|
|
||||||
const commonProps = {
|
|
||||||
backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
|
|
||||||
fillStyle: "hachure",
|
|
||||||
fontFamily: DEFAULT_FONT_FAMILY,
|
|
||||||
fontSize: DEFAULT_FONT_SIZE,
|
|
||||||
groupIds: [randomId()],
|
|
||||||
opacity: 100,
|
|
||||||
roughness: 1,
|
|
||||||
strokeColor: colors.elementStroke[0],
|
|
||||||
strokeSharpness: "sharp",
|
|
||||||
strokeStyle: "solid",
|
|
||||||
strokeWidth: 1,
|
|
||||||
verticalAlign: "middle",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const minYLabel = newTextElement({
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_GAP,
|
|
||||||
text: "0",
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxYLabel = newTextElement({
|
|
||||||
...commonProps,
|
|
||||||
x: x - BAR_GAP,
|
|
||||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
|
||||||
text: max.toLocaleString(),
|
|
||||||
textAlign: "right",
|
|
||||||
});
|
|
||||||
|
|
||||||
const xAxisLine = newLinearElement({
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
...commonProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
const yAxisLine = newLinearElement({
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[0, -chartHeight],
|
|
||||||
],
|
|
||||||
...commonProps,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxValueLine = newLinearElement({
|
|
||||||
type: "line",
|
|
||||||
x,
|
|
||||||
y: y - BAR_HEIGHT - BAR_GAP,
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: null,
|
|
||||||
...commonProps,
|
|
||||||
strokeStyle: "dotted",
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[chartWidth, 0],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const bars = values.map((value, index) => {
|
|
||||||
const barHeight = (value / max) * BAR_HEIGHT;
|
|
||||||
return newElement({
|
|
||||||
...commonProps,
|
|
||||||
type: "rectangle",
|
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
|
||||||
y: y - barHeight - BAR_GAP,
|
|
||||||
width: BAR_WIDTH,
|
|
||||||
height: barHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const xLabels =
|
|
||||||
spreadsheet.labels?.map((label, index) => {
|
spreadsheet.labels?.map((label, index) => {
|
||||||
return newTextElement({
|
return newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
...commonProps,
|
...commonProps,
|
||||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||||
@@ -251,29 +193,287 @@ export const renderSpreadsheet = (
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
verticalAlign: "top",
|
verticalAlign: "top",
|
||||||
});
|
});
|
||||||
}) || [];
|
}) || []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartYLabels = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
const minYLabel = newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
x: x - BAR_GAP,
|
||||||
|
y: y - BAR_GAP,
|
||||||
|
text: "0",
|
||||||
|
textAlign: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxYLabel = newTextElement({
|
||||||
|
groupIds: [groupId],
|
||||||
|
backgroundColor,
|
||||||
|
...commonProps,
|
||||||
|
x: x - BAR_GAP,
|
||||||
|
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||||
|
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||||
|
textAlign: "right",
|
||||||
|
});
|
||||||
|
|
||||||
|
return [minYLabel, maxYLabel];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartLines = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
): ChartElements => {
|
||||||
|
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||||
|
const xLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
width: chartWidth,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const yLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: chartHeight,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, -chartHeight],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxLine = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x,
|
||||||
|
y: y - BAR_HEIGHT - BAR_GAP,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
strokeStyle: "dotted",
|
||||||
|
width: chartWidth,
|
||||||
|
opacity: GRID_OPACITY,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[chartWidth, 0],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return [xLine, yLine, maxLine];
|
||||||
|
};
|
||||||
|
|
||||||
|
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
|
||||||
|
const chartBaseElements = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
groupId: string,
|
||||||
|
backgroundColor: string,
|
||||||
|
debug?: boolean,
|
||||||
|
): ChartElements => {
|
||||||
|
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||||
|
|
||||||
const title = spreadsheet.title
|
const title = spreadsheet.title
|
||||||
? newTextElement({
|
? newTextElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
...commonProps,
|
...commonProps,
|
||||||
text: spreadsheet.title,
|
text: spreadsheet.title,
|
||||||
x: x + chartWidth / 2,
|
x: x + chartWidth / 2,
|
||||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
|
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
|
const debugRect = debug
|
||||||
|
? newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "rectangle",
|
||||||
|
x,
|
||||||
|
y: y - chartHeight,
|
||||||
|
width: chartWidth,
|
||||||
|
height: chartHeight,
|
||||||
|
strokeColor: colors.elementStroke[0],
|
||||||
|
fillStyle: "solid",
|
||||||
|
opacity: 6,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
title,
|
...(debugRect ? [debugRect] : []),
|
||||||
...bars,
|
...(title ? [title] : []),
|
||||||
...xLabels,
|
...chartXLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
xAxisLine,
|
...chartYLabels(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
yAxisLine,
|
...chartLines(spreadsheet, x, y, groupId, backgroundColor),
|
||||||
maxValueLine,
|
];
|
||||||
minYLabel,
|
};
|
||||||
maxYLabel,
|
|
||||||
].filter((element) => element !== null) as ExcalidrawElement[];
|
const chartTypeBar = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
const max = Math.max(...spreadsheet.values);
|
||||||
|
const groupId = randomId();
|
||||||
|
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||||
|
|
||||||
|
const bars = spreadsheet.values.map((value, index) => {
|
||||||
|
const barHeight = (value / max) * BAR_HEIGHT;
|
||||||
|
return newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "rectangle",
|
||||||
|
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
|
||||||
|
y: y - barHeight - BAR_GAP,
|
||||||
|
width: BAR_WIDTH,
|
||||||
|
height: barHeight,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...bars,
|
||||||
|
...chartBaseElements(
|
||||||
|
spreadsheet,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
groupId,
|
||||||
|
backgroundColor,
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartTypeLine = (
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
const max = Math.max(...spreadsheet.values);
|
||||||
|
const groupId = randomId();
|
||||||
|
const backgroundColor = bgColors[Math.floor(Math.random() * bgColors.length)];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const points = [];
|
||||||
|
for (const value of spreadsheet.values) {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP);
|
||||||
|
const cy = -(value / max) * BAR_HEIGHT;
|
||||||
|
points.push([cx, cy]);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxX = Math.max(...points.map((element) => element[0]));
|
||||||
|
const maxY = Math.max(...points.map((element) => element[1]));
|
||||||
|
const minX = Math.min(...points.map((element) => element[0]));
|
||||||
|
const minY = Math.min(...points.map((element) => element[1]));
|
||||||
|
|
||||||
|
const line = newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x: x + BAR_GAP + BAR_WIDTH / 2,
|
||||||
|
y: y - BAR_GAP,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: maxY - minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
strokeWidth: 2,
|
||||||
|
points: points as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dots = spreadsheet.values.map((value, index) => {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||||
|
const cy = -(value / max) * BAR_HEIGHT + BAR_GAP / 2;
|
||||||
|
return newElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
fillStyle: "solid",
|
||||||
|
strokeWidth: 2,
|
||||||
|
type: "ellipse",
|
||||||
|
x: x + cx + BAR_WIDTH / 2,
|
||||||
|
y: y + cy - BAR_GAP * 2,
|
||||||
|
width: BAR_GAP,
|
||||||
|
height: BAR_GAP,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = spreadsheet.values.map((value, index) => {
|
||||||
|
const cx = index * (BAR_WIDTH + BAR_GAP) + BAR_GAP / 2;
|
||||||
|
const cy = (value / max) * BAR_HEIGHT + BAR_GAP / 2 + BAR_GAP;
|
||||||
|
return newLinearElement({
|
||||||
|
backgroundColor,
|
||||||
|
groupIds: [groupId],
|
||||||
|
...commonProps,
|
||||||
|
type: "line",
|
||||||
|
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
|
||||||
|
y: y - cy,
|
||||||
|
startArrowhead: null,
|
||||||
|
endArrowhead: null,
|
||||||
|
height: cy,
|
||||||
|
strokeStyle: "dotted",
|
||||||
|
opacity: GRID_OPACITY,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[0, cy],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...chartBaseElements(
|
||||||
|
spreadsheet,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
groupId,
|
||||||
|
backgroundColor,
|
||||||
|
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||||
|
),
|
||||||
|
line,
|
||||||
|
...lines,
|
||||||
|
...dots,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderSpreadsheet = (
|
||||||
|
chartType: string,
|
||||||
|
spreadsheet: Spreadsheet,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): ChartElements => {
|
||||||
|
if (chartType === "line") {
|
||||||
|
return chartTypeLine(spreadsheet, x, y);
|
||||||
|
}
|
||||||
|
return chartTypeBar(spreadsheet, x, y);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState, Zoom } from "../types";
|
|
||||||
import { ExcalidrawElement } from "../element/types";
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
|
import { getNonDeletedElements } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
import {
|
import {
|
||||||
hasBackground,
|
|
||||||
hasStroke,
|
|
||||||
canChangeSharpness,
|
canChangeSharpness,
|
||||||
hasText,
|
|
||||||
canHaveArrowheads,
|
canHaveArrowheads,
|
||||||
getTargetElements,
|
getTargetElements,
|
||||||
|
hasBackground,
|
||||||
|
hasStroke,
|
||||||
|
hasText,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import { t } from "../i18n";
|
|
||||||
import { SHAPES } from "../shapes";
|
import { SHAPES } from "../shapes";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { AppState, Zoom } from "../types";
|
||||||
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import useIsMobile from "../is-mobile";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { getNonDeletedElements } from "../element";
|
|
||||||
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
|
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@@ -164,9 +163,9 @@ export const ShapesSwitcher = ({
|
|||||||
{SHAPES.map(({ value, icon, key }, index) => {
|
{SHAPES.map(({ value, icon, key }, index) => {
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter = typeof key === "string" ? key : key[0];
|
const letter = typeof key === "string" ? key : key[0];
|
||||||
const shortcut = `${capitalizeString(letter)} ${t(
|
const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
|
||||||
"shortcutsDialog.or",
|
index + 1
|
||||||
)} ${index + 1}`;
|
}`;
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className="Shape"
|
className="Shape"
|
||||||
@@ -181,7 +180,6 @@ export const ShapesSwitcher = ({
|
|||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={value}
|
data-testid={value}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
trackEvent(EVENT_SHAPE, value, "toolbar");
|
|
||||||
setAppState({
|
setAppState({
|
||||||
elementType: value,
|
elementType: value,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
@@ -203,9 +201,6 @@ export const ShapesSwitcher = ({
|
|||||||
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
title={`${capitalizeString(t("toolBar.library"))} — 9`}
|
||||||
aria-label={capitalizeString(t("toolBar.library"))}
|
aria-label={capitalizeString(t("toolBar.library"))}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isLibraryOpen) {
|
|
||||||
trackEvent(EVENT_DIALOG, "library");
|
|
||||||
}
|
|
||||||
setAppState({ isLibraryOpen: !isLibraryOpen });
|
setAppState({ isLibraryOpen: !isLibraryOpen });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Avatar {
|
.Avatar {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { EVENT_CHANGE, trackEvent } from "../analytics";
|
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { DarkModeToggle } from "./DarkModeToggle";
|
import { DarkModeToggle } from "./DarkModeToggle";
|
||||||
|
|
||||||
@@ -19,8 +18,6 @@ export const BackgroundPickerAndDarkModeToggle = ({
|
|||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
value={appState.appearance}
|
value={appState.appearance}
|
||||||
onChange={(appearance) => {
|
onChange={(appearance) => {
|
||||||
// TODO: track the theme on the first load too
|
|
||||||
trackEvent(EVENT_CHANGE, "theme", appearance);
|
|
||||||
setAppState({ appearance });
|
setAppState({ appearance });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.CollabButton.is-collaborating {
|
.CollabButton.is-collaborating {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import useIsMobile from "../is-mobile";
|
|||||||
import { users } from "./icons";
|
import { users } from "./icons";
|
||||||
|
|
||||||
import "./CollabButton.scss";
|
import "./CollabButton.scss";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const CollabButton = ({
|
const CollabButton = ({
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
@@ -23,10 +22,7 @@ const CollabButton = ({
|
|||||||
className={clsx("CollabButton", {
|
className={clsx("CollabButton", {
|
||||||
"is-collaborating": isCollaborating,
|
"is-collaborating": isCollaborating,
|
||||||
})}
|
})}
|
||||||
onClick={() => {
|
onClick={onClick}
|
||||||
trackEvent(EVENT_DIALOG, "collaboration");
|
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
icon={users}
|
icon={users}
|
||||||
type="button"
|
type="button"
|
||||||
title={t("buttons.roomDialog")}
|
title={t("buttons.roomDialog")}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.color-picker {
|
.color-picker {
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$media-query} {
|
@media #{$is-mobile-query} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.context-menu {
|
.context-menu {
|
||||||
@@ -9,9 +9,10 @@
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin: -0.25rem 0 0 0.125rem;
|
margin: -0.25rem 0 0 0.125rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.5rem 0;
|
||||||
background-color: var(--popup-secondary-background-color);
|
background-color: var(--popup-secondary-background-color);
|
||||||
border: 1px solid var(--button-gray-3);
|
border: 1px solid var(--button-gray-3);
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu button {
|
.context-menu button {
|
||||||
@@ -32,13 +33,29 @@
|
|||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 0.2fr;
|
grid-template-columns: 1fr 0.2fr;
|
||||||
div:nth-child(1) {
|
align-items: center;
|
||||||
|
|
||||||
|
&.checkmark::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
content: "\2713";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
.context-menu-option__label {
|
||||||
|
color: $oc-red-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option__label {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
margin-inline-end: 20px;
|
margin-inline-end: 20px;
|
||||||
}
|
}
|
||||||
div:nth-child(2) {
|
.context-menu-option__shortcut {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
font-family: inherit;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,9 +63,35 @@
|
|||||||
.context-menu-option:hover {
|
.context-menu-option:hover {
|
||||||
color: var(--popup-background-color);
|
color: var(--popup-background-color);
|
||||||
background-color: var(--select-highlight-color);
|
background-color: var(--select-highlight-color);
|
||||||
|
|
||||||
|
&.dangerous {
|
||||||
|
.context-menu-option__label {
|
||||||
|
color: var(--popup-background-color);
|
||||||
|
}
|
||||||
|
background-color: $oc-red-6;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-option:focus {
|
.context-menu-option:focus {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.context-menu-option {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.context-menu-option__label {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option__shortcut {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-option-separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid $oc-gray-5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,39 @@ import React from "react";
|
|||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Popover } from "./Popover";
|
import { Popover } from "./Popover";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
import "./ContextMenu.scss";
|
import "./ContextMenu.scss";
|
||||||
import {
|
import {
|
||||||
getShortcutFromShortcutName,
|
getShortcutFromShortcutName,
|
||||||
ShortcutName,
|
ShortcutName,
|
||||||
} from "../actions/shortcuts";
|
} from "../actions/shortcuts";
|
||||||
|
import { Action } from "../actions/types";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
|
||||||
type ContextMenuOption = {
|
export type ContextMenuOption = "separator" | Action;
|
||||||
shortcutName: ShortcutName;
|
|
||||||
label: string;
|
|
||||||
action(): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type ContextMenuProps = {
|
||||||
options: ContextMenuOption[];
|
options: ContextMenuOption[];
|
||||||
onCloseRequest?(): void;
|
onCloseRequest?(): void;
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
|
actionManager: ActionManager;
|
||||||
|
appState: Readonly<AppState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
const ContextMenu = ({
|
||||||
|
options,
|
||||||
|
onCloseRequest,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
actionManager,
|
||||||
|
appState,
|
||||||
|
}: ContextMenuProps) => {
|
||||||
const isDarkTheme = !!document
|
const isDarkTheme = !!document
|
||||||
.querySelector(".excalidraw")
|
.querySelector(".excalidraw")
|
||||||
?.classList.contains("Appearance_dark");
|
?.classList.contains("Appearance_dark");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx("excalidraw", {
|
className={clsx("excalidraw", {
|
||||||
@@ -43,18 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
|
|||||||
className="context-menu"
|
className="context-menu"
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{options.map(({ action, shortcutName, label }, idx) => (
|
{options.map((option, idx) => {
|
||||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
if (option === "separator") {
|
||||||
<button className="context-menu-option" onClick={action}>
|
return <hr key={idx} className="context-menu-option-separator" />;
|
||||||
<div>{label}</div>
|
}
|
||||||
<div>
|
|
||||||
{shortcutName
|
const actionName = option.name;
|
||||||
? getShortcutFromShortcutName(shortcutName)
|
const label = option.contextItemLabel
|
||||||
: ""}
|
? t(option.contextItemLabel)
|
||||||
</div>
|
: "";
|
||||||
</button>
|
return (
|
||||||
</li>
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
))}
|
<button
|
||||||
|
className={clsx("context-menu-option", {
|
||||||
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
|
checkmark: option.checked?.(appState),
|
||||||
|
})}
|
||||||
|
onClick={() => actionManager.executeAction(option)}
|
||||||
|
>
|
||||||
|
<div className="context-menu-option__label">{label}</div>
|
||||||
|
<kbd className="context-menu-option__shortcut">
|
||||||
|
{actionName
|
||||||
|
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||||
|
: ""}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => {
|
|||||||
|
|
||||||
type ContextMenuParams = {
|
type ContextMenuParams = {
|
||||||
options: (ContextMenuOption | false | null | undefined)[];
|
options: (ContextMenuOption | false | null | undefined)[];
|
||||||
top: number;
|
top: ContextMenuProps["top"];
|
||||||
left: number;
|
left: ContextMenuProps["left"];
|
||||||
|
actionManager: ContextMenuProps["actionManager"];
|
||||||
|
appState: Readonly<AppState>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -96,6 +122,8 @@ export default {
|
|||||||
left={params.left}
|
left={params.left}
|
||||||
options={options}
|
options={options}
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
|
actionManager={params.actionManager}
|
||||||
|
appState={params.appState}
|
||||||
/>,
|
/>,
|
||||||
getContextMenuNode(),
|
getContextMenuNode(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const DarkModeToggle = (props: {
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
|
||||||
title={t("buttons.toggleDarkMode")}
|
title={
|
||||||
|
props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
|
||||||
@@ -23,7 +25,11 @@ export const DarkModeToggle = (props: {
|
|||||||
props.onChange(event.target.checked ? "dark" : "light")
|
props.onChange(event.target.checked ? "dark" : "light")
|
||||||
}
|
}
|
||||||
checked={props.value === "dark"}
|
checked={props.value === "dark"}
|
||||||
aria-label={t("buttons.toggleDarkMode")}
|
aria-label={
|
||||||
|
props.value === "dark"
|
||||||
|
? t("buttons.lightMode")
|
||||||
|
: t("buttons.darkMode")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="ToolIcon__icon">
|
<div className="ToolIcon__icon">
|
||||||
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
.Dialog {
|
||||||
|
user-select: text;
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.Dialog__title {
|
.Dialog__title {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
grid-template-columns: 1fr calc(var(--space-factor) * 7);
|
||||||
grid-gap: var(--metric);
|
grid-gap: var(--metric);
|
||||||
|
padding: calc(var(--space-factor) * 2);
|
||||||
|
text-align: center;
|
||||||
|
font-variant: small-caps;
|
||||||
|
font-size: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Dialog__titleContent {
|
.Dialog__titleContent {
|
||||||
@@ -18,7 +27,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$media-query} {
|
.Dialog__content {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
.Dialog {
|
.Dialog {
|
||||||
--metric: calc(var(--space-factor) * 4);
|
--metric: calc(var(--space-factor) * 4);
|
||||||
--inset-left: #{"max(var(--metric), var(--sal))"};
|
--inset-left: #{"max(var(--metric), var(--sal))"};
|
||||||
@@ -30,13 +43,8 @@
|
|||||||
var(--space-factor) * 7
|
var(--space-factor) * 7
|
||||||
);
|
);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(-1 * var(--metric));
|
top: 0;
|
||||||
margin: calc(-1 * var(--inset-right));
|
|
||||||
margin-top: calc(-1 * var(--metric));
|
|
||||||
margin-bottom: var(--metric);
|
|
||||||
padding: calc(var(--space-factor) * 2);
|
padding: calc(var(--space-factor) * 2);
|
||||||
padding-left: var(--inset-left);
|
|
||||||
padding-right: var(--inset-right);
|
|
||||||
background: var(--bg-color-island);
|
background: var(--bg-color-island);
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Modal } from "./Modal";
|
import React, { useEffect } from "react";
|
||||||
import { Island } from "./Island";
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { back, close } from "./icons";
|
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
import "./Dialog.scss";
|
import "./Dialog.scss";
|
||||||
|
import { back, close } from "./icons";
|
||||||
const useRefState = <T,>() => {
|
import { Island } from "./Island";
|
||||||
const [refValue, setRefValue] = useState<T | null>(null);
|
import { Modal } from "./Modal";
|
||||||
const refCallback = useCallback((value: T) => {
|
|
||||||
setRefValue(value);
|
|
||||||
}, []);
|
|
||||||
return [refValue, refCallback] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export const Dialog = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
maxWidth?: number;
|
small?: boolean;
|
||||||
onCloseRequest(): void;
|
onCloseRequest(): void;
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
|
autofocus?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
if (!islandNode) {
|
||||||
@@ -33,7 +26,7 @@ export const Dialog = (props: {
|
|||||||
|
|
||||||
const focusableElements = queryFocusableElements(islandNode);
|
const focusableElements = queryFocusableElements(islandNode);
|
||||||
|
|
||||||
if (focusableElements.length > 0) {
|
if (focusableElements.length > 0 && props.autofocus !== false) {
|
||||||
// If there's an element other than close, focus it.
|
// If there's an element other than close, focus it.
|
||||||
(focusableElements[1] || focusableElements[0]).focus();
|
(focusableElements[1] || focusableElements[0]).focus();
|
||||||
}
|
}
|
||||||
@@ -62,7 +55,7 @@ export const Dialog = (props: {
|
|||||||
islandNode.addEventListener("keydown", handleKeyDown);
|
islandNode.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [islandNode]);
|
}, [islandNode, props.autofocus]);
|
||||||
|
|
||||||
const queryFocusableElements = (node: HTMLElement) => {
|
const queryFocusableElements = (node: HTMLElement) => {
|
||||||
const focusableElements = node.querySelectorAll<HTMLElement>(
|
const focusableElements = node.querySelectorAll<HTMLElement>(
|
||||||
@@ -76,10 +69,10 @@ export const Dialog = (props: {
|
|||||||
<Modal
|
<Modal
|
||||||
className={clsx("Dialog", props.className)}
|
className={clsx("Dialog", props.className)}
|
||||||
labelledBy="dialog-title"
|
labelledBy="dialog-title"
|
||||||
maxWidth={props.maxWidth}
|
maxWidth={props.small ? 550 : 800}
|
||||||
onCloseRequest={props.onCloseRequest}
|
onCloseRequest={props.onCloseRequest}
|
||||||
>
|
>
|
||||||
<Island padding={4} ref={setIslandNode}>
|
<Island ref={setIslandNode}>
|
||||||
<h2 id="dialog-title" className="Dialog__title">
|
<h2 id="dialog-title" className="Dialog__title">
|
||||||
<span className="Dialog__titleContent">{props.title}</span>
|
<span className="Dialog__titleContent">{props.title}</span>
|
||||||
<button
|
<button
|
||||||
@@ -90,7 +83,7 @@ export const Dialog = (props: {
|
|||||||
{useIsMobile() ? back : close}
|
{useIsMobile() ? back : close}
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
{props.children}
|
<div className="Dialog__content">{props.children}</div>
|
||||||
</Island>
|
</Island>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,18 @@ export const ErrorDialog = ({
|
|||||||
<>
|
<>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog
|
||||||
maxWidth={500}
|
small
|
||||||
onCloseRequest={handleClose}
|
onCloseRequest={handleClose}
|
||||||
title={t("errorDialog.title")}
|
title={t("errorDialog.title")}
|
||||||
>
|
>
|
||||||
<div>{message}</div>
|
<div>
|
||||||
|
{message.split("\n").map((line) => (
|
||||||
|
<>
|
||||||
|
{line}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ExportDialog__preview {
|
.ExportDialog__preview {
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
@media #{$is-mobile-query} {
|
||||||
.ExportDialog {
|
.ExportDialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -51,9 +51,7 @@
|
|||||||
.ExportDialog__actions > * {
|
.ExportDialog__actions > * {
|
||||||
margin-bottom: calc(var(--space-factor) * 3);
|
margin-bottom: calc(var(--space-factor) * 3);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
.ExportDialog__preview canvas {
|
.ExportDialog__preview canvas {
|
||||||
max-height: 30vh;
|
max-height: 30vh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { render, unmountComponentAtNode } from "react-dom";
|
||||||
import { ActionsManagerInterface } from "../actions/types";
|
import { ActionsManagerInterface } from "../actions/types";
|
||||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { canvasToBlob } from "../data/blob";
|
import { canvasToBlob } from "../data/blob";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
@@ -67,7 +66,7 @@ const ExportModal = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend: ExportCB;
|
onExportToBackend?: ExportCB;
|
||||||
onCloseRequest: () => void;
|
onCloseRequest: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||||
@@ -155,13 +154,15 @@ const ExportModal = ({
|
|||||||
onClick={() => onExportToClipboard(exportedElements, scale)}
|
onClick={() => onExportToClipboard(exportedElements, scale)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ToolButton
|
{onExportToBackend && (
|
||||||
type="button"
|
<ToolButton
|
||||||
icon={link}
|
type="button"
|
||||||
title={t("buttons.getShareableLink")}
|
icon={link}
|
||||||
aria-label={t("buttons.getShareableLink")}
|
title={t("buttons.getShareableLink")}
|
||||||
onClick={() => onExportToBackend(exportedElements)}
|
aria-label={t("buttons.getShareableLink")}
|
||||||
/>
|
onClick={() => onExportToBackend(exportedElements)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
<div className="ExportDialog__name">
|
<div className="ExportDialog__name">
|
||||||
{actionManager.renderAction("changeProjectName")}
|
{actionManager.renderAction("changeProjectName")}
|
||||||
@@ -235,7 +236,7 @@ export const ExportDialog = ({
|
|||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToSvg: ExportCB;
|
onExportToSvg: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
onExportToBackend: ExportCB;
|
onExportToBackend?: ExportCB;
|
||||||
}) => {
|
}) => {
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||||
@@ -249,7 +250,6 @@ export const ExportDialog = ({
|
|||||||
<>
|
<>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_DIALOG, "export");
|
|
||||||
setModalIsShown(true);
|
setModalIsShown(true);
|
||||||
}}
|
}}
|
||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
@@ -260,11 +260,7 @@ export const ExportDialog = ({
|
|||||||
ref={triggerButton}
|
ref={triggerButton}
|
||||||
/>
|
/>
|
||||||
{modalIsShown && (
|
{modalIsShown && (
|
||||||
<Dialog
|
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||||
maxWidth={800}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
title={t("buttons.export")}
|
|
||||||
>
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
import React from "react";
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(
|
||||||
@@ -17,9 +16,6 @@ export const GitHubCorner = React.memo(
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="GitHub repository"
|
aria-label="GitHub repository"
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "github");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ShortcutsDialog-island {
|
.HelpDialog h3 {
|
||||||
|
border-bottom: 1px solid var(--button-gray-2);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.HelpDialog--island {
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShortcutsDialog-island-title {
|
.HelpDialog--island-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShorcutsDialog-shortcut {
|
.HelpDialog--shortcut {
|
||||||
border-top: 1px solid var(--button-gray-2);
|
border-top: 1px solid var(--button-gray-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShorcutsDialog-key {
|
.HelpDialog--key {
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
border: 1px solid var(--button-gray-2);
|
border: 1px solid var(--button-gray-2);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
@@ -29,14 +34,23 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ShortcutsDialog-footer {
|
.HelpDialog--header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
border-top: 1px solid var(--button-gray-2);
|
margin-bottom: 32px;
|
||||||
margin-top: 8px;
|
padding-bottom: 16px;
|
||||||
padding-top: 16px;
|
}
|
||||||
|
|
||||||
|
.HelpDialog--btn {
|
||||||
|
border: 1px solid var(--link-color);
|
||||||
|
padding: 8px 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.HelpDialog--btn:hover {
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { isDarwin, isWindows } from "../keys";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import { getShortcutKey } from "../utils";
|
||||||
|
import "./HelpDialog.scss";
|
||||||
|
|
||||||
|
const Header = () => (
|
||||||
|
<div className="HelpDialog--header">
|
||||||
|
<a
|
||||||
|
className="HelpDialog--btn"
|
||||||
|
href="https://github.com/excalidraw/excalidraw#documentation"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("helpDialog.documentation")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="HelpDialog--btn"
|
||||||
|
href="https://blog.excalidraw.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("helpDialog.blog")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="HelpDialog--btn"
|
||||||
|
href="https://github.com/excalidraw/excalidraw/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("helpDialog.github")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Section = (props: { title: string; children: React.ReactNode }) => (
|
||||||
|
<>
|
||||||
|
<h3>{props.title}</h3>
|
||||||
|
{props.children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Columns = (props: { children: React.ReactNode }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Column = (props: { children: React.ReactNode }) => (
|
||||||
|
<div style={{ width: "49%" }}>{props.children}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ShortcutIsland = (props: {
|
||||||
|
caption: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div className="HelpDialog--island">
|
||||||
|
<h3 className="HelpDialog--island-title">{props.caption}</h3>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Shortcut = (props: {
|
||||||
|
label: string;
|
||||||
|
shortcuts: string[];
|
||||||
|
isOr: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="HelpDialog--shortcut">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
margin: "0",
|
||||||
|
padding: "4px 8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
marginInlineStart: "auto",
|
||||||
|
minWidth: "30%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.shortcuts.map((shortcut, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<ShortcutKey>{shortcut}</ShortcutKey>
|
||||||
|
{props.isOr &&
|
||||||
|
index !== props.shortcuts.length - 1 &&
|
||||||
|
t("helpDialog.or")}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Shortcut.defaultProps = {
|
||||||
|
isOr: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShortcutKey = (props: { children: React.ReactNode }) => (
|
||||||
|
<kbd className="HelpDialog--key" {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
title={t("helpDialog.title")}
|
||||||
|
className={"HelpDialog"}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<Section title={t("helpDialog.shortcuts")}>
|
||||||
|
<Columns>
|
||||||
|
<Column>
|
||||||
|
<ShortcutIsland caption={t("helpDialog.shapes")}>
|
||||||
|
<Shortcut
|
||||||
|
label={t("toolBar.selection")}
|
||||||
|
shortcuts={["V", "1"]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("toolBar.rectangle")}
|
||||||
|
shortcuts={["R", "2"]}
|
||||||
|
/>
|
||||||
|
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||||
|
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
||||||
|
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||||
|
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||||
|
<Shortcut
|
||||||
|
label={t("toolBar.draw")}
|
||||||
|
shortcuts={["Shift+P", "7"]}
|
||||||
|
/>
|
||||||
|
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.textNewLine")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey("Enter"),
|
||||||
|
getShortcutKey("Shift+Enter"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.textFinish")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey("Esc"),
|
||||||
|
getShortcutKey("CtrlOrCmd+Enter"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.curvedArrow")}
|
||||||
|
shortcuts={[
|
||||||
|
"A",
|
||||||
|
t("helpDialog.click"),
|
||||||
|
t("helpDialog.click"),
|
||||||
|
t("helpDialog.click"),
|
||||||
|
]}
|
||||||
|
isOr={false}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.curvedLine")}
|
||||||
|
shortcuts={[
|
||||||
|
"L",
|
||||||
|
t("helpDialog.click"),
|
||||||
|
t("helpDialog.click"),
|
||||||
|
t("helpDialog.click"),
|
||||||
|
]}
|
||||||
|
isOr={false}
|
||||||
|
/>
|
||||||
|
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.preventBinding")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||||
|
/>
|
||||||
|
</ShortcutIsland>
|
||||||
|
<ShortcutIsland caption={t("helpDialog.view")}>
|
||||||
|
<Shortcut
|
||||||
|
label={t("buttons.zoomIn")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("buttons.zoomOut")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("buttons.resetZoom")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.zoomToFit")}
|
||||||
|
shortcuts={["Shift+1"]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("helpDialog.zoomToSelection")}
|
||||||
|
shortcuts={["Shift+2"]}
|
||||||
|
/>
|
||||||
|
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
|
||||||
|
<Shortcut
|
||||||
|
label={t("buttons.zenMode")}
|
||||||
|
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.gridMode")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.viewMode")}
|
||||||
|
shortcuts={[getShortcutKey("Alt+R")]}
|
||||||
|
/>
|
||||||
|
</ShortcutIsland>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<ShortcutIsland caption={t("helpDialog.editor")}>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.selectAll")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.multiSelect")}
|
||||||
|
shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.moveCanvas")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey(`Space+${t("helpDialog.drag")}`),
|
||||||
|
getShortcutKey(`Wheel+${t("helpDialog.drag")}`),
|
||||||
|
]}
|
||||||
|
isOr={true}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.cut")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.copy")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.paste")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.copyAsPng")}
|
||||||
|
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.copyStyles")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.pasteStyles")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.delete")}
|
||||||
|
shortcuts={[getShortcutKey("Del")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.sendToBack")}
|
||||||
|
shortcuts={[
|
||||||
|
isDarwin
|
||||||
|
? getShortcutKey("CtrlOrCmd+Alt+[")
|
||||||
|
: getShortcutKey("CtrlOrCmd+Shift+["),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.bringToFront")}
|
||||||
|
shortcuts={[
|
||||||
|
isDarwin
|
||||||
|
? getShortcutKey("CtrlOrCmd+Alt+]")
|
||||||
|
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.sendBackward")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.bringForward")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.alignTop")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.alignBottom")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.alignLeft")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.alignRight")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.duplicateSelection")}
|
||||||
|
shortcuts={[
|
||||||
|
getShortcutKey("CtrlOrCmd+D"),
|
||||||
|
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("buttons.undo")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("buttons.redo")}
|
||||||
|
shortcuts={
|
||||||
|
isWindows
|
||||||
|
? [
|
||||||
|
getShortcutKey("CtrlOrCmd+Y"),
|
||||||
|
getShortcutKey("CtrlOrCmd+Shift+Z"),
|
||||||
|
]
|
||||||
|
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.group")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
|
||||||
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.ungroup")}
|
||||||
|
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
||||||
|
/>
|
||||||
|
</ShortcutIsland>
|
||||||
|
</Column>
|
||||||
|
</Columns>
|
||||||
|
</Section>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { questionCircle } from "../components/icons";
|
||||||
|
|
||||||
type HelpIconProps = {
|
type HelpIconProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -7,19 +8,8 @@ type HelpIconProps = {
|
|||||||
onClick?(): void;
|
onClick?(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICON = (
|
|
||||||
<svg
|
|
||||||
width="30"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path d="M528 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h480c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM128 180v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm-336 96v-40c0-6.627-5.373-12-12-12H76c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12zm288 0v-40c0-6.627-5.373-12-12-12H172c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h232c6.627 0 12-5.373 12-12zm96 0v-40c0-6.627-5.373-12-12-12h-40c-6.627 0-12 5.373-12 12v40c0 6.627 5.373 12 12 12h40c6.627 0 12-5.373 12-12z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const HelpIcon = (props: HelpIconProps) => (
|
export const HelpIcon = (props: HelpIconProps) => (
|
||||||
<label title={`${props.title} — ?`} className="help-icon">
|
<label title={`${props.title} — ?`} className="help-icon">
|
||||||
<div onClick={props.onClick}>{ICON}</div>
|
<div onClick={props.onClick}>{questionCircle}</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
// this is loosely based on the longest hint text
|
||||||
|
$wide-viewport-width: 1000px;
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.HintViewer {
|
.HintViewer {
|
||||||
@@ -16,12 +19,9 @@
|
|||||||
color: $oc-gray-6;
|
color: $oc-gray-6;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media #{$is-mobile-query} {
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
position: static;
|
position: static;
|
||||||
|
padding-right: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ const getHints = ({ appState, elements }: Hint) => {
|
|||||||
selectedElements.length === 1
|
selectedElements.length === 1
|
||||||
) {
|
) {
|
||||||
const targetElement = selectedElements[0];
|
const targetElement = selectedElements[0];
|
||||||
if (isLinearElement(targetElement) && targetElement.points.length > 2) {
|
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||||
return null;
|
return t("hints.lockAngle");
|
||||||
}
|
}
|
||||||
return t("hints.resize");
|
return t("hints.resize");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.picker-container {
|
.picker-container {
|
||||||
@@ -93,12 +93,16 @@
|
|||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
|
color: var(--keybinding-color);
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
right: 2px;
|
right: 2px;
|
||||||
@@ -107,7 +111,7 @@
|
|||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
}
|
}
|
||||||
@media #{$media-query} {
|
@media #{$is-mobile-query} {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { setLanguageFirstTime } from "../i18n";
|
import {
|
||||||
|
defaultLang,
|
||||||
|
Language,
|
||||||
|
languages,
|
||||||
|
setLanguageFirstTime,
|
||||||
|
} from "../i18n";
|
||||||
|
|
||||||
export class InitializeApp extends React.Component<
|
interface Props {
|
||||||
any,
|
langCode: Language["code"];
|
||||||
{ isLoading: boolean }
|
}
|
||||||
> {
|
interface State {
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
export class InitializeApp extends React.Component<Props, State> {
|
||||||
public state: { isLoading: boolean } = {
|
public state: { isLoading: boolean } = {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
async componentDidMount() {
|
async componentDidMount() {
|
||||||
await setLanguageFirstTime();
|
const currentLang =
|
||||||
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
|
defaultLang;
|
||||||
|
await setLanguageFirstTime(currentLang);
|
||||||
this.setState({
|
this.setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
background-color: var(--bg-color-island);
|
background-color: var(--bg-color-island);
|
||||||
backdrop-filter: saturate(100%) blur(10px);
|
backdrop-filter: saturate(100%) blur(10px);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: var(--border-radius-m);
|
border-radius: 4px;
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
transition: box-shadow 0.5s ease-in-out;
|
||||||
|
|||||||
@@ -7,11 +7,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.browse-libraries {
|
.layer-ui__library-header {
|
||||||
position: absolute;
|
display: flex;
|
||||||
right: 12px;
|
align-items: center;
|
||||||
top: 16px;
|
width: 100%;
|
||||||
white-space: nowrap;
|
margin: 2px 0;
|
||||||
|
|
||||||
|
button {
|
||||||
|
// 2px from the left to account for focus border of left-most button
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: auto;
|
||||||
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
|
padding-right: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +38,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layer-ui__wrapper {
|
.layer-ui__wrapper {
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
|
||||||
.encrypted-icon {
|
.encrypted-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-inline-start: 15px;
|
margin-inline-start: 15px;
|
||||||
@@ -39,64 +53,6 @@
|
|||||||
width: 1.2rem;
|
width: 1.2rem;
|
||||||
height: 1.2rem;
|
height: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tooltip .tooltip-text {
|
|
||||||
visibility: hidden;
|
|
||||||
width: 20rem;
|
|
||||||
bottom: calc(50% + 0.8rem + 6px);
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
background-color: $oc-black;
|
|
||||||
color: $oc-white;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 5px;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
--size: 6px;
|
|
||||||
content: "";
|
|
||||||
border: var(--size) solid transparent;
|
|
||||||
border-top-color: $oc-black;
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(-2 * var(--size));
|
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
|
||||||
left: calc(5px + var(--size) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
|
||||||
right: calc(5px + var(--size) / 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
|
||||||
// the cursor) when you drag over when you draw on canvas, but at the same
|
|
||||||
// time it still works when clicking on the link/shield
|
|
||||||
|
|
||||||
body:active &.tooltip:not(:hover) {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(:active) &.tooltip:hover .tooltip-text {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-text:hover {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__github-corner {
|
&__github-corner {
|
||||||
|
|||||||
@@ -1,56 +1,46 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
import React, {
|
import React, {
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
RefObject,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { showSelectedShapeActions } from "../element";
|
|
||||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
|
||||||
import { exportCanvas } from "../data";
|
|
||||||
|
|
||||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
|
||||||
|
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { Island } from "./Island";
|
import { CLASSES } from "../constants";
|
||||||
import Stack from "./Stack";
|
import { exportCanvas } from "../data";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
import { UserList } from "./UserList";
|
import { Library } from "../data/library";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { ExportDialog, ExportCB } from "./ExportDialog";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { LanguageList } from "./LanguageList";
|
import { Language, t } from "../i18n";
|
||||||
import { t, languages, setLanguage } from "../i18n";
|
|
||||||
import { HintViewer } from "./HintViewer";
|
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||||
import { ExportType } from "../scene/types";
|
import { ExportType } from "../scene/types";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { AppState, LibraryItem, LibraryItems } from "../types";
|
||||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import { muteFSAbortError } from "../utils";
|
||||||
import { Section } from "./Section";
|
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||||
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { ErrorDialog } from "./ErrorDialog";
|
import { ErrorDialog } from "./ErrorDialog";
|
||||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
import { ExportCB, ExportDialog } from "./ExportDialog";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { CLASSES } from "../constants";
|
|
||||||
import { shield, exportFile, load } from "./icons";
|
|
||||||
import { GitHubCorner } from "./GitHubCorner";
|
import { GitHubCorner } from "./GitHubCorner";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { HintViewer } from "./HintViewer";
|
||||||
|
import { exportFile, load, shield } from "./icons";
|
||||||
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
|
import { LockIcon } from "./LockIcon";
|
||||||
|
import { MobileMenu } from "./MobileMenu";
|
||||||
|
import { PasteChartDialog } from "./PasteChartDialog";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
import { HelpDialog } from "./HelpDialog";
|
||||||
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { muteFSAbortError } from "../utils";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import { Library } from "../data/library";
|
|
||||||
import {
|
|
||||||
EVENT_ACTION,
|
|
||||||
EVENT_EXIT,
|
|
||||||
EVENT_LIBRARY,
|
|
||||||
trackEvent,
|
|
||||||
} from "../analytics";
|
|
||||||
|
|
||||||
interface LayerUIProps {
|
interface LayerUIProps {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
@@ -60,11 +50,18 @@ interface LayerUIProps {
|
|||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onCollabButtonClick?: () => void;
|
onCollabButtonClick?: () => void;
|
||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
lng: string;
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
|
onExportToBackend?: (
|
||||||
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
canvas: HTMLCanvasElement | null,
|
||||||
|
) => void;
|
||||||
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
|
viewModeEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
@@ -118,59 +115,45 @@ const LibraryMenuItems = ({
|
|||||||
let addedPendingElements = false;
|
let addedPendingElements = false;
|
||||||
|
|
||||||
rows.push(
|
rows.push(
|
||||||
<>
|
<div className="layer-ui__library-header">
|
||||||
<a
|
<ToolButton
|
||||||
className="browse-libraries"
|
key="import"
|
||||||
href="https://libraries.excalidraw.com"
|
type="button"
|
||||||
target="_excalidraw_libraries"
|
title={t("buttons.load")}
|
||||||
|
aria-label={t("buttons.load")}
|
||||||
|
icon={load}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_EXIT, "libraries");
|
importLibraryFromJSON()
|
||||||
|
.then(() => {
|
||||||
|
// Maybe we should close and open the menu so that the items get updated.
|
||||||
|
// But for now we just close the menu.
|
||||||
|
setAppState({ isLibraryOpen: false });
|
||||||
|
})
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
|
<ToolButton
|
||||||
|
key="export"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.export")}
|
||||||
|
aria-label={t("buttons.export")}
|
||||||
|
icon={exportFile}
|
||||||
|
onClick={() => {
|
||||||
|
saveLibraryAsJSON()
|
||||||
|
.catch(muteFSAbortError)
|
||||||
|
.catch((error) => {
|
||||||
|
setAppState({ errorMessage: error.message });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
|
||||||
{t("labels.libraries")}
|
{t("labels.libraries")}
|
||||||
</a>
|
</a>
|
||||||
|
</div>,
|
||||||
<Stack.Row
|
|
||||||
align="center"
|
|
||||||
gap={1}
|
|
||||||
key={"actions"}
|
|
||||||
style={{ padding: "2px" }}
|
|
||||||
>
|
|
||||||
<ToolButton
|
|
||||||
key="import"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.load")}
|
|
||||||
aria-label={t("buttons.load")}
|
|
||||||
icon={load}
|
|
||||||
onClick={() => {
|
|
||||||
importLibraryFromJSON()
|
|
||||||
.then(() => {
|
|
||||||
// Maybe we should close and open the menu so that the items get updated.
|
|
||||||
// But for now we just close the menu.
|
|
||||||
setAppState({ isLibraryOpen: false });
|
|
||||||
})
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ToolButton
|
|
||||||
key="export"
|
|
||||||
type="button"
|
|
||||||
title={t("buttons.export")}
|
|
||||||
aria-label={t("buttons.export")}
|
|
||||||
icon={exportFile}
|
|
||||||
onClick={() => {
|
|
||||||
saveLibraryAsJSON()
|
|
||||||
.catch(muteFSAbortError)
|
|
||||||
.catch((error) => {
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
|
||||||
</>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let row = 0; row < numRows; row++) {
|
for (let row = 0; row < numRows; row++) {
|
||||||
@@ -269,7 +252,6 @@ const LibraryMenu = ({
|
|||||||
const items = await Library.loadLibrary();
|
const items = await Library.loadLibrary();
|
||||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||||
Library.saveLibrary(nextItems);
|
Library.saveLibrary(nextItems);
|
||||||
trackEvent(EVENT_LIBRARY, "remove");
|
|
||||||
setLibraryItems(nextItems);
|
setLibraryItems(nextItems);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -278,7 +260,6 @@ const LibraryMenu = ({
|
|||||||
const items = await Library.loadLibrary();
|
const items = await Library.loadLibrary();
|
||||||
const nextItems = [...items, elements];
|
const nextItems = [...items, elements];
|
||||||
onAddToLibrary();
|
onAddToLibrary();
|
||||||
trackEvent(EVENT_LIBRARY, "add");
|
|
||||||
Library.saveLibrary(nextItems);
|
Library.saveLibrary(nextItems);
|
||||||
setLibraryItems(nextItems);
|
setLibraryItems(nextItems);
|
||||||
},
|
},
|
||||||
@@ -313,14 +294,16 @@ const LayerUI = ({
|
|||||||
elements,
|
elements,
|
||||||
onCollabButtonClick,
|
onCollabButtonClick,
|
||||||
onLockToggle,
|
onLockToggle,
|
||||||
onInsertShape,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
|
onExportToBackend,
|
||||||
|
renderCustomFooter,
|
||||||
|
viewModeEnabled,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// TODO: Extend tooltip component and use here.
|
|
||||||
const renderEncryptedIcon = () => (
|
const renderEncryptedIcon = () => (
|
||||||
<a
|
<a
|
||||||
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
|
||||||
@@ -329,14 +312,10 @@ const LayerUI = ({
|
|||||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "e2ee shield");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="tooltip-text" dir="auto">
|
<Tooltip label={t("encrypted.tooltip")} position="above" long={true}>
|
||||||
{t("encrypted.tooltip")}
|
{shield}
|
||||||
</span>
|
</Tooltip>
|
||||||
{shield}
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -360,6 +339,7 @@ const LayerUI = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -368,32 +348,40 @@ const LayerUI = ({
|
|||||||
onExportToPng={createExporter("png")}
|
onExportToPng={createExporter("png")}
|
||||||
onExportToSvg={createExporter("svg")}
|
onExportToSvg={createExporter("svg")}
|
||||||
onExportToClipboard={createExporter("clipboard")}
|
onExportToClipboard={createExporter("clipboard")}
|
||||||
onExportToBackend={async (exportedElements) => {
|
onExportToBackend={
|
||||||
if (canvas) {
|
onExportToBackend
|
||||||
try {
|
? (elements) => {
|
||||||
await exportCanvas(
|
onExportToBackend &&
|
||||||
"backend",
|
onExportToBackend(elements, appState, canvas);
|
||||||
exportedElements,
|
|
||||||
{
|
|
||||||
...appState,
|
|
||||||
selectedElementIds: {},
|
|
||||||
},
|
|
||||||
canvas,
|
|
||||||
appState,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== "AbortError") {
|
|
||||||
const { width, height } = canvas;
|
|
||||||
console.error(error, { width, height });
|
|
||||||
setAppState({ errorMessage: error.message });
|
|
||||||
}
|
}
|
||||||
}
|
: undefined
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
@@ -465,7 +453,7 @@ const LayerUI = ({
|
|||||||
<LibraryMenu
|
<LibraryMenu
|
||||||
pendingElements={getSelectedElements(elements, appState)}
|
pendingElements={getSelectedElements(elements, appState)}
|
||||||
onClickOutside={closeLibrary}
|
onClickOutside={closeLibrary}
|
||||||
onInsertShape={onInsertShape}
|
onInsertShape={onInsertElements}
|
||||||
onAddToLibrary={deselectItems}
|
onAddToLibrary={deselectItems}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
@@ -484,38 +472,42 @@ const LayerUI = ({
|
|||||||
gap={4}
|
gap={4}
|
||||||
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
|
||||||
>
|
>
|
||||||
{renderCanvasActions()}
|
{viewModeEnabled
|
||||||
|
? renderViewModeCanvasActions()
|
||||||
|
: renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
<Section heading="shapes">
|
{!viewModeEnabled && (
|
||||||
{(heading) => (
|
<Section heading="shapes">
|
||||||
<Stack.Col gap={4} align="start">
|
{(heading) => (
|
||||||
<Stack.Row gap={1}>
|
<Stack.Col gap={4} align="start">
|
||||||
<Island
|
<Stack.Row gap={1}>
|
||||||
padding={1}
|
<Island
|
||||||
className={clsx({ "zen-mode": zenModeEnabled })}
|
padding={1}
|
||||||
>
|
className={clsx({ "zen-mode": zenModeEnabled })}
|
||||||
<HintViewer appState={appState} elements={elements} />
|
>
|
||||||
{heading}
|
<HintViewer appState={appState} elements={elements} />
|
||||||
<Stack.Row gap={1}>
|
{heading}
|
||||||
<ShapesSwitcher
|
<Stack.Row gap={1}>
|
||||||
elementType={appState.elementType}
|
<ShapesSwitcher
|
||||||
setAppState={setAppState}
|
elementType={appState.elementType}
|
||||||
isLibraryOpen={appState.isLibraryOpen}
|
setAppState={setAppState}
|
||||||
/>
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
</Stack.Row>
|
/>
|
||||||
</Island>
|
</Stack.Row>
|
||||||
<LockIcon
|
</Island>
|
||||||
zenModeEnabled={zenModeEnabled}
|
<LockIcon
|
||||||
checked={appState.elementLocked}
|
zenModeEnabled={zenModeEnabled}
|
||||||
onChange={onLockToggle}
|
checked={appState.elementLocked}
|
||||||
title={t("toolBar.lock")}
|
onChange={onLockToggle}
|
||||||
/>
|
title={t("toolBar.lock")}
|
||||||
</Stack.Row>
|
/>
|
||||||
{libraryMenu}
|
</Stack.Row>
|
||||||
</Stack.Col>
|
{libraryMenu}
|
||||||
)}
|
</Stack.Col>
|
||||||
</Section>
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
<UserList
|
<UserList
|
||||||
className={clsx("zen-mode-transition", {
|
className={clsx("zen-mode-transition", {
|
||||||
"transition-right": zenModeEnabled,
|
"transition-right": zenModeEnabled,
|
||||||
@@ -560,6 +552,20 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderGitHubCorner = () => {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={clsx(
|
||||||
|
"layer-ui__wrapper__github-corner zen-mode-transition",
|
||||||
|
{
|
||||||
|
"transition-right": zenModeEnabled,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GitHubCorner appearance={appState.appearance} />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
const renderFooter = () => (
|
const renderFooter = () => (
|
||||||
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
<footer role="contentinfo" className="layer-ui__wrapper__footer">
|
||||||
<div
|
<div
|
||||||
@@ -567,14 +573,7 @@ const LayerUI = ({
|
|||||||
"transition-right disable-pointerEvents": zenModeEnabled,
|
"transition-right disable-pointerEvents": zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LanguageList
|
{renderCustomFooter?.(false)}
|
||||||
onChange={async (lng) => {
|
|
||||||
await setLanguage(lng);
|
|
||||||
setAppState({});
|
|
||||||
}}
|
|
||||||
languages={languages}
|
|
||||||
floating
|
|
||||||
/>
|
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -589,7 +588,6 @@ const LayerUI = ({
|
|||||||
<button
|
<button
|
||||||
className="scroll-back-to-content"
|
className="scroll-back-to-content"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
trackEvent(EVENT_ACTION, "scroll to content");
|
|
||||||
setAppState({
|
setAppState({
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
});
|
});
|
||||||
@@ -601,21 +599,8 @@ const LayerUI = ({
|
|||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isMobile ? (
|
const dialogs = (
|
||||||
<MobileMenu
|
<>
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
actionManager={actionManager}
|
|
||||||
libraryMenu={libraryMenu}
|
|
||||||
exportButton={renderExportDialog()}
|
|
||||||
setAppState={setAppState}
|
|
||||||
onCollabButtonClick={onCollabButtonClick}
|
|
||||||
onLockToggle={onLockToggle}
|
|
||||||
canvas={canvas}
|
|
||||||
isCollaborating={isCollaborating}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="layer-ui__wrapper">
|
|
||||||
{appState.isLoading && <LoadingMessage />}
|
{appState.isLoading && <LoadingMessage />}
|
||||||
{appState.errorMessage && (
|
{appState.errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
@@ -623,25 +608,55 @@ const LayerUI = ({
|
|||||||
onClose={() => setAppState({ errorMessage: null })}
|
onClose={() => setAppState({ errorMessage: null })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{appState.showShortcutsDialog && (
|
{appState.showHelpDialog && (
|
||||||
<ShortcutsDialog
|
<HelpDialog onClose={() => setAppState({ showHelpDialog: false })} />
|
||||||
onClose={() => setAppState({ showShortcutsDialog: false })}
|
)}
|
||||||
|
{appState.pasteDialog.shown && (
|
||||||
|
<PasteChartDialog
|
||||||
|
setAppState={setAppState}
|
||||||
|
appState={appState}
|
||||||
|
onInsertChart={onInsertElements}
|
||||||
|
onClose={() =>
|
||||||
|
setAppState({
|
||||||
|
pasteDialog: { shown: false, data: null },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return isMobile ? (
|
||||||
|
<>
|
||||||
|
{dialogs}
|
||||||
|
<MobileMenu
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
actionManager={actionManager}
|
||||||
|
libraryMenu={libraryMenu}
|
||||||
|
exportButton={renderExportDialog()}
|
||||||
|
setAppState={setAppState}
|
||||||
|
onCollabButtonClick={onCollabButtonClick}
|
||||||
|
onLockToggle={onLockToggle}
|
||||||
|
canvas={canvas}
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
renderCustomFooter={renderCustomFooter}
|
||||||
|
viewModeEnabled={viewModeEnabled}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={clsx("layer-ui__wrapper", {
|
||||||
|
"disable-pointerEvents":
|
||||||
|
appState.draggingElement ||
|
||||||
|
appState.resizingElement ||
|
||||||
|
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{dialogs}
|
||||||
{renderFixedSideContainer()}
|
{renderFixedSideContainer()}
|
||||||
{renderBottomAppMenu()}
|
{renderBottomAppMenu()}
|
||||||
{
|
{renderGitHubCorner()}
|
||||||
<aside
|
|
||||||
className={clsx(
|
|
||||||
"layer-ui__wrapper__github-corner zen-mode-transition",
|
|
||||||
{
|
|
||||||
"transition-right": zenModeEnabled,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<GitHubCorner appearance={appState.appearance} />
|
|
||||||
</aside>
|
|
||||||
}
|
|
||||||
{renderFooter()}
|
{renderFooter()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -650,8 +665,6 @@ const LayerUI = ({
|
|||||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||||
const {
|
const {
|
||||||
cursorX,
|
|
||||||
cursorY,
|
|
||||||
suggestedBindings,
|
suggestedBindings,
|
||||||
startBoundElement: boundElement,
|
startBoundElement: boundElement,
|
||||||
...ret
|
...ret
|
||||||
@@ -662,9 +675,8 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
|||||||
const nextAppState = getNecessaryObj(next.appState);
|
const nextAppState = getNecessaryObj(next.appState);
|
||||||
|
|
||||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prev.lng === next.lng &&
|
prev.langCode === next.langCode &&
|
||||||
prev.elements === next.elements &&
|
prev.elements === next.elements &&
|
||||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { exportToSvg } from "../scene/export";
|
import oc from "open-color";
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { close } from "../components/icons";
|
import { close } from "../components/icons";
|
||||||
|
import { MIME_TYPES } from "../constants";
|
||||||
import "./LibraryUnit.scss";
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
|
import { exportToSvg } from "../scene/export";
|
||||||
import { LibraryItem } from "../types";
|
import { LibraryItem } from "../types";
|
||||||
import { MIME_TYPES } from "../constants";
|
import "./LibraryUnit.scss";
|
||||||
|
|
||||||
// fa-plus
|
// fa-plus
|
||||||
const PLUS_ICON = (
|
const PLUS_ICON = (
|
||||||
@@ -38,7 +38,7 @@ export const LibraryUnit = ({
|
|||||||
}
|
}
|
||||||
const svg = exportToSvg(elementsToRender, {
|
const svg = exportToSvg(elementsToRender, {
|
||||||
exportBackground: false,
|
exportBackground: false,
|
||||||
viewBackgroundColor: "#fff",
|
viewBackgroundColor: oc.white,
|
||||||
shouldAddWatermark: false,
|
shouldAddWatermark: false,
|
||||||
});
|
});
|
||||||
for (const child of ref.current!.children) {
|
for (const child of ref.current!.children) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { t, setLanguage } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { LanguageList } from "./LanguageList";
|
|
||||||
import { showSelectedShapeActions } from "../element";
|
import { showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
@@ -15,10 +14,8 @@ import { Section } from "./Section";
|
|||||||
import CollabButton from "./CollabButton";
|
import CollabButton from "./CollabButton";
|
||||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||||
import { LockIcon } from "./LockIcon";
|
import { LockIcon } from "./LockIcon";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||||
import { EVENT_ACTION, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
type MobileMenuProps = {
|
type MobileMenuProps = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -31,6 +28,8 @@ type MobileMenuProps = {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
|
viewModeEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -44,131 +43,165 @@ export const MobileMenu = ({
|
|||||||
onLockToggle,
|
onLockToggle,
|
||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
}: MobileMenuProps) => (
|
renderCustomFooter,
|
||||||
<>
|
viewModeEnabled,
|
||||||
{appState.isLoading && <LoadingMessage />}
|
}: MobileMenuProps) => {
|
||||||
<FixedSideContainer side="top">
|
const renderToolbar = () => {
|
||||||
<Section heading="shapes">
|
return (
|
||||||
{(heading) => (
|
<FixedSideContainer side="top" className="App-top-bar">
|
||||||
<Stack.Col gap={4} align="center">
|
<Section heading="shapes">
|
||||||
<Stack.Row gap={1}>
|
{(heading) => (
|
||||||
<Island padding={1}>
|
<Stack.Col gap={4} align="center">
|
||||||
{heading}
|
<Stack.Row gap={1}>
|
||||||
<Stack.Row gap={1}>
|
<Island padding={1}>
|
||||||
<ShapesSwitcher
|
{heading}
|
||||||
elementType={appState.elementType}
|
<Stack.Row gap={1}>
|
||||||
setAppState={setAppState}
|
<ShapesSwitcher
|
||||||
isLibraryOpen={appState.isLibraryOpen}
|
elementType={appState.elementType}
|
||||||
/>
|
setAppState={setAppState}
|
||||||
</Stack.Row>
|
isLibraryOpen={appState.isLibraryOpen}
|
||||||
</Island>
|
/>
|
||||||
<LockIcon
|
</Stack.Row>
|
||||||
checked={appState.elementLocked}
|
</Island>
|
||||||
onChange={onLockToggle}
|
<LockIcon
|
||||||
title={t("toolBar.lock")}
|
checked={appState.elementLocked}
|
||||||
/>
|
onChange={onLockToggle}
|
||||||
</Stack.Row>
|
title={t("toolBar.lock")}
|
||||||
{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}
|
|
||||||
/>
|
/>
|
||||||
<fieldset>
|
</Stack.Row>
|
||||||
<legend>{t("labels.language")}</legend>
|
{libraryMenu}
|
||||||
<LanguageList
|
</Stack.Col>
|
||||||
onChange={async (lng) => {
|
|
||||||
await setLanguage(lng);
|
|
||||||
setAppState({});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset>
|
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
|
||||||
<UserList mobile>
|
|
||||||
{Array.from(appState.collaborators)
|
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
|
||||||
.map(([clientId, client]) => (
|
|
||||||
<React.Fragment key={clientId}>
|
|
||||||
{actionManager.renderAction(
|
|
||||||
"goToCollaborator",
|
|
||||||
clientId,
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</UserList>
|
|
||||||
</fieldset>
|
|
||||||
</Stack.Col>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
) : appState.openMenu === "shape" &&
|
|
||||||
showSelectedShapeActions(appState, elements) ? (
|
|
||||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
|
||||||
<SelectedShapeActions
|
|
||||||
appState={appState}
|
|
||||||
elements={elements}
|
|
||||||
renderAction={actionManager.renderAction}
|
|
||||||
elementType={appState.elementType}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
) : null}
|
|
||||||
<footer className="App-toolbar">
|
|
||||||
<div className="App-toolbar-content">
|
|
||||||
{actionManager.renderAction("toggleCanvasMenu")}
|
|
||||||
{actionManager.renderAction("toggleEditMenu")}
|
|
||||||
{actionManager.renderAction("undo")}
|
|
||||||
{actionManager.renderAction("redo")}
|
|
||||||
{actionManager.renderAction(
|
|
||||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
|
||||||
)}
|
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
|
||||||
</div>
|
|
||||||
{appState.scrolledOutside && !appState.openMenu && (
|
|
||||||
<button
|
|
||||||
className="scroll-back-to-content"
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_ACTION, "scroll to content");
|
|
||||||
setAppState({
|
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("buttons.scrollBackToContent")}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</footer>
|
</Section>
|
||||||
</Island>
|
<HintViewer appState={appState} elements={elements} />
|
||||||
</div>
|
</FixedSideContainer>
|
||||||
</>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const renderAppToolbar = () => {
|
||||||
|
if (viewModeEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="App-toolbar-content">
|
||||||
|
{actionManager.renderAction("toggleCanvasMenu")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="App-toolbar-content">
|
||||||
|
{actionManager.renderAction("toggleCanvasMenu")}
|
||||||
|
{actionManager.renderAction("toggleEditMenu")}
|
||||||
|
{actionManager.renderAction("undo")}
|
||||||
|
{actionManager.renderAction("redo")}
|
||||||
|
{actionManager.renderAction(
|
||||||
|
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||||
|
)}
|
||||||
|
{actionManager.renderAction("deleteSelectedElements")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCanvasActions = () => {
|
||||||
|
if (viewModeEnabled) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{actionManager.renderAction("saveScene")}
|
||||||
|
{actionManager.renderAction("saveAsScene")}
|
||||||
|
{exportButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{actionManager.renderAction("loadScene")}
|
||||||
|
{actionManager.renderAction("saveScene")}
|
||||||
|
{actionManager.renderAction("saveAsScene")}
|
||||||
|
{exportButton}
|
||||||
|
{actionManager.renderAction("clearCanvas")}
|
||||||
|
{onCollabButtonClick && (
|
||||||
|
<CollabButton
|
||||||
|
isCollaborating={isCollaborating}
|
||||||
|
collaboratorCount={appState.collaborators.size}
|
||||||
|
onClick={onCollabButtonClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{
|
||||||
|
<BackgroundPickerAndDarkModeToggle
|
||||||
|
actionManager={actionManager}
|
||||||
|
appState={appState}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!viewModeEnabled && renderToolbar()}
|
||||||
|
<div
|
||||||
|
className="App-bottom-bar"
|
||||||
|
style={{
|
||||||
|
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||||
|
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||||
|
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Island padding={0}>
|
||||||
|
{appState.openMenu === "canvas" ? (
|
||||||
|
<Section className="App-mobile-menu" heading="canvasActions">
|
||||||
|
<div className="panelColumn">
|
||||||
|
<Stack.Col gap={4}>
|
||||||
|
{renderCanvasActions()}
|
||||||
|
{renderCustomFooter?.(true)}
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
|
<UserList mobile>
|
||||||
|
{Array.from(appState.collaborators)
|
||||||
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
|
.filter(
|
||||||
|
([_, client]) => Object.keys(client).length !== 0,
|
||||||
|
)
|
||||||
|
.map(([clientId, client]) => (
|
||||||
|
<React.Fragment key={clientId}>
|
||||||
|
{actionManager.renderAction(
|
||||||
|
"goToCollaborator",
|
||||||
|
clientId,
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</UserList>
|
||||||
|
</fieldset>
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
) : appState.openMenu === "shape" &&
|
||||||
|
!viewModeEnabled &&
|
||||||
|
showSelectedShapeActions(appState, elements) ? (
|
||||||
|
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||||
|
<SelectedShapeActions
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
elementType={appState.elementType}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
<footer className="App-toolbar">
|
||||||
|
{renderAppToolbar()}
|
||||||
|
{appState.scrolledOutside && !appState.openMenu && (
|
||||||
|
<button
|
||||||
|
className="scroll-back-to-content"
|
||||||
|
onClick={() => {
|
||||||
|
setAppState({
|
||||||
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</Island>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Modal {
|
.Modal {
|
||||||
@@ -30,18 +30,26 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: var(--max-width);
|
max-width: var(--max-width);
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
// for modals, reset blurry bg
|
// for modals, reset blurry bg
|
||||||
background: var(--bg-color-island);
|
background: var(--bg-color-island);
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
|
|
||||||
@media #{$media-query} {
|
border: 1px solid var(--dialog-border);
|
||||||
|
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +76,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.Modal__close--floating {
|
@media #{$is-mobile-query} {
|
||||||
position: absolute;
|
|
||||||
right: calc(var(--space-factor) * 5);
|
|
||||||
top: calc(var(--space-factor) * 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media #{$media-query} {
|
|
||||||
.Modal {
|
.Modal {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,7 @@ export const Modal = (props: {
|
|||||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
||||||
<div
|
<div
|
||||||
className="Modal__content"
|
className="Modal__content"
|
||||||
style={{
|
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||||
"--max-width": `${props.maxWidth}px`,
|
|
||||||
maxHeight: "100%",
|
|
||||||
overflowY: "scroll",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.PasteChartDialog {
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.Island {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ChartPreview {
|
||||||
|
margin: 8px;
|
||||||
|
text-align: center;
|
||||||
|
width: 192px;
|
||||||
|
height: 128px;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px;
|
||||||
|
border: 1px solid $oc-gray-4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
div {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
svg {
|
||||||
|
max-height: 120px;
|
||||||
|
max-width: 186px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid $oc-blue-5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import oc from "open-color";
|
||||||
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { trackEvent } from "../analytics";
|
||||||
|
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
||||||
|
import { ChartType } from "../element/types";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
import { exportToSvg } from "../scene/export";
|
||||||
|
import { AppState, LibraryItem } from "../types";
|
||||||
|
import { Dialog } from "./Dialog";
|
||||||
|
import "./PasteChartDialog.scss";
|
||||||
|
|
||||||
|
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
|
||||||
|
|
||||||
|
const ChartPreviewBtn = (props: {
|
||||||
|
spreadsheet: Spreadsheet | null;
|
||||||
|
chartType: ChartType;
|
||||||
|
selected: boolean;
|
||||||
|
onClick: OnInsertChart;
|
||||||
|
}) => {
|
||||||
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [chartElements, setChartElements] = useState<ChartElements | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!props.spreadsheet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = renderSpreadsheet(
|
||||||
|
props.chartType,
|
||||||
|
props.spreadsheet,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
setChartElements(elements);
|
||||||
|
|
||||||
|
const svg = exportToSvg(elements, {
|
||||||
|
exportBackground: false,
|
||||||
|
viewBackgroundColor: oc.white,
|
||||||
|
shouldAddWatermark: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewNode = previewRef.current!;
|
||||||
|
|
||||||
|
previewNode.appendChild(svg);
|
||||||
|
|
||||||
|
if (props.selected) {
|
||||||
|
(previewNode.parentNode as HTMLDivElement).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
previewNode.removeChild(svg);
|
||||||
|
};
|
||||||
|
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="ChartPreview"
|
||||||
|
onClick={() => {
|
||||||
|
if (chartElements) {
|
||||||
|
props.onClick(props.chartType, chartElements);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={previewRef} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasteChartDialog = ({
|
||||||
|
setAppState,
|
||||||
|
appState,
|
||||||
|
onClose,
|
||||||
|
onInsertChart,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
onClose: () => void;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
onInsertChart: (elements: LibraryItem) => void;
|
||||||
|
}) => {
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
|
||||||
|
onInsertChart(elements);
|
||||||
|
trackEvent("magic", "chart", chartType);
|
||||||
|
setAppState({
|
||||||
|
currentChartType: chartType,
|
||||||
|
pasteDialog: {
|
||||||
|
shown: false,
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
small
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
title={t("labels.pasteCharts")}
|
||||||
|
className={"PasteChartDialog"}
|
||||||
|
autofocus={false}
|
||||||
|
>
|
||||||
|
<div className={"container"}>
|
||||||
|
<ChartPreviewBtn
|
||||||
|
chartType="bar"
|
||||||
|
spreadsheet={appState.pasteDialog.data}
|
||||||
|
selected={appState.currentChartType === "bar"}
|
||||||
|
onClick={handleChartClick}
|
||||||
|
/>
|
||||||
|
<ChartPreviewBtn
|
||||||
|
chartType="line"
|
||||||
|
spreadsheet={appState.pasteDialog.data}
|
||||||
|
selected={appState.currentChartType === "line"}
|
||||||
|
onClick={handleChartClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { t } from "../i18n";
|
|
||||||
import { isDarwin } from "../keys";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
import { getShortcutKey } from "../utils";
|
|
||||||
import "./ShortcutsDialog.scss";
|
|
||||||
import { EVENT_EXIT, trackEvent } from "../analytics";
|
|
||||||
|
|
||||||
const Columns = (props: { children: React.ReactNode }) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Column = (props: { children: React.ReactNode }) => (
|
|
||||||
<div style={{ width: "49%" }}>{props.children}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ShortcutIsland = (props: {
|
|
||||||
caption: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => (
|
|
||||||
<div className="ShortcutsDialog-island">
|
|
||||||
<h3 className="ShortcutsDialog-island-title">{props.caption}</h3>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Shortcut = (props: {
|
|
||||||
label: string;
|
|
||||||
shortcuts: string[];
|
|
||||||
isOr: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="ShorcutsDialog-shortcut">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
margin: "0",
|
|
||||||
padding: "4px 8px",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flex: "0 0 auto",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
marginInlineStart: "auto",
|
|
||||||
minWidth: "30%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.shortcuts.map((shortcut, index) => (
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
<ShortcutKey>{shortcut}</ShortcutKey>
|
|
||||||
{props.isOr &&
|
|
||||||
index !== props.shortcuts.length - 1 &&
|
|
||||||
t("shortcutsDialog.or")}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
Shortcut.defaultProps = {
|
|
||||||
isOr: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShortcutKey = (props: { children: React.ReactNode }) => (
|
|
||||||
<span className="ShorcutsDialog-key" {...props} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const Footer = () => (
|
|
||||||
<div className="ShortcutsDialog-footer">
|
|
||||||
<a
|
|
||||||
href="https://blog.excalidraw.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "blog");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("shortcutsDialog.blog")}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://howto.excalidraw.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "guides");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("shortcutsDialog.howto")}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/excalidraw/excalidraw/issues"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent(EVENT_EXIT, "issues");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("shortcutsDialog.github")}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => {
|
|
||||||
const handleClose = React.useCallback(() => {
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog
|
|
||||||
maxWidth={900}
|
|
||||||
onCloseRequest={handleClose}
|
|
||||||
title={t("shortcutsDialog.title")}
|
|
||||||
>
|
|
||||||
<Columns>
|
|
||||||
<Column>
|
|
||||||
<ShortcutIsland caption={t("shortcutsDialog.shapes")}>
|
|
||||||
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
|
|
||||||
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
|
|
||||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
|
||||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["E", "4"]} />
|
|
||||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
|
||||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
|
||||||
<Shortcut
|
|
||||||
label={t("toolBar.draw")}
|
|
||||||
shortcuts={["Shift+P", "7"]}
|
|
||||||
/>
|
|
||||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
|
||||||
<Shortcut
|
|
||||||
label={t("shortcutsDialog.textNewLine")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey("Enter"),
|
|
||||||
getShortcutKey("Shift+Enter"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("shortcutsDialog.textFinish")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey("Esc"),
|
|
||||||
getShortcutKey("CtrlOrCmd+Enter"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("shortcutsDialog.curvedArrow")}
|
|
||||||
shortcuts={[
|
|
||||||
"A",
|
|
||||||
t("shortcutsDialog.click"),
|
|
||||||
t("shortcutsDialog.click"),
|
|
||||||
t("shortcutsDialog.click"),
|
|
||||||
]}
|
|
||||||
isOr={false}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("shortcutsDialog.curvedLine")}
|
|
||||||
shortcuts={[
|
|
||||||
"L",
|
|
||||||
t("shortcutsDialog.click"),
|
|
||||||
t("shortcutsDialog.click"),
|
|
||||||
t("shortcutsDialog.click"),
|
|
||||||
]}
|
|
||||||
isOr={false}
|
|
||||||
/>
|
|
||||||
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
|
|
||||||
<Shortcut
|
|
||||||
label={t("shortcutsDialog.preventBinding")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
|
||||||
<ShortcutIsland caption={t("shortcutsDialog.view")}>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.zoomIn")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd++")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.zoomOut")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+-")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.resetZoom")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+0")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("shortcutsDialog.zoomToFit")}
|
|
||||||
shortcuts={["Shift+1"]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.toggleFullScreen")}
|
|
||||||
shortcuts={["F"]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.toggleZenMode")}
|
|
||||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.toggleGridMode")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
|
||||||
</Column>
|
|
||||||
<Column>
|
|
||||||
<ShortcutIsland caption={t("shortcutsDialog.editor")}>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.selectAll")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+A")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.multiSelect")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey(`Shift+${t("shortcutsDialog.click")}`),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.moveCanvas")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey(`Space+${t("shortcutsDialog.drag")}`),
|
|
||||||
getShortcutKey(`Wheel+${t("shortcutsDialog.drag")}`),
|
|
||||||
]}
|
|
||||||
isOr={true}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.cut")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+X")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.copy")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+C")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.paste")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.copyAsPng")}
|
|
||||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.copyStyles")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.pasteStyles")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.delete")}
|
|
||||||
shortcuts={[getShortcutKey("Del")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.sendToBack")}
|
|
||||||
shortcuts={[
|
|
||||||
isDarwin
|
|
||||||
? getShortcutKey("CtrlOrCmd+Alt+[")
|
|
||||||
: getShortcutKey("CtrlOrCmd+Shift+["),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.bringToFront")}
|
|
||||||
shortcuts={[
|
|
||||||
isDarwin
|
|
||||||
? getShortcutKey("CtrlOrCmd+Alt+]")
|
|
||||||
: getShortcutKey("CtrlOrCmd+Shift+]"),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.sendBackward")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+[")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.bringForward")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+]")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.alignTop")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.alignBottom")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.alignLeft")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.alignRight")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.duplicateSelection")}
|
|
||||||
shortcuts={[
|
|
||||||
getShortcutKey("CtrlOrCmd+D"),
|
|
||||||
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.undo")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("buttons.redo")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.group")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+G")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
|
||||||
label={t("labels.ungroup")}
|
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]}
|
|
||||||
/>
|
|
||||||
</ShortcutIsland>
|
|
||||||
</Column>
|
|
||||||
</Columns>
|
|
||||||
<Footer />
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.Stats {
|
.Stats {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 24px 8px 0;
|
margin: 0 24px 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
@@ -29,9 +31,21 @@
|
|||||||
}
|
}
|
||||||
tr {
|
tr {
|
||||||
td:nth-child(2) {
|
td:nth-child(2) {
|
||||||
min-width: 48px;
|
min-width: 24px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
left: 12px;
|
||||||
|
right: initial;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 8px 24px;
|
||||||
|
}
|
||||||
|
.close {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { copyTextToSystemClipboard } from "../clipboard";
|
||||||
|
import { DEFAULT_VERSION } from "../constants";
|
||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds } from "../element/bounds";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +11,7 @@ import { t } from "../i18n";
|
|||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { getTargetElements } from "../scene";
|
import { getTargetElements } from "../scene";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { debounce, nFormatter } from "../utils";
|
import { debounce, getVersion, nFormatter } from "../utils";
|
||||||
import { close } from "./icons";
|
import { close } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./Stats.scss";
|
import "./Stats.scss";
|
||||||
@@ -25,6 +27,7 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
|||||||
|
|
||||||
export const Stats = (props: {
|
export const Stats = (props: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
elements: readonly NonDeletedExcalidrawElement[];
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -50,6 +53,17 @@ export const Stats = (props: {
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<div className="Stats">
|
<div className="Stats">
|
||||||
<Island padding={2}>
|
<Island padding={2}>
|
||||||
@@ -85,7 +99,6 @@ export const Stats = (props: {
|
|||||||
<td>{t("stats.total")}</td>
|
<td>{t("stats.total")}</td>
|
||||||
<td>{nFormatter(storageSizes.total, 1)}</td>
|
<td>{nFormatter(storageSizes.total, 1)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{selectedElements.length === 1 && (
|
{selectedElements.length === 1 && (
|
||||||
<tr>
|
<tr>
|
||||||
<th colSpan={2}>{t("stats.element")}</th>
|
<th colSpan={2}>{t("stats.element")}</th>
|
||||||
@@ -157,6 +170,28 @@ export const Stats = (props: {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</Island>
|
</Island>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables.scss";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.TextInput {
|
.TextInput {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
|
.excalidraw {
|
||||||
|
.Toast {
|
||||||
|
animation: fade-in 0.5s;
|
||||||
|
background-color: var(--button-gray-1);
|
||||||
|
border-radius: 4px;
|
||||||
|
bottom: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: default;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -150px;
|
||||||
|
padding: 4px 0;
|
||||||
|
position: fixed;
|
||||||
|
text-align: center;
|
||||||
|
width: 300px;
|
||||||
|
z-index: 999999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Toast__message {
|
||||||
|
color: var(--popup-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { TOAST_TIMEOUT } from "../constants";
|
||||||
|
import "./Toast.scss";
|
||||||
|
|
||||||
|
export const Toast = ({
|
||||||
|
message,
|
||||||
|
clearToast,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
clearToast: () => void;
|
||||||
|
}) => {
|
||||||
|
const timerRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const scheduleTimeout = useCallback(
|
||||||
|
() =>
|
||||||
|
(timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)),
|
||||||
|
[clearToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scheduleTimeout();
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [scheduleTimeout, message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="Toast"
|
||||||
|
onMouseEnter={() => clearTimeout(timerRef?.current)}
|
||||||
|
onMouseLeave={scheduleTimeout}
|
||||||
|
>
|
||||||
|
<p className="Toast__message">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ToolIcon {
|
.ToolIcon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -140,6 +142,7 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shrink shape icons on small viewports to make them fit
|
||||||
@media (max-width: 425px) {
|
@media (max-width: 425px) {
|
||||||
.Shape .ToolIcon__icon {
|
.Shape .ToolIcon__icon {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@@ -151,6 +154,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// move the lock button out of the way on small viewports
|
||||||
|
// it begins to collide with the GitHub icon before we switch to mobile mode
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.ToolIcon.ToolIcon__lock {
|
.ToolIcon.ToolIcon__lock {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -160,6 +165,7 @@
|
|||||||
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
border-radius: 20px 0 0 20px;
|
border-radius: 20px 0 0 20px;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
background-color: var(--button-gray-1);
|
background-color: var(--button-gray-1);
|
||||||
|
|
||||||
@@ -181,6 +187,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.TooltipIcon {
|
||||||
|
width: 0.9em;
|
||||||
|
height: 0.9em;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-top: 1px;
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.unlocked-icon {
|
.unlocked-icon {
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
left: 2px;
|
left: 2px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Tooltip {
|
.Tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -7,39 +7,48 @@
|
|||||||
.Tooltip__label {
|
.Tooltip__label {
|
||||||
--arrow-size: 4px;
|
--arrow-size: 4px;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
width: 10ch;
|
|
||||||
background: $oc-black;
|
background: $oc-black;
|
||||||
color: $oc-white;
|
color: $oc-white;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
padding: 4px;
|
padding: 8px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
font-size: 0.7rem;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
top: calc(100% + var(--arrow-size) + 3px);
|
font-weight: 500;
|
||||||
// extra pixel offset for unknown reasons
|
// extra pixel offset for unknown reasons
|
||||||
left: calc(-50% + var(--arrow-size) / 2 - 1px);
|
left: calc(50% + var(--arrow-size) / 2 - 1px);
|
||||||
|
transform: translateX(-50%);
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
content: "";
|
||||||
border: var(--arrow-size) solid transparent;
|
border: var(--arrow-size) solid transparent;
|
||||||
border-bottom-color: $oc-black;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
|
||||||
left: calc(50% - var(--arrow-size));
|
left: calc(50% - var(--arrow-size));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--above {
|
||||||
|
bottom: calc(100% + var(--arrow-size) + 3px);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-top-color: $oc-black;
|
||||||
|
top: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--below {
|
||||||
|
top: calc(100% + var(--arrow-size) + 3px);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border-bottom-color: $oc-black;
|
||||||
|
bottom: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the following 3 rules ensure that the tooltip doesn't show (nor affect
|
.Tooltip:hover .Tooltip__label {
|
||||||
// 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;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,27 @@ import React from "react";
|
|||||||
type TooltipProps = {
|
type TooltipProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
position?: "above" | "below";
|
||||||
|
long?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Tooltip = ({ children, label }: TooltipProps) => (
|
export const Tooltip = ({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
position = "below",
|
||||||
|
long = false,
|
||||||
|
}: TooltipProps) => (
|
||||||
<div className="Tooltip">
|
<div className="Tooltip">
|
||||||
<span className="Tooltip__label">{label}</span>
|
<span
|
||||||
|
className={
|
||||||
|
position === "above"
|
||||||
|
? "Tooltip__label Tooltip__label--above"
|
||||||
|
: "Tooltip__label Tooltip__label--below"
|
||||||
|
}
|
||||||
|
style={{ width: long ? "50ch" : "10ch" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ export const redo = createIcon(
|
|||||||
{ mirror: true },
|
{ mirror: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const questionCircle = createIcon(
|
||||||
|
"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zM262.655 90c-54.497 0-89.255 22.957-116.549 63.758-3.536 5.286-2.353 12.415 2.715 16.258l34.699 26.31c5.205 3.947 12.621 3.008 16.665-2.122 17.864-22.658 30.113-35.797 57.303-35.797 20.429 0 45.698 13.148 45.698 32.958 0 14.976-12.363 22.667-32.534 33.976C247.128 238.528 216 254.941 216 296v4c0 6.627 5.373 12 12 12h56c6.627 0 12-5.373 12-12v-1.333c0-28.462 83.186-29.647 83.186-106.667 0-58.002-60.165-102-116.531-102zM256 338c-25.365 0-46 20.635-46 46 0 25.364 20.635 46 46 46s46-20.636 46-46c0-25.365-20.635-46-46-46z",
|
||||||
|
{ mirror: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Icon imported form Storybook
|
// Icon imported form Storybook
|
||||||
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
|
// Storybook is licensed under MIT https://github.com/storybookjs/storybook/blob/next/LICENSE
|
||||||
export const resetZoom = createIcon(
|
export const resetZoom = createIcon(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { FontFamily } from "./element/types";
|
import { FontFamily } from "./element/types";
|
||||||
|
|
||||||
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
export const DRAGGING_THRESHOLD = 10; // 10px
|
export const DRAGGING_THRESHOLD = 10; // 10px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
@@ -44,6 +46,7 @@ export enum EVENT {
|
|||||||
TOUCH_START = "touchstart",
|
TOUCH_START = "touchstart",
|
||||||
TOUCH_END = "touchend",
|
TOUCH_END = "touchend",
|
||||||
HASHCHANGE = "hashchange",
|
HASHCHANGE = "hashchange",
|
||||||
|
VISIBILITY_CHANGE = "visibilitychange",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENV = {
|
export const ENV = {
|
||||||
@@ -68,6 +71,7 @@ export const DEFAULT_FONT_SIZE = 20;
|
|||||||
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
export const DEFAULT_FONT_FAMILY: FontFamily = 1;
|
||||||
export const DEFAULT_TEXT_ALIGN = "left";
|
export const DEFAULT_TEXT_ALIGN = "left";
|
||||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||||
|
export const DEFAULT_VERSION = "{version}";
|
||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
@@ -85,3 +89,13 @@ export const STORAGE_KEYS = {
|
|||||||
// time in milliseconds
|
// time in milliseconds
|
||||||
export const TAP_TWICE_TIMEOUT = 300;
|
export const TAP_TWICE_TIMEOUT = 300;
|
||||||
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
export const TOUCH_CTX_MENU_TIMEOUT = 500;
|
||||||
|
export const TITLE_TIMEOUT = 10000;
|
||||||
|
export const TOAST_TIMEOUT = 5000;
|
||||||
|
export const VERSION_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const createInverseContext = <T extends unknown = null>(
|
||||||
|
initialValue: T,
|
||||||
|
) => {
|
||||||
|
const Context = React.createContext(initialValue) as React.Context<T> & {
|
||||||
|
_updateProviderValue?: (value: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InverseConsumer extends React.Component {
|
||||||
|
state = { value: initialValue };
|
||||||
|
constructor(props: any) {
|
||||||
|
super(props);
|
||||||
|
Context._updateProviderValue = (value: T) => this.setState({ value });
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Context.Provider value={this.state.value}>
|
||||||
|
{this.props.children}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InverseProvider extends React.Component<{ value: T }> {
|
||||||
|
componentDidMount() {
|
||||||
|
Context._updateProviderValue?.(this.props.value);
|
||||||
|
}
|
||||||
|
componentDidUpdate() {
|
||||||
|
Context._updateProviderValue?.(this.props.value);
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
return <Context.Consumer>{() => this.props.children}</Context.Consumer>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Context,
|
||||||
|
Consumer: InverseConsumer,
|
||||||
|
Provider: InverseProvider,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@import "open-color/open-color.scss";
|
|
||||||
|
|
||||||
$media-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
@import "./_variables";
|
@import "./variables.module";
|
||||||
@import "./theme";
|
@import "./theme";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--zIndex-canvas: 1;
|
||||||
|
--zIndex-wysiwyg: 2;
|
||||||
|
--zIndex-layerUI: 3;
|
||||||
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
color: var(--text-color-primary);
|
color: var(--text-color-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -13,7 +19,7 @@
|
|||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: $oc-blue-7; /* OC Blue 7 */
|
color: var(--link-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -30,6 +36,8 @@
|
|||||||
image-rendering: pixelated; // chromium
|
image-rendering: pixelated; // chromium
|
||||||
// NOTE: must be declared *after* the above
|
// NOTE: must be declared *after* the above
|
||||||
image-rendering: -moz-crisp-edges; // FF
|
image-rendering: -moz-crisp-edges; // FF
|
||||||
|
|
||||||
|
z-index: var(--zIndex-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.Appearance_dark {
|
&.Appearance_dark {
|
||||||
@@ -216,6 +224,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.App-top-bar {
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.App-bottom-bar {
|
.App-bottom-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -282,7 +296,7 @@
|
|||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-menu_top > * {
|
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +337,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.App-menu_bottom > * {
|
.layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +445,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
fill: $oc-gray-6;
|
fill: $oc-gray-6;
|
||||||
bottom: 14px;
|
bottom: 14px;
|
||||||
|
width: 1.5rem;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
right: 14px;
|
right: 14px;
|
||||||
@@ -441,7 +456,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$media-query} {
|
@media #{$is-mobile-query} {
|
||||||
aside {
|
aside {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -491,6 +506,13 @@
|
|||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.excalidraw--view-mode {
|
||||||
|
.App-menu {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.App-bottom-bar,
|
.App-bottom-bar,
|
||||||
.FixedSideContainer,
|
.FixedSideContainer,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-color-island: rgba(255, 255, 255, 0.9);
|
--bg-color-island: rgba(255, 255, 255, 0.9);
|
||||||
--popup-background-color: #{$oc-white};
|
--popup-background-color: #{$oc-white};
|
||||||
--border-radius-m: 4px;
|
|
||||||
--space-factor: 0.25rem;
|
--space-factor: 0.25rem;
|
||||||
--button-gray-1: #{$oc-gray-2};
|
--button-gray-1: #{$oc-gray-2};
|
||||||
--button-gray-2: #{$oc-gray-4};
|
--button-gray-2: #{$oc-gray-4};
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
--icon-fill-color: #{$oc-black};
|
--icon-fill-color: #{$oc-black};
|
||||||
--icon-green-fill-color: #{$oc-green-9};
|
--icon-green-fill-color: #{$oc-green-9};
|
||||||
--keybinding-color: #{$oc-gray-5};
|
--keybinding-color: #{$oc-gray-5};
|
||||||
--color-overlay-text-color: #ccc;
|
|
||||||
--sat: env(safe-area-inset-top);
|
--sat: env(safe-area-inset-top);
|
||||||
--sab: env(safe-area-inset-bottom);
|
--sab: env(safe-area-inset-bottom);
|
||||||
--sal: env(safe-area-inset-left);
|
--sal: env(safe-area-inset-left);
|
||||||
@@ -23,8 +21,6 @@
|
|||||||
--text-color-primary: #{$oc-gray-8};
|
--text-color-primary: #{$oc-gray-8};
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
||||||
--overlay-background-color: #{transparentize($oc-white, 0.12)};
|
--overlay-background-color: #{transparentize($oc-white, 0.12)};
|
||||||
--border-radius-m: 4px;
|
|
||||||
--space-factor: 0.25rem;
|
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||||
--focus-highlight-color: #{$oc-blue-2};
|
--focus-highlight-color: #{$oc-blue-2};
|
||||||
--select-highlight-color: #{$oc-blue-5};
|
--select-highlight-color: #{$oc-blue-5};
|
||||||
@@ -35,6 +31,8 @@
|
|||||||
--popup-secondary-background-color: #{$oc-gray-1};
|
--popup-secondary-background-color: #{$oc-gray-1};
|
||||||
--popup-text-color: #{$oc-black};
|
--popup-text-color: #{$oc-black};
|
||||||
--popup-text-inverted-color: #{$oc-white};
|
--popup-text-inverted-color: #{$oc-white};
|
||||||
|
--dialog-border: #{$oc-gray-6};
|
||||||
|
--link-color: #{$oc-blue-7};
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
@@ -60,10 +58,8 @@
|
|||||||
--icon-fill-color: #{$oc-gray-4};
|
--icon-fill-color: #{$oc-gray-4};
|
||||||
--icon-green-fill-color: #{$oc-green-4};
|
--icon-green-fill-color: #{$oc-green-4};
|
||||||
--keybinding-color: #{$oc-gray-6};
|
--keybinding-color: #{$oc-gray-6};
|
||||||
--color-overlay-text-color: #bbb;
|
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
||||||
--overlay-background-color: rgba(30, 30, 30, 0.88);
|
--overlay-background-color: rgba(30, 30, 30, 0.88);
|
||||||
// #{$oc-gray-4}; inlined
|
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||||
--focus-highlight-color: #{$oc-blue-6};
|
--focus-highlight-color: #{$oc-blue-6};
|
||||||
--select-highlight-color: #{$oc-blue-4};
|
--select-highlight-color: #{$oc-blue-4};
|
||||||
@@ -74,5 +70,6 @@
|
|||||||
--popup-secondary-background-color: #222;
|
--popup-secondary-background-color: #222;
|
||||||
--popup-text-color: #{$oc-gray-4};
|
--popup-text-color: #{$oc-gray-4};
|
||||||
--popup-text-inverted-color: #2c2c2c;
|
--popup-text-inverted-color: #2c2c2c;
|
||||||
|
--dialog-border: #{$oc-gray-9};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||