Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
232412d7bc | ||
|
|
6a8680f500 | ||
|
|
3b0aff0ac6 | ||
|
|
93d0a56bdb | ||
|
|
0a3675e1b9 | ||
|
|
cf35caaf23 | ||
|
|
4e3bf7e8d2 | ||
|
|
4c3544df4a | ||
|
|
74e82d0d7c | ||
|
|
9dd2257932 | ||
|
|
9c0f832a41 | ||
|
|
6cafb6bb90 | ||
|
|
e6cd97c4f2 | ||
|
|
ba9b65b051 | ||
|
|
830fb64a25 | ||
|
|
5b343a9d46 | ||
|
|
f8beb305de | ||
|
|
4b4eecbd27 | ||
|
|
e8bd910b9b | ||
|
|
a9a3e1bca5 | ||
|
|
2a922dd477 | ||
|
|
07dab85ebf | ||
|
|
d2ce4a7523 | ||
|
|
dc73f3a9eb | ||
|
|
d100f38750 | ||
|
|
503500cc74 | ||
|
|
1a828a43d9 | ||
|
|
d88884466b | ||
|
|
d3d470ac3d | ||
|
|
54df521a78 | ||
|
|
c5557b5cc1 | ||
|
|
51875fd627 | ||
|
|
b2ba61bbcf | ||
|
|
3b7f62c9a0 | ||
|
|
aeafb81479 | ||
|
|
dfe81bf6b2 | ||
|
|
1d332d597a | ||
|
|
b5fc8757a4 | ||
|
|
ecbd5ba55d | ||
|
|
6967d8c985 | ||
|
|
4b253c7362 | ||
|
|
4fdddb518a | ||
|
|
0b2e4dd60b | ||
|
|
f162512988 | ||
|
|
2f7154cdf2 | ||
|
|
5ab0ce5a33 | ||
|
|
073f4032f3 | ||
|
|
73cba59d2d | ||
|
|
4085071347 | ||
|
|
8a63187d4f | ||
|
|
9c51ba6067 | ||
|
|
bf50c9cae7 | ||
|
|
1801048763 | ||
|
|
414deea084 | ||
|
|
979d28d5c6 | ||
|
|
1f8b7e417f | ||
|
|
8c968cd13e | ||
|
|
f798000006 | ||
|
|
7ff3a71179 | ||
|
|
6c0804d4c3 | ||
|
|
ae8e7aca16 | ||
|
|
842b185aa6 | ||
|
|
f59387471e | ||
|
|
60eb709eb3 | ||
|
|
02539bbb89 | ||
|
|
77ae5d4605 | ||
|
|
bdd4f69bf6 | ||
|
|
87ca829490 | ||
|
|
a31a7fd766 | ||
|
|
e70f02063f | ||
|
|
769f727bd4 | ||
|
|
bc994fcbe2 | ||
|
|
ac5e058222 | ||
|
|
d61970cdac | ||
|
|
489d4b7469 | ||
|
|
d88de08872 | ||
|
|
42882e2a93 | ||
|
|
f6374e5bde | ||
|
|
aac9d4e837 | ||
|
|
d9103b8b24 | ||
|
|
8b56346011 | ||
|
|
e63a0ec5be | ||
|
|
86222662f2 | ||
|
|
066560311b | ||
|
|
d6ca981f7a | ||
|
|
f7f98d9dda | ||
|
|
1a67642fd1 | ||
|
|
6aa22bada8 | ||
|
|
00209ef9c3 | ||
|
|
b79ef0d428 | ||
|
|
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 |
+1
-1
@@ -2,6 +2,7 @@
|
|||||||
"extends": ["prettier", "react-app"],
|
"extends": ["prettier", "react-app"],
|
||||||
"plugins": ["prettier"],
|
"plugins": ["prettier"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": "warn",
|
||||||
"curly": "warn",
|
"curly": "warn",
|
||||||
"dot-notation": "warn",
|
"dot-notation": "warn",
|
||||||
"import/no-anonymous-default-export": "off",
|
"import/no-anonymous-default-export": "off",
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
],
|
],
|
||||||
"no-unneeded-ternary": "warn",
|
"no-unneeded-ternary": "warn",
|
||||||
"no-unused-expressions": "warn",
|
"no-unused-expressions": "warn",
|
||||||
"no-unused-vars": "warn",
|
|
||||||
"no-useless-return": "warn",
|
"no-useless-return": "warn",
|
||||||
"no-var": "warn",
|
"no-var": "warn",
|
||||||
"object-shorthand": "warn",
|
"object-shorthand": "warn",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
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: |
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
name: Cancel
|
name: Cancel previous runs
|
||||||
on: [push]
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cancel:
|
cancel:
|
||||||
name: "Cancel Previous Runs"
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
timeout-minutes: 3
|
timeout-minutes: 3
|
||||||
steps:
|
steps:
|
||||||
- uses: styfle/cancel-workflow-action@0.6.0
|
- uses: styfle/cancel-workflow-action@0.6.0
|
||||||
|
|||||||
@@ -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,18 +14,18 @@ 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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
main:
|
main:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: amannn/action-semantic-pull-request@v3.0.0
|
- uses: amannn/action-semantic-pull-request@v3.0.0
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -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
-2
@@ -19,7 +19,7 @@
|
|||||||
### Option 2 - CodeSandbox
|
### Option 2 - CodeSandbox
|
||||||
|
|
||||||
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
1. Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
||||||
1. Connect your Github account
|
1. Connect your GitHub account
|
||||||
1. Go to Git tab on left side
|
1. Go to Git tab on left side
|
||||||
1. Tap on `Fork Sandbox`
|
1. Tap on `Fork Sandbox`
|
||||||
1. Write your code
|
1. Write your code
|
||||||
@@ -35,7 +35,6 @@ Make sure the title starts with a semantic prefix:
|
|||||||
|
|
||||||
- **feat**: A new feature
|
- **feat**: A new feature
|
||||||
- **fix**: A bug fix
|
- **fix**: A bug fix
|
||||||
- **improvement**: An improvement to a current feature
|
|
||||||
- **docs**: Documentation only changes
|
- **docs**: Documentation only changes
|
||||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
|
- **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
|
- **refactor**: A code change that neither fixes a bug nor adds a feature
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<a href="https://excalidraw.com">
|
<a href="https://excalidraw.com">
|
||||||
<img width="540" src="./public/og-image-sm.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.<br>Collaborative and end to end encrypted.</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">
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p>Ask questions or hang out on our <a target="_blank" href="https://discord.gg/UexuTaE">discord.gg/UexuTaE</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Try it now
|
## Try it now
|
||||||
@@ -19,6 +20,14 @@ Go to [excalidraw.com](https://excalidraw.com) to start sketching.
|
|||||||
|
|
||||||
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
|
Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/).
|
||||||
|
|
||||||
|
## Supporting Excalidraw
|
||||||
|
|
||||||
|
If you like the project, you can become a sponsor at [Open Collective](https://opencollective.com/excalidraw).
|
||||||
|
|
||||||
|
[<img src="https://opencollective.com/excalidraw/tiers/sponsors/0/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/0/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/1/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/1/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/2/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/2/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/3/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/3/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/4/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/4/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/5/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/5/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/6/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/6/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/7/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/7/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/8/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/8/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/9/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/9/website) [<img src="https://opencollective.com/excalidraw/tiers/sponsors/10/avatar.svg?avatarHeight=120">](https://opencollective.com/excalidraw/tiers/sponsors/10/website)
|
||||||
|
|
||||||
|
<a href="https://opencollective.com/excalidraw#category-CONTRIBUTE" target="_blank"><img src="https://opencollective.com/excalidraw/tiers/backers.svg?avatarHeight=32"/></a>
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
### Shortcuts
|
### Shortcuts
|
||||||
@@ -41,7 +50,7 @@ Translations will be available on the app if they exceed a certain threshold of
|
|||||||
|
|
||||||
### Create a collaboration session manually
|
### Create a collaboration session manually
|
||||||
|
|
||||||
In order to create a session manually you just need to generate a link of this form:
|
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}
|
https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22}
|
||||||
@@ -61,12 +70,16 @@ The second set of digits is the encryption key. The Excalidraw server doesn’t
|
|||||||
|
|
||||||
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).
|
||||||
|
|
||||||
## Developement
|
## Embedding Excalidraw in your App?
|
||||||
|
|
||||||
|
Try out [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw). This package allows you to easily embed Excalidraw as a React component into your apps.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
### Code Sandbox
|
### Code Sandbox
|
||||||
|
|
||||||
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
- Go to https://codesandbox.io/s/github/excalidraw/excalidraw
|
||||||
- You may need to sign in with Github and reload the page
|
- You may need to sign in with GitHub and reload the page
|
||||||
- You can start coding instantly, and even send PRs from there!
|
- You can start coding instantly, and even send PRs from there!
|
||||||
|
|
||||||
### Local Installation
|
### Local Installation
|
||||||
@@ -92,15 +105,15 @@ git clone https://github.com/excalidraw/excalidraw.git
|
|||||||
|
|
||||||
#### Docker Compose
|
#### Docker Compose
|
||||||
|
|
||||||
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
|
You can use docker-compose to work on Excalidraw locally if you don't want to setup a Node.js env.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
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.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t excalidraw/excalidraw .
|
docker build -t excalidraw/excalidraw .
|
||||||
@@ -111,7 +124,7 @@ The Docker image is free of analytics and other tracking libraries.
|
|||||||
|
|
||||||
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
|
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
|
||||||
|
|
||||||
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
|
We are working towards providing a full-fledged solution for self-hosting your own Excalidraw.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
Generated
+29606
-1570
File diff suppressed because it is too large
Load Diff
+20
-15
@@ -19,17 +19,17 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "5.30.0",
|
"@sentry/browser": "6.1.0",
|
||||||
"@sentry/integrations": "5.30.0",
|
"@sentry/integrations": "6.1.0",
|
||||||
"@testing-library/jest-dom": "5.11.9",
|
"@testing-library/jest-dom": "5.11.9",
|
||||||
"@testing-library/react": "11.2.3",
|
"@testing-library/react": "11.2.5",
|
||||||
"@types/jest": "26.0.20",
|
"@types/jest": "26.0.20",
|
||||||
"@types/react": "17.0.0",
|
"@types/react": "17.0.2",
|
||||||
"@types/react-dom": "17.0.0",
|
"@types/react-dom": "17.0.1",
|
||||||
"@types/socket.io-client": "1.4.35",
|
"@types/socket.io-client": "1.4.35",
|
||||||
"browser-nativefs": "0.12.0",
|
"browser-fs-access": "0.13.1",
|
||||||
"clsx": "1.1.1",
|
"clsx": "1.1.1",
|
||||||
"firebase": "8.2.3",
|
"firebase": "8.2.7",
|
||||||
"i18next-browser-languagedetector": "6.0.1",
|
"i18next-browser-languagedetector": "6.0.1",
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
"nanoid": "3.1.20",
|
"nanoid": "3.1.20",
|
||||||
@@ -43,23 +43,25 @@
|
|||||||
"pwacompat": "2.0.17",
|
"pwacompat": "2.0.17",
|
||||||
"react": "17.0.1",
|
"react": "17.0.1",
|
||||||
"react-dom": "17.0.1",
|
"react-dom": "17.0.1",
|
||||||
"react-scripts": "4.0.1",
|
"react-scripts": "4.0.2",
|
||||||
"roughjs": "4.3.1",
|
"roughjs": "4.3.1",
|
||||||
"socket.io-client": "2.3.1",
|
"socket.io-client": "2.3.1",
|
||||||
"typescript": "4.1.3"
|
"typescript": "4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/lodash.throttle": "4.1.6",
|
"@types/lodash.throttle": "4.1.6",
|
||||||
"@types/pako": "1.0.1",
|
"@types/pako": "1.0.1",
|
||||||
"eslint-config-prettier": "7.1.0",
|
"@types/resize-observer-browser": "0.1.5",
|
||||||
|
"eslint-config-prettier": "7.2.0",
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"firebase-tools": "9.2.1",
|
"firebase-tools": "9.3.0",
|
||||||
"husky": "4.3.8",
|
"husky": "4.3.8",
|
||||||
"jest-canvas-mock": "2.3.0",
|
"jest-canvas-mock": "2.3.1",
|
||||||
"lint-staged": "10.5.3",
|
"lint-staged": "10.5.4",
|
||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"rewire": "5.0.0"
|
"rewire": "5.0.0",
|
||||||
|
"worker-loader": "3.0.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -72,8 +74,11 @@
|
|||||||
},
|
},
|
||||||
"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)/)"
|
||||||
],
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^worker-loader!.+": "<rootDir>/src/__mocks__/worker-mock.js"
|
||||||
|
},
|
||||||
"resetMocks": false
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
"name": "excalidraw",
|
"name": "excalidraw",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
|
|
||||||
<!-- Excalidraw version -->
|
<!-- Excalidraw version -->
|
||||||
<meta name="version" content="{version}" />
|
<meta name="version" content="{version}" />
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="FG_Virgil.woff2"
|
href="FG_Virgil.woff2"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const crowdinMap = {
|
|||||||
"id-ID": "en-id",
|
"id-ID": "en-id",
|
||||||
"it-IT": "en-it",
|
"it-IT": "en-it",
|
||||||
"ja-JP": "en-ja",
|
"ja-JP": "en-ja",
|
||||||
|
"kab-KAB": "en-kab",
|
||||||
"ko-KR": "en-ko",
|
"ko-KR": "en-ko",
|
||||||
"my-MM": "en-my",
|
"my-MM": "en-my",
|
||||||
"nb-NO": "en-nb",
|
"nb-NO": "en-nb",
|
||||||
@@ -40,7 +41,7 @@ const crowdinMap = {
|
|||||||
const flags = {
|
const flags = {
|
||||||
"ar-SA": "🇸🇦",
|
"ar-SA": "🇸🇦",
|
||||||
"bg-BG": "🇧🇬",
|
"bg-BG": "🇧🇬",
|
||||||
"ca-ES": "🇪🇸",
|
"ca-ES": "🏳",
|
||||||
"de-DE": "🇩🇪",
|
"de-DE": "🇩🇪",
|
||||||
"el-GR": "🇬🇷",
|
"el-GR": "🇬🇷",
|
||||||
"es-ES": "🇪🇸",
|
"es-ES": "🇪🇸",
|
||||||
@@ -53,6 +54,7 @@ const flags = {
|
|||||||
"id-ID": "🇮🇩",
|
"id-ID": "🇮🇩",
|
||||||
"it-IT": "🇮🇹",
|
"it-IT": "🇮🇹",
|
||||||
"ja-JP": "🇯🇵",
|
"ja-JP": "🇯🇵",
|
||||||
|
"kab-KAB": "🏳",
|
||||||
"ko-KR": "🇰🇷",
|
"ko-KR": "🇰🇷",
|
||||||
"my-MM": "🇲🇲",
|
"my-MM": "🇲🇲",
|
||||||
"nb-NO": "🇳🇴",
|
"nb-NO": "🇳🇴",
|
||||||
@@ -88,6 +90,7 @@ const languages = {
|
|||||||
"id-ID": "Bahasa Indonesia",
|
"id-ID": "Bahasa Indonesia",
|
||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
|
"kab-KAB": "Taqbaylit",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
"my-MM": "Burmese",
|
"my-MM": "Burmese",
|
||||||
"nb-NO": "Norsk bokmål",
|
"nb-NO": "Norsk bokmål",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = class {
|
||||||
|
postMessage() {}
|
||||||
|
terminate() {}
|
||||||
|
};
|
||||||
@@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
contextMenuOrder: 6,
|
|
||||||
contextItemLabel: "labels.addToLibrary",
|
contextItemLabel: "labels.addToLibrary",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getDefaultAppState } from "../appState";
|
|||||||
import { ColorPicker } from "../components/ColorPicker";
|
import { ColorPicker } from "../components/ColorPicker";
|
||||||
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
|
import { ZOOM_STEP } from "../constants";
|
||||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
import { newElementWith } from "../element/mutateElement";
|
import { newElementWith } from "../element/mutateElement";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
@@ -75,8 +76,6 @@ 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) => {
|
||||||
|
|||||||
@@ -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: 999999,
|
|
||||||
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
|
|||||||
@@ -96,9 +96,24 @@ export const actionChangeShouldAddWatermark = register({
|
|||||||
export const actionSaveScene = register({
|
export const actionSaveScene = register({
|
||||||
name: "saveScene",
|
name: "saveScene",
|
||||||
perform: async (elements, appState, value) => {
|
perform: async (elements, appState, value) => {
|
||||||
|
const fileHandleExists = !!appState.fileHandle;
|
||||||
try {
|
try {
|
||||||
const { fileHandle } = await saveAsJSON(elements, appState);
|
const { fileHandle } = await saveAsJSON(elements, appState);
|
||||||
return { commitToHistory: false, appState: { ...appState, fileHandle } };
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
fileHandle,
|
||||||
|
toastMessage: fileHandleExists
|
||||||
|
? fileHandle.name
|
||||||
|
? t("toast.fileSavedToFilename").replace(
|
||||||
|
"{filename}",
|
||||||
|
`"${fileHandle.name}"`,
|
||||||
|
)
|
||||||
|
: t("toast.fileSaved")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name !== "AbortError") {
|
if (error?.name !== "AbortError") {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const actionFinalize = register({
|
|||||||
// If the multi point line closes the loop,
|
// If the multi point line closes the loop,
|
||||||
// set the last point to first point.
|
// set the last point to first point.
|
||||||
// This ensures that loop remains closed at different scales.
|
// This ensures that loop remains closed at different scales.
|
||||||
const isLoop = isPathALoop(multiPointElement.points);
|
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||||
if (
|
if (
|
||||||
multiPointElement.type === "line" ||
|
multiPointElement.type === "line" ||
|
||||||
multiPointElement.type === "draw"
|
multiPointElement.type === "draw"
|
||||||
|
|||||||
@@ -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,6 +7,7 @@ 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 { MiniMap } from "../components/MiniMap";
|
||||||
|
|
||||||
export const actionToggleCanvasMenu = register({
|
export const actionToggleCanvasMenu = register({
|
||||||
name: "toggleCanvasMenu",
|
name: "toggleCanvasMenu",
|
||||||
@@ -74,7 +75,7 @@ export const actionShortcuts = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showHelpDialog: true,
|
showHelpDialog: !appState.showHelpDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@@ -84,3 +85,21 @@ export const actionShortcuts = register({
|
|||||||
),
|
),
|
||||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionMinimap = register({
|
||||||
|
name: "toggleMinimap",
|
||||||
|
perform: (_elements, appState) => {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
isMinimapEnabled: !appState.isMinimapEnabled,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, elements }) =>
|
||||||
|
appState.isMinimapEnabled ? (
|
||||||
|
<MiniMap appState={appState} elements={elements} />
|
||||||
|
) : null,
|
||||||
|
keyTest: (event) => event.key === KEYS.M,
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const actionGoToCollaborator = register({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { background, stroke } = getClientColors(clientId);
|
const { background, stroke } = getClientColors(clientId, appState);
|
||||||
const shortName = getClientInitials(collaborator.username);
|
const shortName = getClientInitials(collaborator.username);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ 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({
|
||||||
@@ -74,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.showGrid",
|
||||||
|
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,
|
||||||
|
});
|
||||||
@@ -44,6 +44,7 @@ export {
|
|||||||
actionToggleEditMenu,
|
actionToggleEditMenu,
|
||||||
actionFullScreen,
|
actionFullScreen,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
|
actionMinimap,
|
||||||
} from "./actionMenu";
|
} from "./actionMenu";
|
||||||
|
|
||||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||||
@@ -65,3 +66,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";
|
||||||
|
|||||||
+18
-38
@@ -3,14 +3,16 @@ 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 { MODES } from "../constants";
|
||||||
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 +20,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 +40,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 +67,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 (!Object.values(MODES).includes(data[0].name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.updater(
|
this.updater(
|
||||||
@@ -70,6 +80,7 @@ export class ActionManager implements ActionsManagerInterface {
|
|||||||
this.getElementsIncludingDeleted(),
|
this.getElementsIncludingDeleted(),
|
||||||
this.getAppState(),
|
this.getAppState(),
|
||||||
null,
|
null,
|
||||||
|
this.app,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
@@ -81,43 +92,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 +111,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"
|
||||||
@@ -22,7 +22,8 @@ export type ShortcutName =
|
|||||||
| "gridMode"
|
| "gridMode"
|
||||||
| "zenMode"
|
| "zenMode"
|
||||||
| "stats"
|
| "stats"
|
||||||
| "addToLibrary";
|
| "addToLibrary"
|
||||||
|
| "viewMode";
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
cut: [getShortcutKey("CtrlOrCmd+X")],
|
cut: [getShortcutKey("CtrlOrCmd+X")],
|
||||||
@@ -31,7 +32,7 @@ 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("helpDialog.drag")}`),
|
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||||
@@ -56,6 +57,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
zenMode: [getShortcutKey("Alt+Z")],
|
zenMode: [getShortcutKey("Alt+Z")],
|
||||||
stats: [],
|
stats: [],
|
||||||
addToLibrary: [],
|
addToLibrary: [],
|
||||||
|
viewMode: [getShortcutKey("Alt+R")],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||||
|
|||||||
+13
-5
@@ -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"
|
||||||
@@ -75,7 +84,9 @@ export type ActionName =
|
|||||||
| "alignVerticallyCentered"
|
| "alignVerticallyCentered"
|
||||||
| "alignHorizontallyCentered"
|
| "alignHorizontallyCentered"
|
||||||
| "distributeHorizontally"
|
| "distributeHorizontally"
|
||||||
| "distributeVertically";
|
| "distributeVertically"
|
||||||
|
| "viewMode"
|
||||||
|
| "toggleMinimap";
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
@@ -93,19 +104,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -5,7 +5,7 @@ import {
|
|||||||
DEFAULT_TEXT_ALIGN,
|
DEFAULT_TEXT_ALIGN,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { t } from "./i18n";
|
import { t } from "./i18n";
|
||||||
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
|
import { AppState, NormalizedZoomValue } from "./types";
|
||||||
import { getDateTime } from "./utils";
|
import { getDateTime } from "./utils";
|
||||||
|
|
||||||
export const getDefaultAppState = (): Omit<
|
export const getDefaultAppState = (): Omit<
|
||||||
@@ -42,7 +42,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
exportEmbedScene: false,
|
exportEmbedScene: false,
|
||||||
fileHandle: null,
|
fileHandle: null,
|
||||||
gridSize: null,
|
gridSize: null,
|
||||||
height: window.innerHeight,
|
height: globalThis.innerHeight,
|
||||||
isBindingEnabled: true,
|
isBindingEnabled: true,
|
||||||
isLibraryOpen: false,
|
isLibraryOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -56,8 +56,8 @@ export const getDefaultAppState = (): Omit<
|
|||||||
previousSelectedElementIds: {},
|
previousSelectedElementIds: {},
|
||||||
resizingElement: null,
|
resizingElement: null,
|
||||||
scrolledOutside: false,
|
scrolledOutside: false,
|
||||||
scrollX: 0 as FlooredNumber,
|
scrollX: 0,
|
||||||
scrollY: 0 as FlooredNumber,
|
scrollY: 0,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
@@ -69,9 +69,11 @@ export const getDefaultAppState = (): Omit<
|
|||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
toastMessage: null,
|
toastMessage: null,
|
||||||
viewBackgroundColor: oc.white,
|
viewBackgroundColor: oc.white,
|
||||||
width: window.innerWidth,
|
width: globalThis.innerWidth,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
|
||||||
|
viewModeEnabled: false,
|
||||||
|
isMinimapEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +153,8 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
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 },
|
||||||
|
viewModeEnabled: { browser: false, export: false },
|
||||||
|
isMinimapEnabled: { browser: true, export: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,13 @@
|
|||||||
import colors from "./colors";
|
import colors from "./colors";
|
||||||
|
import { AppState } from "./types";
|
||||||
|
|
||||||
export const getClientColors = (clientId: string) => {
|
export const getClientColors = (clientId: string, appState: AppState) => {
|
||||||
|
if (appState?.collaborators) {
|
||||||
|
const currentUser = appState.collaborators.get(clientId);
|
||||||
|
if (currentUser?.color) {
|
||||||
|
return currentUser.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Naive way of getting an integer out of the clientId
|
// Naive way of getting an integer out of the clientId
|
||||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||||
|
|
||||||
|
|||||||
+337
-208
@@ -2,8 +2,30 @@ import { Point, simplify } from "points-on-curve";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import "../actions";
|
import clsx from "clsx";
|
||||||
import { actionDeleteSelected, actionFinalize } from "../actions";
|
|
||||||
|
import {
|
||||||
|
actionAddToLibrary,
|
||||||
|
actionBringForward,
|
||||||
|
actionBringToFront,
|
||||||
|
actionCopy,
|
||||||
|
actionCopyAsPng,
|
||||||
|
actionCopyAsSvg,
|
||||||
|
actionCopyStyles,
|
||||||
|
actionCut,
|
||||||
|
actionDeleteSelected,
|
||||||
|
actionDuplicateSelection,
|
||||||
|
actionFinalize,
|
||||||
|
actionGroup,
|
||||||
|
actionPasteStyles,
|
||||||
|
actionSelectAll,
|
||||||
|
actionSendBackward,
|
||||||
|
actionSendToBack,
|
||||||
|
actionToggleGridMode,
|
||||||
|
actionToggleStats,
|
||||||
|
actionToggleZenMode,
|
||||||
|
actionUngroup,
|
||||||
|
} from "../actions";
|
||||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { actions } from "../actions/register";
|
import { actions } from "../actions/register";
|
||||||
@@ -18,7 +40,6 @@ import {
|
|||||||
} from "../clipboard";
|
} from "../clipboard";
|
||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
CANVAS_ONLY_ACTIONS,
|
|
||||||
CURSOR_TYPE,
|
CURSOR_TYPE,
|
||||||
DEFAULT_VERTICAL_ALIGN,
|
DEFAULT_VERTICAL_ALIGN,
|
||||||
DRAGGING_THRESHOLD,
|
DRAGGING_THRESHOLD,
|
||||||
@@ -30,11 +51,13 @@ import {
|
|||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
POINTER_BUTTON,
|
POINTER_BUTTON,
|
||||||
|
SCROLL_TIMEOUT,
|
||||||
TAP_TWICE_TIMEOUT,
|
TAP_TWICE_TIMEOUT,
|
||||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||||
TOUCH_CTX_MENU_TIMEOUT,
|
TOUCH_CTX_MENU_TIMEOUT,
|
||||||
|
ZOOM_STEP,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { exportCanvas, loadFromBlob } from "../data";
|
import { loadFromBlob } from "../data";
|
||||||
import { isValidLibrary } from "../data/json";
|
import { isValidLibrary } from "../data/json";
|
||||||
import { Library } from "../data/library";
|
import { Library } from "../data/library";
|
||||||
import { restore } from "../data/restore";
|
import { restore } from "../data/restore";
|
||||||
@@ -127,7 +150,6 @@ import {
|
|||||||
getSelectedElements,
|
getSelectedElements,
|
||||||
isOverScrollBars,
|
isOverScrollBars,
|
||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
normalizeScroll,
|
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
import Scene from "../scene/Scene";
|
import Scene from "../scene/Scene";
|
||||||
import { SceneState, ScrollBars } from "../scene/types";
|
import { SceneState, ScrollBars } from "../scene/types";
|
||||||
@@ -155,10 +177,12 @@ import {
|
|||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import ContextMenu from "./ContextMenu";
|
import { isMobile } from "../is-mobile";
|
||||||
|
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
|
||||||
import LayerUI from "./LayerUI";
|
import LayerUI from "./LayerUI";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
import { Toast } from "./Toast";
|
import { Toast } from "./Toast";
|
||||||
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||||
|
|
||||||
const { history } = createHistory();
|
const { history } = createHistory();
|
||||||
|
|
||||||
@@ -248,6 +272,7 @@ export type ExcalidrawImperativeAPI = {
|
|||||||
};
|
};
|
||||||
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
|
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
|
||||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||||
|
getAppState: () => InstanceType<typeof App>["state"];
|
||||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||||
ready: true;
|
ready: true;
|
||||||
};
|
};
|
||||||
@@ -274,6 +299,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
offsetLeft,
|
offsetLeft,
|
||||||
offsetTop,
|
offsetTop,
|
||||||
excalidrawRef,
|
excalidrawRef,
|
||||||
|
viewModeEnabled = false,
|
||||||
|
zenModeEnabled = false,
|
||||||
|
gridModeEnabled = false,
|
||||||
} = props;
|
} = props;
|
||||||
this.state = {
|
this.state = {
|
||||||
...defaultAppState,
|
...defaultAppState,
|
||||||
@@ -281,6 +309,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
||||||
|
viewModeEnabled,
|
||||||
|
zenModeEnabled,
|
||||||
|
gridSize: gridModeEnabled ? GRID_SIZE : null,
|
||||||
};
|
};
|
||||||
if (excalidrawRef) {
|
if (excalidrawRef) {
|
||||||
const readyPromise =
|
const readyPromise =
|
||||||
@@ -298,6 +329,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
},
|
},
|
||||||
setScrollToCenter: this.setScrollToCenter,
|
setScrollToCenter: this.setScrollToCenter,
|
||||||
getSceneElements: this.getSceneElements,
|
getSceneElements: this.getSceneElements,
|
||||||
|
getAppState: () => this.state,
|
||||||
} as const;
|
} as const;
|
||||||
if (typeof excalidrawRef === "function") {
|
if (typeof excalidrawRef === "function") {
|
||||||
excalidrawRef(api);
|
excalidrawRef(api);
|
||||||
@@ -312,6 +344,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.syncActionResult,
|
this.syncActionResult,
|
||||||
() => this.state,
|
() => this.state,
|
||||||
() => this.scene.getElementsIncludingDeleted(),
|
() => this.scene.getElementsIncludingDeleted(),
|
||||||
|
this,
|
||||||
);
|
);
|
||||||
this.actionManager.registerAll(actions);
|
this.actionManager.registerAll(actions);
|
||||||
|
|
||||||
@@ -319,6 +352,62 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.actionManager.registerAction(createRedoAction(history));
|
this.actionManager.registerAction(createRedoAction(history));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderCanvas() {
|
||||||
|
const canvasScale = window.devicePixelRatio;
|
||||||
|
const {
|
||||||
|
width: canvasDOMWidth,
|
||||||
|
height: canvasDOMHeight,
|
||||||
|
viewModeEnabled,
|
||||||
|
} = this.state;
|
||||||
|
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||||
|
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||||
|
if (viewModeEnabled) {
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
id="canvas"
|
||||||
|
style={{
|
||||||
|
width: canvasDOMWidth,
|
||||||
|
height: canvasDOMHeight,
|
||||||
|
cursor: "grabbing",
|
||||||
|
}}
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
ref={this.handleCanvasRef}
|
||||||
|
onContextMenu={this.handleCanvasContextMenu}
|
||||||
|
onPointerMove={this.handleCanvasPointerMove}
|
||||||
|
onPointerUp={this.removePointer}
|
||||||
|
onPointerCancel={this.removePointer}
|
||||||
|
onTouchMove={this.handleTouchMove}
|
||||||
|
onPointerDown={this.handleCanvasPointerDown}
|
||||||
|
>
|
||||||
|
{t("labels.drawingCanvas")}
|
||||||
|
</canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
id="canvas"
|
||||||
|
style={{
|
||||||
|
width: canvasDOMWidth,
|
||||||
|
height: canvasDOMHeight,
|
||||||
|
}}
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
ref={this.handleCanvasRef}
|
||||||
|
onContextMenu={this.handleCanvasContextMenu}
|
||||||
|
onPointerDown={this.handleCanvasPointerDown}
|
||||||
|
onDoubleClick={this.handleCanvasDoubleClick}
|
||||||
|
onPointerMove={this.handleCanvasPointerMove}
|
||||||
|
onPointerUp={this.removePointer}
|
||||||
|
onPointerCancel={this.removePointer}
|
||||||
|
onTouchMove={this.handleTouchMove}
|
||||||
|
onDrop={this.handleCanvasOnDrop}
|
||||||
|
>
|
||||||
|
{t("labels.drawingCanvas")}
|
||||||
|
</canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
@@ -326,20 +415,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
height: canvasDOMHeight,
|
height: canvasDOMHeight,
|
||||||
offsetTop,
|
offsetTop,
|
||||||
offsetLeft,
|
offsetLeft,
|
||||||
|
viewModeEnabled,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
|
const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props;
|
||||||
const canvasScale = window.devicePixelRatio;
|
|
||||||
|
|
||||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
|
||||||
const canvasHeight = canvasDOMHeight * canvasScale;
|
|
||||||
|
|
||||||
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
|
const DEFAULT_PASTE_X = canvasDOMWidth / 2;
|
||||||
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
|
const DEFAULT_PASTE_Y = canvasDOMHeight / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="excalidraw"
|
className={clsx("excalidraw", {
|
||||||
|
"excalidraw--view-mode": viewModeEnabled,
|
||||||
|
})}
|
||||||
ref={this.excalidrawContainerRef}
|
ref={this.excalidrawContainerRef}
|
||||||
style={{
|
style={{
|
||||||
width: canvasDOMWidth,
|
width: canvasDOMWidth,
|
||||||
@@ -369,10 +457,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
isCollaborating={this.props.isCollaborating || false}
|
isCollaborating={this.props.isCollaborating || false}
|
||||||
onExportToBackend={onExportToBackend}
|
onExportToBackend={onExportToBackend}
|
||||||
renderCustomFooter={renderFooter}
|
renderCustomFooter={renderFooter}
|
||||||
|
viewModeEnabled={viewModeEnabled}
|
||||||
|
showExitZenModeBtn={
|
||||||
|
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
<div className="excalidraw-textEditorContainer" />
|
||||||
{this.state.showStats && (
|
{this.state.showStats && (
|
||||||
<Stats
|
<Stats
|
||||||
appState={this.state}
|
appState={this.state}
|
||||||
|
setAppState={this.setAppState}
|
||||||
elements={this.scene.getElements()}
|
elements={this.scene.getElements()}
|
||||||
onClose={this.toggleStats}
|
onClose={this.toggleStats}
|
||||||
/>
|
/>
|
||||||
@@ -383,28 +477,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
clearToast={this.clearToast}
|
clearToast={this.clearToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<main>
|
<main>{this.renderCanvas()}</main>
|
||||||
<canvas
|
|
||||||
id="canvas"
|
|
||||||
style={{
|
|
||||||
width: canvasDOMWidth,
|
|
||||||
height: canvasDOMHeight,
|
|
||||||
}}
|
|
||||||
width={canvasWidth}
|
|
||||||
height={canvasHeight}
|
|
||||||
ref={this.handleCanvasRef}
|
|
||||||
onContextMenu={this.handleCanvasContextMenu}
|
|
||||||
onPointerDown={this.handleCanvasPointerDown}
|
|
||||||
onDoubleClick={this.handleCanvasDoubleClick}
|
|
||||||
onPointerMove={this.handleCanvasPointerMove}
|
|
||||||
onPointerUp={this.removePointer}
|
|
||||||
onPointerCancel={this.removePointer}
|
|
||||||
onTouchMove={this.handleTouchMove}
|
|
||||||
onDrop={this.handleCanvasOnDrop}
|
|
||||||
>
|
|
||||||
{t("labels.drawingCanvas")}
|
|
||||||
</canvas>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -444,6 +517,23 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
if (actionResult.commitToHistory) {
|
if (actionResult.commitToHistory) {
|
||||||
history.resumeRecording();
|
history.resumeRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||||
|
let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
|
||||||
|
let gridSize = actionResult?.appState?.gridSize || null;
|
||||||
|
|
||||||
|
if (typeof this.props.viewModeEnabled !== "undefined") {
|
||||||
|
viewModeEnabled = this.props.viewModeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.props.zenModeEnabled !== "undefined") {
|
||||||
|
zenModeEnabled = this.props.zenModeEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.props.gridModeEnabled !== "undefined") {
|
||||||
|
gridSize = this.props.gridModeEnabled ? GRID_SIZE : null;
|
||||||
|
}
|
||||||
|
|
||||||
this.setState(
|
this.setState(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
...actionResult.appState,
|
...actionResult.appState,
|
||||||
@@ -453,6 +543,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
height: state.height,
|
height: state.height,
|
||||||
offsetTop: state.offsetTop,
|
offsetTop: state.offsetTop,
|
||||||
offsetLeft: state.offsetLeft,
|
offsetLeft: state.offsetLeft,
|
||||||
|
viewModeEnabled,
|
||||||
|
zenModeEnabled,
|
||||||
|
gridSize,
|
||||||
}),
|
}),
|
||||||
() => {
|
() => {
|
||||||
if (actionResult.syncHistory) {
|
if (actionResult.syncHistory) {
|
||||||
@@ -635,7 +728,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.scene.addCallback(this.onSceneUpdated);
|
this.scene.addCallback(this.onSceneUpdated);
|
||||||
|
|
||||||
this.addEventListeners();
|
this.addEventListeners();
|
||||||
|
|
||||||
// optim to avoid extra render on init
|
// optim to avoid extra render on init
|
||||||
@@ -702,25 +794,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private addEventListeners() {
|
private addEventListeners() {
|
||||||
|
this.removeEventListeners();
|
||||||
document.addEventListener(EVENT.COPY, this.onCopy);
|
document.addEventListener(EVENT.COPY, this.onCopy);
|
||||||
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
|
||||||
document.addEventListener(EVENT.CUT, this.onCut);
|
|
||||||
|
|
||||||
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false);
|
||||||
document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
|
document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true });
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
EVENT.MOUSE_MOVE,
|
EVENT.MOUSE_MOVE,
|
||||||
this.updateCurrentCursorPosition,
|
this.updateCurrentCursorPosition,
|
||||||
);
|
);
|
||||||
window.addEventListener(EVENT.RESIZE, this.onResize, false);
|
|
||||||
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
|
|
||||||
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
|
||||||
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
|
||||||
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
|
||||||
|
|
||||||
// rerender text elements on font load to fix #637 && #1553
|
// rerender text elements on font load to fix #637 && #1553
|
||||||
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
||||||
|
|
||||||
// Safari-only desktop pinch zoom
|
// Safari-only desktop pinch zoom
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
EVENT.GESTURE_START,
|
EVENT.GESTURE_START,
|
||||||
@@ -737,6 +820,19 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.onGestureEnd as any,
|
this.onGestureEnd as any,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
if (this.state.viewModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||||
|
document.addEventListener(EVENT.CUT, this.onCut);
|
||||||
|
document.addEventListener(EVENT.SCROLL, this.onScroll);
|
||||||
|
|
||||||
|
window.addEventListener(EVENT.RESIZE, this.onResize, false);
|
||||||
|
window.addEventListener(EVENT.UNLOAD, this.onUnload, false);
|
||||||
|
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
||||||
|
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||||
|
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||||
@@ -759,6 +855,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
|
||||||
|
this.setState(
|
||||||
|
{ viewModeEnabled: !!this.props.viewModeEnabled },
|
||||||
|
this.addEventListeners,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
|
||||||
|
this.addEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
|
||||||
|
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevProps.gridModeEnabled !== this.props.gridModeEnabled) {
|
||||||
|
this.setState({
|
||||||
|
gridSize: this.props.gridModeEnabled ? GRID_SIZE : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
document
|
document
|
||||||
.querySelector(".excalidraw")
|
.querySelector(".excalidraw")
|
||||||
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
|
?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
|
||||||
@@ -798,6 +914,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
|
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
|
||||||
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
|
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
|
||||||
const pointerUsernames: { [id: string]: string } = {};
|
const pointerUsernames: { [id: string]: string } = {};
|
||||||
|
const pointerUserStates: { [id: string]: string } = {};
|
||||||
this.state.collaborators.forEach((user, socketId) => {
|
this.state.collaborators.forEach((user, socketId) => {
|
||||||
if (user.selectedElementIds) {
|
if (user.selectedElementIds) {
|
||||||
for (const id of Object.keys(user.selectedElementIds)) {
|
for (const id of Object.keys(user.selectedElementIds)) {
|
||||||
@@ -813,6 +930,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
if (user.username) {
|
if (user.username) {
|
||||||
pointerUsernames[socketId] = user.username;
|
pointerUsernames[socketId] = user.username;
|
||||||
}
|
}
|
||||||
|
if (user.userState) {
|
||||||
|
pointerUserStates[socketId] = user.userState;
|
||||||
|
}
|
||||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||||
{
|
{
|
||||||
sceneX: user.pointer.x,
|
sceneX: user.pointer.x,
|
||||||
@@ -847,10 +967,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
remotePointerButton: cursorButton,
|
remotePointerButton: cursorButton,
|
||||||
remoteSelectedElementIds,
|
remoteSelectedElementIds,
|
||||||
remotePointerUsernames: pointerUsernames,
|
remotePointerUsernames: pointerUsernames,
|
||||||
|
remotePointerUserStates: pointerUserStates,
|
||||||
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
renderOptimizations: true,
|
renderOptimizations: true,
|
||||||
|
renderScrollbars: !isMobile(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (scrollBars) {
|
if (scrollBars) {
|
||||||
@@ -879,6 +1001,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onScroll = debounce(() => {
|
||||||
|
this.setState({ ...this.getCanvasOffsets() });
|
||||||
|
}, SCROLL_TIMEOUT);
|
||||||
|
|
||||||
// Copy/paste
|
// Copy/paste
|
||||||
|
|
||||||
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
private onCut = withBatchedUpdates((event: ClipboardEvent) => {
|
||||||
@@ -906,44 +1032,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
copyToClipboard(this.scene.getElements(), this.state);
|
copyToClipboard(this.scene.getElements(), this.state);
|
||||||
};
|
};
|
||||||
|
|
||||||
private copyToClipboardAsPng = async () => {
|
|
||||||
const elements = this.scene.getElements();
|
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, this.state);
|
|
||||||
try {
|
|
||||||
await exportCanvas(
|
|
||||||
"clipboard",
|
|
||||||
selectedElements.length ? selectedElements : elements,
|
|
||||||
this.state,
|
|
||||||
this.canvas!,
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
this.setState({ toastMessage: t("toast.copyToClipboardAsPng") });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.setState({ errorMessage: error.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private copyToClipboardAsSvg = async () => {
|
|
||||||
const selectedElements = getSelectedElements(
|
|
||||||
this.scene.getElements(),
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await exportCanvas(
|
|
||||||
"clipboard-svg",
|
|
||||||
selectedElements.length ? selectedElements : this.scene.getElements(),
|
|
||||||
this.state,
|
|
||||||
this.canvas!,
|
|
||||||
this.state,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
this.setState({ errorMessage: error.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static resetTapTwice() {
|
private static resetTapTwice() {
|
||||||
didTapTwice = false;
|
didTapTwice = false;
|
||||||
}
|
}
|
||||||
@@ -1146,24 +1234,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
toggleZenMode = () => {
|
toggleZenMode = () => {
|
||||||
this.setState({
|
this.actionManager.executeAction(actionToggleZenMode);
|
||||||
zenModeEnabled: !this.state.zenModeEnabled,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleGridMode = () => {
|
|
||||||
this.setState({
|
|
||||||
gridSize: this.state.gridSize ? null : GRID_SIZE,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
toggleStats = () => {
|
toggleStats = () => {
|
||||||
if (!this.state.showStats) {
|
if (!this.state.showStats) {
|
||||||
trackEvent("dialog", "stats");
|
trackEvent("dialog", "stats");
|
||||||
}
|
}
|
||||||
this.setState({
|
this.actionManager.executeAction(actionToggleStats);
|
||||||
showStats: !this.state.showStats,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
|
setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
|
||||||
@@ -1253,27 +1331,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z) {
|
|
||||||
this.toggleZenMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE) {
|
|
||||||
this.toggleGridMode();
|
|
||||||
}
|
|
||||||
if (event[KEYS.CTRL_OR_CMD]) {
|
|
||||||
this.setState({ isBindingEnabled: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code === CODES.C && event.altKey && event.shiftKey) {
|
|
||||||
this.copyToClipboardAsPng();
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.actionManager.handleKeyDown(event)) {
|
if (this.actionManager.handleKeyDown(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.viewModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event[KEYS.CTRL_OR_CMD]) {
|
||||||
|
this.setState({ isBindingEnabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
if (event.code === CODES.NINE) {
|
if (event.code === CODES.NINE) {
|
||||||
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
||||||
}
|
}
|
||||||
@@ -1488,6 +1557,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
textWysiwyg({
|
textWysiwyg({
|
||||||
id: element.id,
|
id: element.id,
|
||||||
appState: this.state,
|
appState: this.state,
|
||||||
|
canvas: this.canvas,
|
||||||
getViewportCoords: (x, y) => {
|
getViewportCoords: (x, y) => {
|
||||||
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||||
{
|
{
|
||||||
@@ -1778,8 +1848,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
const scaleFactor = distance / gesture.initialDistance;
|
const scaleFactor = distance / gesture.initialDistance;
|
||||||
|
|
||||||
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
|
this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({
|
||||||
scrollX: normalizeScroll(scrollX + deltaX / zoom.value),
|
scrollX: scrollX + deltaX / zoom.value,
|
||||||
scrollY: normalizeScroll(scrollY + deltaY / zoom.value),
|
scrollY: scrollY + deltaY / zoom.value,
|
||||||
zoom: getNewZoom(
|
zoom: getNewZoom(
|
||||||
getNormalizedZoom(initialScale * scaleFactor),
|
getNormalizedZoom(initialScale * scaleFactor),
|
||||||
zoom,
|
zoom,
|
||||||
@@ -1796,10 +1866,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
|
if (isHoldingSpace || isPanning || isDraggingScrollBar) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPointerOverScrollBars = isOverScrollBars(
|
const isPointerOverScrollBars = isOverScrollBars(
|
||||||
currentScrollBars,
|
currentScrollBars,
|
||||||
event.clientX,
|
event.clientX - this.state.offsetLeft,
|
||||||
event.clientY,
|
event.clientY - this.state.offsetTop,
|
||||||
);
|
);
|
||||||
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
|
const isOverScrollBar = isPointerOverScrollBars.isOverEither;
|
||||||
if (!this.state.draggingElement && !this.state.multiElement) {
|
if (!this.state.draggingElement && !this.state.multiElement) {
|
||||||
@@ -1893,7 +1964,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
points: points.slice(0, -1),
|
points: points.slice(0, -1),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (isPathALoop(points)) {
|
if (isPathALoop(points, this.state.zoom.value)) {
|
||||||
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
document.documentElement.style.cursor = CURSOR_TYPE.POINTER;
|
||||||
}
|
}
|
||||||
// update last uncommitted point
|
// update last uncommitted point
|
||||||
@@ -2080,14 +2151,16 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
|
|
||||||
lastPointerUp = onPointerUp;
|
lastPointerUp = onPointerUp;
|
||||||
|
|
||||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
if (!this.state.viewModeEnabled) {
|
||||||
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
|
||||||
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
|
||||||
window.addEventListener(EVENT.KEYUP, onKeyUp);
|
window.addEventListener(EVENT.KEYDOWN, onKeyDown);
|
||||||
pointerDownState.eventListeners.onMove = onPointerMove;
|
window.addEventListener(EVENT.KEYUP, onKeyUp);
|
||||||
pointerDownState.eventListeners.onUp = onPointerUp;
|
pointerDownState.eventListeners.onMove = onPointerMove;
|
||||||
pointerDownState.eventListeners.onKeyUp = onKeyUp;
|
pointerDownState.eventListeners.onUp = onPointerUp;
|
||||||
pointerDownState.eventListeners.onKeyDown = onKeyDown;
|
pointerDownState.eventListeners.onKeyUp = onKeyUp;
|
||||||
|
pointerDownState.eventListeners.onKeyDown = onKeyDown;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
|
private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
|
||||||
@@ -2137,7 +2210,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
!(
|
!(
|
||||||
gesture.pointers.size === 0 &&
|
gesture.pointers.size === 0 &&
|
||||||
(event.button === POINTER_BUTTON.WHEEL ||
|
(event.button === POINTER_BUTTON.WHEEL ||
|
||||||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace))
|
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
|
||||||
|
this.state.viewModeEnabled)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2190,12 +2264,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollX: normalizeScroll(
|
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
||||||
this.state.scrollX - deltaX / this.state.zoom.value,
|
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
|
||||||
),
|
|
||||||
scrollY: normalizeScroll(
|
|
||||||
this.state.scrollY - deltaY / this.state.zoom.value,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const teardown = withBatchedUpdates(
|
const teardown = withBatchedUpdates(
|
||||||
@@ -2256,8 +2326,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
),
|
),
|
||||||
scrollbars: isOverScrollBars(
|
scrollbars: isOverScrollBars(
|
||||||
currentScrollBars,
|
currentScrollBars,
|
||||||
event.clientX,
|
event.clientX - this.state.offsetLeft,
|
||||||
event.clientY,
|
event.clientY - this.state.offsetTop,
|
||||||
),
|
),
|
||||||
// we need to duplicate because we'll be updating this state
|
// we need to duplicate because we'll be updating this state
|
||||||
lastCoords: { ...origin },
|
lastCoords: { ...origin },
|
||||||
@@ -2566,7 +2636,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
const { multiElement } = this.state;
|
const { multiElement } = this.state;
|
||||||
|
|
||||||
// finalize if completing a loop
|
// finalize if completing a loop
|
||||||
if (multiElement.type === "line" && isPathALoop(multiElement.points)) {
|
if (
|
||||||
|
multiElement.type === "line" &&
|
||||||
|
isPathALoop(multiElement.points, this.state.zoom.value)
|
||||||
|
) {
|
||||||
mutateElement(multiElement, {
|
mutateElement(multiElement, {
|
||||||
lastCommittedPoint:
|
lastCommittedPoint:
|
||||||
multiElement.points[multiElement.points.length - 1],
|
multiElement.points[multiElement.points.length - 1],
|
||||||
@@ -3009,9 +3082,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
const x = event.clientX;
|
const x = event.clientX;
|
||||||
const dx = x - pointerDownState.lastCoords.x;
|
const dx = x - pointerDownState.lastCoords.x;
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollX: normalizeScroll(
|
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||||
this.state.scrollX - dx / this.state.zoom.value,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
pointerDownState.lastCoords.x = x;
|
pointerDownState.lastCoords.x = x;
|
||||||
return true;
|
return true;
|
||||||
@@ -3021,9 +3092,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
const y = event.clientY;
|
const y = event.clientY;
|
||||||
const dy = y - pointerDownState.lastCoords.y;
|
const dy = y - pointerDownState.lastCoords.y;
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollY: normalizeScroll(
|
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||||
this.state.scrollY - dy / this.state.zoom.value,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
pointerDownState.lastCoords.y = y;
|
pointerDownState.lastCoords.y = y;
|
||||||
return true;
|
return true;
|
||||||
@@ -3616,52 +3685,90 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const maybeGroupAction = actionGroup.contextItemPredicate!(
|
||||||
|
this.actionManager.getElementsIncludingDeleted(),
|
||||||
|
this.actionManager.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const maybeUngroupAction = actionUngroup.contextItemPredicate!(
|
||||||
|
this.actionManager.getElementsIncludingDeleted(),
|
||||||
|
this.actionManager.getAppState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const separator = "separator";
|
||||||
|
|
||||||
|
const _isMobile = isMobile();
|
||||||
|
|
||||||
const elements = this.scene.getElements();
|
const elements = this.scene.getElements();
|
||||||
const element = this.getElementAtPosition(x, y);
|
const element = this.getElementAtPosition(x, y);
|
||||||
|
const options: ContextMenuOption[] = [];
|
||||||
|
if (probablySupportsClipboardBlob && elements.length > 0) {
|
||||||
|
options.push(actionCopyAsPng);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probablySupportsClipboardWriteText && elements.length > 0) {
|
||||||
|
options.push(actionCopyAsSvg);
|
||||||
|
}
|
||||||
if (!element) {
|
if (!element) {
|
||||||
|
const viewModeOptions = [
|
||||||
|
...options,
|
||||||
|
typeof this.props.gridModeEnabled === "undefined" &&
|
||||||
|
actionToggleGridMode,
|
||||||
|
typeof this.props.zenModeEnabled === "undefined" && actionToggleZenMode,
|
||||||
|
typeof this.props.viewModeEnabled === "undefined" &&
|
||||||
|
actionToggleViewMode,
|
||||||
|
actionToggleStats,
|
||||||
|
];
|
||||||
|
|
||||||
|
ContextMenu.push({
|
||||||
|
options: viewModeOptions,
|
||||||
|
top: clientY,
|
||||||
|
left: clientX,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.state.viewModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
navigator.clipboard && {
|
_isMobile &&
|
||||||
shortcutName: "paste",
|
navigator.clipboard && {
|
||||||
label: t("labels.paste"),
|
name: "paste",
|
||||||
action: () => this.pasteFromClipboard(null),
|
perform: (elements, appStates) => {
|
||||||
},
|
this.pasteFromClipboard(null);
|
||||||
|
return {
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.paste",
|
||||||
|
},
|
||||||
|
_isMobile && navigator.clipboard && separator,
|
||||||
probablySupportsClipboardBlob &&
|
probablySupportsClipboardBlob &&
|
||||||
elements.length > 0 && {
|
elements.length > 0 &&
|
||||||
shortcutName: "copyAsPng",
|
actionCopyAsPng,
|
||||||
label: t("labels.copyAsPng"),
|
|
||||||
action: this.copyToClipboardAsPng,
|
|
||||||
},
|
|
||||||
probablySupportsClipboardWriteText &&
|
probablySupportsClipboardWriteText &&
|
||||||
elements.length > 0 && {
|
elements.length > 0 &&
|
||||||
shortcutName: "copyAsSvg",
|
actionCopyAsSvg,
|
||||||
label: t("labels.copyAsSvg"),
|
((probablySupportsClipboardBlob && elements.length > 0) ||
|
||||||
action: this.copyToClipboardAsSvg,
|
(probablySupportsClipboardWriteText && elements.length > 0)) &&
|
||||||
},
|
separator,
|
||||||
...this.actionManager.getContextMenuItems((action) =>
|
actionSelectAll,
|
||||||
CANVAS_ONLY_ACTIONS.includes(action.name),
|
separator,
|
||||||
),
|
typeof this.props.gridModeEnabled === "undefined" &&
|
||||||
{
|
actionToggleGridMode,
|
||||||
checked: this.state.gridSize !== null,
|
typeof this.props.zenModeEnabled === "undefined" &&
|
||||||
shortcutName: "gridMode",
|
actionToggleZenMode,
|
||||||
label: t("labels.gridMode"),
|
typeof this.props.viewModeEnabled === "undefined" &&
|
||||||
action: this.toggleGridMode,
|
actionToggleViewMode,
|
||||||
},
|
actionToggleStats,
|
||||||
{
|
|
||||||
checked: this.state.zenModeEnabled,
|
|
||||||
shortcutName: "zenMode",
|
|
||||||
label: t("buttons.zenMode"),
|
|
||||||
action: this.toggleZenMode,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
checked: this.state.showStats,
|
|
||||||
shortcutName: "stats",
|
|
||||||
label: t("stats.title"),
|
|
||||||
action: this.toggleStats,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
top: clientY,
|
top: clientY,
|
||||||
left: clientX,
|
left: clientX,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3670,39 +3777,55 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.setState({ selectedElementIds: { [element.id]: true } });
|
this.setState({ selectedElementIds: { [element.id]: true } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.state.viewModeEnabled) {
|
||||||
|
ContextMenu.push({
|
||||||
|
options: [navigator.clipboard && actionCopy, ...options],
|
||||||
|
top: clientY,
|
||||||
|
left: clientX,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ContextMenu.push({
|
ContextMenu.push({
|
||||||
options: [
|
options: [
|
||||||
{
|
_isMobile && actionCut,
|
||||||
shortcutName: "cut",
|
_isMobile && navigator.clipboard && actionCopy,
|
||||||
label: t("labels.cut"),
|
_isMobile &&
|
||||||
action: this.cutAll,
|
navigator.clipboard && {
|
||||||
},
|
name: "paste",
|
||||||
navigator.clipboard && {
|
perform: (elements, appStates) => {
|
||||||
shortcutName: "copy",
|
this.pasteFromClipboard(null);
|
||||||
label: t("labels.copy"),
|
return {
|
||||||
action: this.copyAll,
|
commitToHistory: false,
|
||||||
},
|
};
|
||||||
navigator.clipboard && {
|
},
|
||||||
shortcutName: "paste",
|
contextItemLabel: "labels.paste",
|
||||||
label: t("labels.paste"),
|
},
|
||||||
action: () => this.pasteFromClipboard(null),
|
_isMobile && separator,
|
||||||
},
|
...options,
|
||||||
probablySupportsClipboardBlob && {
|
separator,
|
||||||
shortcutName: "copyAsPng",
|
actionCopyStyles,
|
||||||
label: t("labels.copyAsPng"),
|
actionPasteStyles,
|
||||||
action: this.copyToClipboardAsPng,
|
separator,
|
||||||
},
|
maybeGroupAction && actionGroup,
|
||||||
probablySupportsClipboardWriteText && {
|
maybeUngroupAction && actionUngroup,
|
||||||
shortcutName: "copyAsSvg",
|
(maybeGroupAction || maybeUngroupAction) && separator,
|
||||||
label: t("labels.copyAsSvg"),
|
actionAddToLibrary,
|
||||||
action: this.copyToClipboardAsSvg,
|
separator,
|
||||||
},
|
actionSendBackward,
|
||||||
...this.actionManager.getContextMenuItems(
|
actionBringForward,
|
||||||
(action) => !CANVAS_ONLY_ACTIONS.includes(action.name),
|
actionSendToBack,
|
||||||
),
|
actionBringToFront,
|
||||||
|
separator,
|
||||||
|
actionDuplicateSelection,
|
||||||
|
actionDeleteSelected,
|
||||||
],
|
],
|
||||||
top: clientY,
|
top: clientY,
|
||||||
left: clientX,
|
left: clientX,
|
||||||
|
actionManager: this.actionManager,
|
||||||
|
appState: this.state,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3733,9 +3856,15 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newZoom = this.state.zoom.value - delta / 100;
|
||||||
|
// increase zoom steps the more zoomed-in we are (applies to >100% only)
|
||||||
|
newZoom += Math.log10(Math.max(1, this.state.zoom.value)) * -sign;
|
||||||
|
// round to nearest step
|
||||||
|
newZoom = Math.round(newZoom * ZOOM_STEP * 100) / (ZOOM_STEP * 100);
|
||||||
|
|
||||||
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
|
this.setState(({ zoom, offsetLeft, offsetTop }) => ({
|
||||||
zoom: getNewZoom(
|
zoom: getNewZoom(
|
||||||
getNormalizedZoom(zoom.value - delta / 100),
|
getNormalizedZoom(newZoom),
|
||||||
zoom,
|
zoom,
|
||||||
{ left: offsetLeft, top: offsetTop },
|
{ left: offsetLeft, top: offsetTop },
|
||||||
{
|
{
|
||||||
@@ -3758,14 +3887,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.setState(({ zoom, scrollX }) => ({
|
this.setState(({ zoom, scrollX }) => ({
|
||||||
// on Mac, shift+wheel tends to result in deltaX
|
// on Mac, shift+wheel tends to result in deltaX
|
||||||
scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value),
|
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
this.setState(({ zoom, scrollX, scrollY }) => ({
|
||||||
scrollX: normalizeScroll(scrollX - deltaX / zoom.value),
|
scrollX: scrollX - deltaX / zoom.value,
|
||||||
scrollY: normalizeScroll(scrollY - deltaY / zoom.value),
|
scrollY: scrollY - deltaY / zoom.value,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Avatar {
|
.Avatar {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.CollabButton.is-collaborating {
|
.CollabButton.is-collaborating {
|
||||||
background-color: var(--button-special-active-background-color);
|
background-color: var(--button-special-active-bg-color);
|
||||||
|
|
||||||
.ToolIcon__icon svg {
|
.ToolIcon__icon svg,
|
||||||
|
.ToolIcon__label {
|
||||||
color: var(--icon-green-fill-color);
|
color: var(--icon-green-fill-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ const CollabButton = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
icon={users}
|
icon={users}
|
||||||
type="button"
|
type="button"
|
||||||
title={t("buttons.roomDialog")}
|
title={t("labels.liveCollaboration")}
|
||||||
aria-label={t("buttons.roomDialog")}
|
aria-label={t("labels.liveCollaboration")}
|
||||||
showAriaLabel={useIsMobile()}
|
showAriaLabel={useIsMobile()}
|
||||||
>
|
>
|
||||||
{collaboratorCount > 0 && (
|
{collaboratorCount > 0 && (
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.color-picker {
|
.color-picker {
|
||||||
background: var(--popup-background-color);
|
background: var(--popup-bg-color);
|
||||||
border: 0px solid transparentize($oc-white, 0.75);
|
border: 0 solid transparentize($oc-white, 0.75);
|
||||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-triangle {
|
.color-picker-triangle {
|
||||||
width: 0px;
|
width: 0;
|
||||||
height: 0px;
|
height: 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 0px 9px 10px;
|
border-width: 0 9px 10px;
|
||||||
border-color: transparent transparent var(--popup-background-color);
|
border-color: transparent transparent var(--popup-bg-color);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -10px;
|
top: -10px;
|
||||||
|
|
||||||
@@ -84,12 +84,12 @@
|
|||||||
|
|
||||||
.color-picker-transparent {
|
.color-picker-transparent {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
|
box-shadow: transparentize($oc-black, 0.9) 0 0 0 1px inset;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0px;
|
top: 0;
|
||||||
right: 0px;
|
right: 0;
|
||||||
bottom: 0px;
|
bottom: 0;
|
||||||
left: 0px;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-transparent,
|
.color-picker-transparent,
|
||||||
@@ -104,11 +104,11 @@
|
|||||||
width: 1.875rem;
|
width: 1.875rem;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
border-radius: 4px 0px 0px 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
border-radius: 0px 4px 4px 0px;
|
border-radius: 0 4px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
color: var(--input-label-color);
|
color: var(--input-label-color);
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-input-container:focus-within .color-picker-hash::after {
|
.color-input-container:focus-within .color-picker-hash::after {
|
||||||
background: var(--input-background-color);
|
background: var(--input-bg-color);
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
right: -2px;
|
right: -2px;
|
||||||
@@ -163,19 +163,19 @@
|
|||||||
width: 12ch; /* length of `transparent` + 1 */
|
width: 12ch; /* length of `transparent` + 1 */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background-color: var(--input-background-color);
|
background-color: var(--input-bg-color);
|
||||||
color: var(--text-color-primary);
|
color: var(--text-primary-color);
|
||||||
border: 0px;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
height: 1.75em;
|
height: 1.75em;
|
||||||
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
|
box-shadow: var(--input-border-color) 0 0 0 1px inset;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
border-radius: 0px 4px 4px 0px;
|
border-radius: 0 4px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] & {
|
:root[dir="rtl"] & {
|
||||||
border-radius: 4px 0px 0px 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
float: left;
|
float: left;
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||||
color: #fff;
|
color: $oc-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||||
@@ -241,10 +241,10 @@
|
|||||||
|
|
||||||
&.Appearance_dark {
|
&.Appearance_dark {
|
||||||
.color-picker-type-elementBackground .color-picker-keybinding {
|
.color-picker-type-elementBackground .color-picker-keybinding {
|
||||||
color: #000;
|
color: $oc-black;
|
||||||
}
|
}
|
||||||
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
|
||||||
color: #000;
|
color: $oc-black;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
|
box-shadow: 0 3px 10px transparentize($oc-black, 0.8);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
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-bg-color);
|
||||||
border: 1px solid var(--button-gray-3);
|
border: 1px solid var(--button-gray-3);
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu button {
|
.context-menu button {
|
||||||
@@ -60,12 +61,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-option:hover {
|
.context-menu-option:hover {
|
||||||
color: var(--popup-background-color);
|
color: var(--popup-bg-color);
|
||||||
background-color: var(--select-highlight-color);
|
background-color: var(--select-highlight-color);
|
||||||
|
|
||||||
&.dangerous {
|
&.dangerous {
|
||||||
.context-menu-option__label {
|
.context-menu-option__label {
|
||||||
color: var(--popup-background-color);
|
color: var(--popup-bg-color);
|
||||||
}
|
}
|
||||||
background-color: $oc-red-6;
|
background-color: $oc-red-6;
|
||||||
}
|
}
|
||||||
@@ -88,4 +89,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-menu-option-separator {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid $oc-gray-5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,36 @@ import React from "react";
|
|||||||
import { render, unmountComponentAtNode } from "react-dom";
|
import { 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;
|
||||||
checked?: boolean;
|
|
||||||
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");
|
||||||
@@ -43,23 +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, checked, shortcutName, label }, idx) => (
|
{options.map((option, idx) => {
|
||||||
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
|
if (option === "separator") {
|
||||||
<button
|
return <hr key={idx} className="context-menu-option-separator" />;
|
||||||
className={`context-menu-option
|
}
|
||||||
${shortcutName === "delete" ? "dangerous" : ""}
|
|
||||||
${checked ? "checkmark" : ""}`}
|
const actionName = option.name;
|
||||||
onClick={action}
|
const label = option.contextItemLabel
|
||||||
>
|
? t(option.contextItemLabel)
|
||||||
<div className="context-menu-option__label">{label}</div>
|
: "";
|
||||||
<kbd className="context-menu-option__shortcut">
|
return (
|
||||||
{shortcutName
|
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||||
? getShortcutFromShortcutName(shortcutName)
|
<button
|
||||||
: ""}
|
className={clsx("context-menu-option", {
|
||||||
</kbd>
|
dangerous: actionName === "deleteSelectedElements",
|
||||||
</button>
|
checkmark: option.checked?.(appState),
|
||||||
</li>
|
})}
|
||||||
))}
|
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>
|
||||||
@@ -78,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 = () => {
|
||||||
@@ -101,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(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Dialog {
|
.Dialog {
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
padding: calc(var(--space-factor) * 2);
|
padding: calc(var(--space-factor) * 2);
|
||||||
background: var(--bg-color-island);
|
background: var(--island-bg-color);
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
@@ -8,14 +9,6 @@ import { back, close } from "./icons";
|
|||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
|
|
||||||
const useRefState = <T,>() => {
|
|
||||||
const [refValue, setRefValue] = useState<T | null>(null);
|
|
||||||
const refCallback = useCallback((value: T) => {
|
|
||||||
setRefValue(value);
|
|
||||||
}, []);
|
|
||||||
return [refValue, refCallback] as const;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dialog = (props: {
|
export const Dialog = (props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -24,7 +17,7 @@ export const Dialog = (props: {
|
|||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
|
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!islandNode) {
|
if (!islandNode) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ExportDialog__preview {
|
.ExportDialog__preview {
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ const ExportModal = ({
|
|||||||
shouldAddWatermark,
|
shouldAddWatermark,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (canvas instanceof OffscreenCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// if converting to blob fails, there's some problem that will
|
// if converting to blob fails, there's some problem that will
|
||||||
// likely prevent preview and export (e.g. canvas too big)
|
// likely prevent preview and export (e.g. canvas too big)
|
||||||
canvasToBlob(canvas)
|
canvasToBlob(canvas)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.HelpDialog h3 {
|
.HelpDialog h3 {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isDarwin } from "../keys";
|
import { isDarwin, isWindows } from "../keys";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { getShortcutKey } from "../utils";
|
import { getShortcutKey } from "../utils";
|
||||||
import "./HelpDialog.scss";
|
import "./HelpDialog.scss";
|
||||||
@@ -224,9 +224,13 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Alt+Z")]}
|
shortcuts={[getShortcutKey("Alt+Z")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.gridMode")}
|
label={t("labels.showGrid")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut
|
||||||
|
label={t("labels.viewMode")}
|
||||||
|
shortcuts={[getShortcutKey("Alt+R")]}
|
||||||
|
/>
|
||||||
</ShortcutIsland>
|
</ShortcutIsland>
|
||||||
</Column>
|
</Column>
|
||||||
<Column>
|
<Column>
|
||||||
@@ -328,7 +332,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("buttons.redo")}
|
label={t("buttons.redo")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Z")]}
|
shortcuts={
|
||||||
|
isWindows
|
||||||
|
? [
|
||||||
|
getShortcutKey("CtrlOrCmd+Y"),
|
||||||
|
getShortcutKey("CtrlOrCmd+Shift+Z"),
|
||||||
|
]
|
||||||
|
: [getShortcutKey("CtrlOrCmd+Shift+Z")]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.group")}
|
label={t("labels.group")}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
// this is loosely based on the longest hint text
|
// this is loosely based on the longest hint text
|
||||||
$wide-viewport-width: 1000px;
|
$wide-viewport-width: 1000px;
|
||||||
@@ -26,7 +26,7 @@ $wide-viewport-width: 1000px;
|
|||||||
|
|
||||||
> span {
|
> span {
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
background-color: var(--overlay-background-color);
|
background-color: var(--overlay-bg-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.picker-container {
|
.picker-container {
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
background: var(--popup-background-color);
|
background: var(--popup-bg-color);
|
||||||
border: 0px solid transparentize($oc-white, 0.75);
|
border: 0 solid transparentize($oc-white, 0.75);
|
||||||
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
|
box-shadow: transparentize($oc-black, 0.75) 0 1px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.picker-triangle {
|
.picker-triangle {
|
||||||
width: 0px;
|
width: 0;
|
||||||
height: 0px;
|
height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -10px;
|
top: -10px;
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 0px 9px 10px;
|
border-width: 0 9px 10px;
|
||||||
border-color: transparent transparent transparentize($oc-black, 0.9);
|
border-color: transparent transparent transparentize($oc-black, 0.9);
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}
|
}
|
||||||
@@ -82,8 +82,8 @@
|
|||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 0px 9px 10px;
|
border-width: 0 9px 10px;
|
||||||
border-color: transparent transparent var(--popup-background-color);
|
border-color: transparent transparent var(--popup-bg-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.picker-type-elementBackground .picker-keybinding {
|
.picker-type-elementBackground .picker-keybinding {
|
||||||
color: #fff;
|
color: $oc-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||||
@@ -134,10 +134,10 @@
|
|||||||
|
|
||||||
&.Appearance_dark {
|
&.Appearance_dark {
|
||||||
.picker-type-elementBackground .picker-keybinding {
|
.picker-type-elementBackground .picker-keybinding {
|
||||||
color: #000;
|
color: $oc-black;
|
||||||
}
|
}
|
||||||
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
.picker-swatch[aria-label="transparent"] .picker-keybinding {
|
||||||
color: #000;
|
color: $oc-black;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import {
|
import { defaultLang, Language, languages, setLanguage } from "../i18n";
|
||||||
defaultLang,
|
|
||||||
Language,
|
|
||||||
languages,
|
|
||||||
setLanguageFirstTime,
|
|
||||||
} from "../i18n";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
@@ -23,7 +18,7 @@ export class InitializeApp extends React.Component<Props, State> {
|
|||||||
const currentLang =
|
const currentLang =
|
||||||
languages.find((lang) => lang.code === this.props.langCode) ||
|
languages.find((lang) => lang.code === this.props.langCode) ||
|
||||||
defaultLang;
|
defaultLang;
|
||||||
await setLanguageFirstTime(currentLang);
|
await setLanguage(currentLang);
|
||||||
this.setState({
|
this.setState({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Island {
|
.Island {
|
||||||
--padding: 0;
|
--padding: 0;
|
||||||
background-color: var(--bg-color-island);
|
background-color: var(--island-bg-color);
|
||||||
backdrop-filter: saturate(100%) blur(10px);
|
backdrop-filter: saturate(100%) blur(10px);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
margin-left: auto;
|
margin-inline-start: auto;
|
||||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||||
padding-right: 18px;
|
padding-inline-end: 18px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,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;
|
||||||
@@ -71,7 +73,7 @@
|
|||||||
&__footer {
|
&__footer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
bottom: 0px;
|
bottom: 0;
|
||||||
|
|
||||||
:root[dir="ltr"] & {
|
:root[dir="ltr"] & {
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="ltr"] &.transition-right {
|
:root[dir="ltr"] &.transition-right {
|
||||||
transform: translate(999px, 0px);
|
transform: translate(999px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[dir="rtl"] &.transition-left {
|
:root[dir="rtl"] &.transition-left {
|
||||||
|
|||||||
+137
-70
@@ -11,7 +11,7 @@ import { CLASSES } from "../constants";
|
|||||||
import { exportCanvas } from "../data";
|
import { exportCanvas } from "../data";
|
||||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||||
import { Library } from "../data/library";
|
import { Library } from "../data/library";
|
||||||
import { showSelectedShapeActions } from "../element";
|
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import useIsMobile from "../is-mobile";
|
import useIsMobile from "../is-mobile";
|
||||||
@@ -27,7 +27,7 @@ import { ExportCB, ExportDialog } from "./ExportDialog";
|
|||||||
import { FixedSideContainer } from "./FixedSideContainer";
|
import { FixedSideContainer } from "./FixedSideContainer";
|
||||||
import { GitHubCorner } from "./GitHubCorner";
|
import { GitHubCorner } from "./GitHubCorner";
|
||||||
import { HintViewer } from "./HintViewer";
|
import { HintViewer } from "./HintViewer";
|
||||||
import { exportFile, load, shield } from "./icons";
|
import { exportFile, load, shield, trash } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import "./LayerUI.scss";
|
import "./LayerUI.scss";
|
||||||
import { LibraryUnit } from "./LibraryUnit";
|
import { LibraryUnit } from "./LibraryUnit";
|
||||||
@@ -52,6 +52,7 @@ interface LayerUIProps {
|
|||||||
onLockToggle: () => void;
|
onLockToggle: () => void;
|
||||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
|
showExitZenModeBtn: boolean;
|
||||||
toggleZenMode: () => void;
|
toggleZenMode: () => void;
|
||||||
langCode: Language["code"];
|
langCode: Language["code"];
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
@@ -61,6 +62,7 @@ interface LayerUIProps {
|
|||||||
canvas: HTMLCanvasElement | null,
|
canvas: HTMLCanvasElement | null,
|
||||||
) => void;
|
) => void;
|
||||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
|
viewModeEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOnClickOutside = (
|
const useOnClickOutside = (
|
||||||
@@ -98,6 +100,7 @@ const LibraryMenuItems = ({
|
|||||||
onInsertShape,
|
onInsertShape,
|
||||||
pendingElements,
|
pendingElements,
|
||||||
setAppState,
|
setAppState,
|
||||||
|
setLibraryItems,
|
||||||
}: {
|
}: {
|
||||||
library: LibraryItems;
|
library: LibraryItems;
|
||||||
pendingElements: LibraryItem;
|
pendingElements: LibraryItem;
|
||||||
@@ -105,6 +108,7 @@ const LibraryMenuItems = ({
|
|||||||
onInsertShape: (elements: LibraryItem) => void;
|
onInsertShape: (elements: LibraryItem) => void;
|
||||||
onAddToLibrary: (elements: LibraryItem) => void;
|
onAddToLibrary: (elements: LibraryItem) => void;
|
||||||
setAppState: React.Component<any, AppState>["setState"];
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
setLibraryItems: (library: LibraryItems) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
|
||||||
@@ -148,6 +152,19 @@ const LibraryMenuItems = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<ToolButton
|
||||||
|
key="reset"
|
||||||
|
type="button"
|
||||||
|
title={t("buttons.resetLibrary")}
|
||||||
|
aria-label={t("buttons.resetLibrary")}
|
||||||
|
icon={trash}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||||
|
Library.resetLibrary();
|
||||||
|
setLibraryItems([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
|
<a href="https://libraries.excalidraw.com" target="_excalidraw_libraries">
|
||||||
{t("labels.libraries")}
|
{t("labels.libraries")}
|
||||||
@@ -279,6 +296,7 @@ const LibraryMenu = ({
|
|||||||
onInsertShape={onInsertShape}
|
onInsertShape={onInsertShape}
|
||||||
pendingElements={pendingElements}
|
pendingElements={pendingElements}
|
||||||
setAppState={setAppState}
|
setAppState={setAppState}
|
||||||
|
setLibraryItems={setLibraryItems}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Island>
|
</Island>
|
||||||
@@ -295,10 +313,12 @@ const LayerUI = ({
|
|||||||
onLockToggle,
|
onLockToggle,
|
||||||
onInsertElements,
|
onInsertElements,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
|
showExitZenModeBtn,
|
||||||
toggleZenMode,
|
toggleZenMode,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
onExportToBackend,
|
onExportToBackend,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
|
viewModeEnabled,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -358,6 +378,28 @@ const LayerUI = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderViewModeCanvasActions = () => {
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
heading="canvasActions"
|
||||||
|
className={clsx("zen-mode-transition", {
|
||||||
|
"transition-left": zenModeEnabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
|
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||||
|
<Island padding={2} style={{ zIndex: 1 }}>
|
||||||
|
<Stack.Col gap={4}>
|
||||||
|
<Stack.Row gap={1} justifyContent="space-between">
|
||||||
|
{actionManager.renderAction("saveScene")}
|
||||||
|
{actionManager.renderAction("saveAsScene")}
|
||||||
|
{renderExportDialog()}
|
||||||
|
</Stack.Row>
|
||||||
|
</Stack.Col>
|
||||||
|
</Island>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
const renderCanvasActions = () => (
|
const renderCanvasActions = () => (
|
||||||
<Section
|
<Section
|
||||||
heading="canvasActions"
|
heading="canvasActions"
|
||||||
@@ -400,7 +442,15 @@ const LayerUI = ({
|
|||||||
"transition-left": zenModeEnabled,
|
"transition-left": zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
|
<Island
|
||||||
|
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||||
|
padding={2}
|
||||||
|
style={{
|
||||||
|
// we want to make sure this doesn't overflow so substracting 200
|
||||||
|
// which is approximately height of zoom footer and top left menu items with some buffer
|
||||||
|
maxHeight: `${appState.height - 200}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectedShapeActions
|
<SelectedShapeActions
|
||||||
appState={appState}
|
appState={appState}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
@@ -448,54 +498,59 @@ 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,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{Array.from(appState.collaborators)
|
{appState.collaborators.size > 0 &&
|
||||||
// Collaborator is either not initialized or is actually the current user.
|
Array.from(appState.collaborators)
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
.map(([clientId, client]) => (
|
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||||
<Tooltip
|
.map(([clientId, client]) => (
|
||||||
label={client.username || "Unknown user"}
|
<Tooltip
|
||||||
key={clientId}
|
label={client.username || "Unknown user"}
|
||||||
>
|
key={clientId}
|
||||||
{actionManager.renderAction("goToCollaborator", clientId)}
|
>
|
||||||
</Tooltip>
|
{actionManager.renderAction("goToCollaborator", clientId)}
|
||||||
))}
|
</Tooltip>
|
||||||
|
))}
|
||||||
</UserList>
|
</UserList>
|
||||||
</div>
|
</div>
|
||||||
</FixedSideContainer>
|
</FixedSideContainer>
|
||||||
@@ -524,6 +579,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
|
||||||
@@ -533,27 +602,16 @@ const LayerUI = ({
|
|||||||
>
|
>
|
||||||
{renderCustomFooter?.(false)}
|
{renderCustomFooter?.(false)}
|
||||||
{actionManager.renderAction("toggleShortcuts")}
|
{actionManager.renderAction("toggleShortcuts")}
|
||||||
|
{actionManager.renderAction("toggleMinimap")}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={clsx("disable-zen-mode", {
|
className={clsx("disable-zen-mode", {
|
||||||
"disable-zen-mode--visible": zenModeEnabled,
|
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||||
})}
|
})}
|
||||||
onClick={toggleZenMode}
|
onClick={toggleZenMode}
|
||||||
>
|
>
|
||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
</button>
|
</button>
|
||||||
{appState.scrolledOutside && (
|
|
||||||
<button
|
|
||||||
className="scroll-back-to-content"
|
|
||||||
onClick={() => {
|
|
||||||
setAppState({
|
|
||||||
...calculateScrollCenter(elements, appState, canvas),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("buttons.scrollBackToContent")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -599,26 +657,35 @@ const LayerUI = ({
|
|||||||
canvas={canvas}
|
canvas={canvas}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
renderCustomFooter={renderCustomFooter}
|
renderCustomFooter={renderCustomFooter}
|
||||||
|
viewModeEnabled={viewModeEnabled}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="layer-ui__wrapper">
|
<div
|
||||||
|
className={clsx("layer-ui__wrapper", {
|
||||||
|
"disable-pointerEvents":
|
||||||
|
appState.draggingElement ||
|
||||||
|
appState.resizingElement ||
|
||||||
|
(appState.editingElement && !isTextElement(appState.editingElement)),
|
||||||
|
})}
|
||||||
|
>
|
||||||
{dialogs}
|
{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()}
|
||||||
|
{appState.scrolledOutside && (
|
||||||
|
<button
|
||||||
|
className="scroll-back-to-content"
|
||||||
|
onClick={() => {
|
||||||
|
setAppState({
|
||||||
|
...calculateScrollCenter(elements, appState, canvas),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.Island.MiniMap {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50px;
|
||||||
|
right: calc(var(--space-factor) * 4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import "./MiniMap.scss";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useMemo, useState } from "react";
|
||||||
|
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||||
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { AppState } from "../types";
|
||||||
|
import { distance, viewportCoordsToSceneCoords } from "../utils";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
// eslint-disable-next-line import/no-webpack-loader-syntax
|
||||||
|
import MinimapWorker from "worker-loader!../renderer/minimapWorker";
|
||||||
|
|
||||||
|
const RATIO = 1.2;
|
||||||
|
const MINIMAP_HEIGHT = 150;
|
||||||
|
const MINIMAP_WIDTH = MINIMAP_HEIGHT * RATIO;
|
||||||
|
|
||||||
|
const MinimapViewport = ({
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
}: {
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
appState: AppState;
|
||||||
|
}) => {
|
||||||
|
const [minX, minY, maxX, maxY] = useMemo(
|
||||||
|
() => getCommonBounds(getNonDeletedElements(elements)),
|
||||||
|
[elements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const minimapScale = Math.min(
|
||||||
|
MINIMAP_WIDTH / distance(minX, maxX),
|
||||||
|
MINIMAP_HEIGHT / distance(minY, maxY),
|
||||||
|
);
|
||||||
|
|
||||||
|
const leftTop = viewportCoordsToSceneCoords(
|
||||||
|
{ clientX: 0, clientY: 0 },
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
const rightBot = viewportCoordsToSceneCoords(
|
||||||
|
{ clientX: appState.width, clientY: appState.height },
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const top = (leftTop.y - minY) * minimapScale;
|
||||||
|
const left = (leftTop.x - minX) * minimapScale;
|
||||||
|
const width = (rightBot.x - leftTop.x) * minimapScale;
|
||||||
|
const height = (rightBot.y - leftTop.y) * minimapScale;
|
||||||
|
|
||||||
|
// Set viewport boundaries
|
||||||
|
const viewportTop = Math.min(Math.max(0, top), MINIMAP_HEIGHT);
|
||||||
|
const viewportLeft = Math.min(Math.max(0, left), MINIMAP_WIDTH);
|
||||||
|
const viewportWidth = Math.min(
|
||||||
|
MINIMAP_WIDTH - viewportLeft,
|
||||||
|
width,
|
||||||
|
width + left,
|
||||||
|
);
|
||||||
|
const viewportHeight = Math.min(
|
||||||
|
MINIMAP_HEIGHT - viewportTop,
|
||||||
|
height,
|
||||||
|
height + top,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isNaN(viewportTop) ||
|
||||||
|
Number.isNaN(viewportLeft) ||
|
||||||
|
Number.isNaN(viewportWidth) ||
|
||||||
|
Number.isNaN(viewportHeight)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "2px solid orange",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
position: "absolute",
|
||||||
|
pointerEvents: "none",
|
||||||
|
top: viewportTop,
|
||||||
|
left: viewportLeft,
|
||||||
|
width: viewportWidth,
|
||||||
|
height: viewportHeight,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MiniMap({
|
||||||
|
appState,
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
appState: AppState;
|
||||||
|
elements: readonly ExcalidrawElement[];
|
||||||
|
}) {
|
||||||
|
const [minimapWorker] = useState(() => new MinimapWorker());
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const elementsRef = useRef(elements);
|
||||||
|
elementsRef.current = elements;
|
||||||
|
const appStateRef = useRef(appState);
|
||||||
|
appStateRef.current = appState;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offscreenCanvas = canvas.transferControlToOffscreen();
|
||||||
|
|
||||||
|
minimapWorker.postMessage({ type: "INIT", canvas: offscreenCanvas }, [
|
||||||
|
offscreenCanvas,
|
||||||
|
]);
|
||||||
|
|
||||||
|
minimapWorker.postMessage({
|
||||||
|
type: "DRAW",
|
||||||
|
elements: elementsRef.current,
|
||||||
|
appState: appStateRef.current,
|
||||||
|
width: MINIMAP_WIDTH,
|
||||||
|
height: MINIMAP_HEIGHT,
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
minimapWorker.postMessage({
|
||||||
|
type: "DRAW",
|
||||||
|
elements: elementsRef.current,
|
||||||
|
appState: appStateRef.current,
|
||||||
|
width: MINIMAP_WIDTH,
|
||||||
|
height: MINIMAP_HEIGHT,
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
minimapWorker.terminate();
|
||||||
|
};
|
||||||
|
}, [minimapWorker]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Island padding={1} className="MiniMap">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: MINIMAP_WIDTH,
|
||||||
|
height: MINIMAP_HEIGHT,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
backgroundColor: appState.viewBackgroundColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
<MinimapViewport elements={elements} appState={appState} />
|
||||||
|
</div>
|
||||||
|
</Island>
|
||||||
|
);
|
||||||
|
}
|
||||||
+162
-116
@@ -29,6 +29,7 @@ type MobileMenuProps = {
|
|||||||
canvas: HTMLCanvasElement | null;
|
canvas: HTMLCanvasElement | null;
|
||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
|
||||||
|
viewModeEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MobileMenu = ({
|
export const MobileMenu = ({
|
||||||
@@ -43,121 +44,166 @@ export const MobileMenu = ({
|
|||||||
canvas,
|
canvas,
|
||||||
isCollaborating,
|
isCollaborating,
|
||||||
renderCustomFooter,
|
renderCustomFooter,
|
||||||
}: MobileMenuProps) => (
|
viewModeEnabled,
|
||||||
<>
|
}: 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}
|
|
||||||
/>
|
/>
|
||||||
{renderCustomFooter?.(true)}
|
</Stack.Row>
|
||||||
<fieldset>
|
{libraryMenu}
|
||||||
<legend>{t("labels.collaborators")}</legend>
|
</Stack.Col>
|
||||||
<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={() => {
|
|
||||||
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)}
|
||||||
|
{appState.collaborators.size > 0 && (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.collaborators")}</legend>
|
||||||
|
<UserList mobile>
|
||||||
|
{Array.from(appState.collaborators)
|
||||||
|
// Collaborator is either not initialized or is actually the current user.
|
||||||
|
.filter(
|
||||||
|
([_, client]) => Object.keys(client).length !== 0,
|
||||||
|
)
|
||||||
|
.map(([clientId, client]) => (
|
||||||
|
<React.Fragment key={clientId}>
|
||||||
|
{actionManager.renderAction(
|
||||||
|
"goToCollaborator",
|
||||||
|
clientId,
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</UserList>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
) : appState.openMenu === "shape" &&
|
||||||
|
!viewModeEnabled &&
|
||||||
|
showSelectedShapeActions(appState, elements) ? (
|
||||||
|
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||||
|
<SelectedShapeActions
|
||||||
|
appState={appState}
|
||||||
|
elements={elements}
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
elementType={appState.elementType}
|
||||||
|
/>
|
||||||
|
</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,8 +1,13 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
&.excalidraw-modal-container {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
.Modal {
|
.Modal {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.Modal__background {
|
.Modal__background {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -39,10 +44,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
// for modals, reset blurry bg
|
// for modals, reset blurry bg
|
||||||
background: var(--bg-color-island);
|
background: var(--island-bg-color);
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
|
|
||||||
border: 1px solid var(--dialog-border);
|
border: 1px solid var(--dialog-border-color);
|
||||||
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
box-shadow: 0 2px 10px transparentize($oc-black, 0.75);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.Modal__content {
|
.Modal__content {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const useBodyRoot = () => {
|
|||||||
?.classList.contains("Appearance_dark");
|
?.classList.contains("Appearance_dark");
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
|
|
||||||
div.classList.add("excalidraw");
|
div.classList.add("excalidraw", "excalidraw-modal-container");
|
||||||
|
|
||||||
if (isDarkTheme) {
|
if (isDarkTheme) {
|
||||||
div.classList.add("Appearance_dark");
|
div.classList.add("Appearance_dark");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.PasteChartDialog {
|
.PasteChartDialog {
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
.excalidraw {
|
.excalidraw {
|
||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popover .cover {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-41
@@ -1,51 +1,53 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.Stats {
|
.excalidraw {
|
||||||
position: fixed;
|
.Stats {
|
||||||
top: 64px;
|
position: absolute;
|
||||||
right: 12px;
|
top: 64px;
|
||||||
font-size: 12px;
|
right: 12px;
|
||||||
z-index: 999;
|
font-size: 12px;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 24px 8px 0;
|
margin: 0 24px 8px 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
float: right;
|
float: right;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
svg {
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
th {
|
||||||
|
border-bottom: 1px solid var(--input-border-color);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td:nth-child(2) {
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
:root[dir="rtl"] & {
|
||||||
width: 100%;
|
left: 12px;
|
||||||
th {
|
right: initial;
|
||||||
border-bottom: 1px solid var(--input-border-color);
|
|
||||||
padding: 4px;
|
h3 {
|
||||||
}
|
margin: 0 0 8px 24px;
|
||||||
tr {
|
}
|
||||||
td:nth-child(2) {
|
.close {
|
||||||
min-width: 24px;
|
float: left;
|
||||||
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}>
|
||||||
@@ -156,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,19 +1,19 @@
|
|||||||
@import "../css/_variables.scss";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.TextInput {
|
.TextInput {
|
||||||
color: var(--text-color-primary);
|
color: var(--text-primary-color);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1.5px solid var(--button-gray-1);
|
border: 1.5px solid var(--button-gray-1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: var(--space-factor);
|
border-radius: var(--space-factor);
|
||||||
background-color: var(--input-background-color);
|
background-color: var(--input-bg-color);
|
||||||
|
|
||||||
&:not(:focus) {
|
&:not(:focus) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--input-hover-background-color);
|
background-color: var(--input-hover-bg-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Toast {
|
.Toast {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
margin-left: -150px;
|
margin-left: -150px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
position: fixed;
|
position: absolute;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
z-index: 999999;
|
z-index: 999999;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
@import "../css/variables";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.ToolIcon {
|
.ToolIcon {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../css/_variables";
|
@import "../css/variables.module";
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Tooltip {
|
.Tooltip {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -48,15 +48,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -2,8 +2,8 @@ import { FontFamily } from "./element/types";
|
|||||||
|
|
||||||
export const APP_NAME = "Excalidraw";
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
export const DRAGGING_THRESHOLD = 10; // 10px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 10; // 10px
|
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
||||||
@@ -46,6 +46,8 @@ export enum EVENT {
|
|||||||
TOUCH_START = "touchstart",
|
TOUCH_START = "touchstart",
|
||||||
TOUCH_END = "touchend",
|
TOUCH_END = "touchend",
|
||||||
HASHCHANGE = "hashchange",
|
HASHCHANGE = "hashchange",
|
||||||
|
VISIBILITY_CHANGE = "visibilitychange",
|
||||||
|
SCROLL = "scroll",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ENV = {
|
export const ENV = {
|
||||||
@@ -90,4 +92,18 @@ 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 TITLE_TIMEOUT = 10000;
|
||||||
export const TOAST_TIMEOUT = 5000;
|
export const TOAST_TIMEOUT = 5000;
|
||||||
export const VERSION_TIMEOUT = 15000;
|
export const VERSION_TIMEOUT = 30000;
|
||||||
|
export const SCROLL_TIMEOUT = 500;
|
||||||
|
|
||||||
|
export const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
// Report a user inactive after IDLE_THRESHOLD milliseconds
|
||||||
|
export const IDLE_THRESHOLD = 60_000;
|
||||||
|
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||||
|
export const ACTIVE_THRESHOLD = 3_000;
|
||||||
|
|
||||||
|
export const MODES = {
|
||||||
|
VIEW: "viewMode",
|
||||||
|
ZEN: "zenMode",
|
||||||
|
GRID: "gridMode",
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
+38
-18
@@ -1,10 +1,15 @@
|
|||||||
@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-primary-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -30,6 +35,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 {
|
||||||
@@ -43,10 +50,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.FixedSideContainer {
|
.FixedSideContainer {
|
||||||
padding-top: var(--sat, 0px);
|
padding-top: var(--sat, 0);
|
||||||
padding-right: var(--sar, 0px);
|
padding-right: var(--sar, 0);
|
||||||
padding-bottom: var(--sab, 0px);
|
padding-bottom: var(--sab, 0);
|
||||||
padding-left: var(--sal, 0px);
|
padding-left: var(--sal, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelRow {
|
.panelRow {
|
||||||
@@ -64,7 +71,7 @@
|
|||||||
margin-top: 0.333rem;
|
margin-top: 0.333rem;
|
||||||
margin-bottom: 0.333rem;
|
margin-bottom: 0.333rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-color-primary);
|
color: var(--text-primary-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -216,6 +223,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.App-top-bar {
|
||||||
|
z-index: var(--zIndex-layerUI);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.App-bottom-bar {
|
.App-bottom-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -223,10 +237,10 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
--bar-padding: calc(4 * var(--space-factor));
|
--bar-padding: calc(4 * var(--space-factor));
|
||||||
padding-top: #{"max(var(--bar-padding), var(--sat, 0px))"};
|
padding-top: #{"max(var(--bar-padding), var(--sat,0))"};
|
||||||
padding-right: var(--sar, 0px);
|
padding-right: var(--sar, 0);
|
||||||
padding-bottom: var(--sab, 0px);
|
padding-bottom: var(--sab, 0);
|
||||||
padding-left: var(--sal, 0px);
|
padding-left: var(--sal, 0);
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -243,7 +257,7 @@
|
|||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
|
|
||||||
.panelColumn {
|
.panelColumn {
|
||||||
padding: 8px 8px 0px 8px;
|
padding: 8px 8px 0 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +361,6 @@
|
|||||||
|
|
||||||
.App-menu__left {
|
.App-menu__left {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: calc(100vh - 236px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-select {
|
.dropdown-select {
|
||||||
@@ -419,7 +432,7 @@
|
|||||||
|
|
||||||
.scroll-back-to-content {
|
.scroll-back-to-content {
|
||||||
color: var(--popup-text-color);
|
color: var(--popup-text-color);
|
||||||
position: fixed;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
@@ -447,7 +460,7 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.scroll-back-to-content {
|
.scroll-back-to-content {
|
||||||
bottom: calc(80px + var(--sab, 0px));
|
bottom: calc(80px + var(--sab, 0));
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,6 +505,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,
|
||||||
|
|||||||
+42
-42
@@ -1,43 +1,43 @@
|
|||||||
@import "open-color/open-color.scss";
|
@import "open-color/open-color.scss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color-island: rgba(255, 255, 255, 0.9);
|
--appearance-filter: none;
|
||||||
--popup-background-color: #{$oc-white};
|
--button-destructive-bg-color: #{$oc-red-1};
|
||||||
--space-factor: 0.25rem;
|
--button-destructive-color: #{$oc-red-9};
|
||||||
--button-gray-1: #{$oc-gray-2};
|
--button-gray-1: #{$oc-gray-2};
|
||||||
--button-gray-2: #{$oc-gray-4};
|
--button-gray-2: #{$oc-gray-4};
|
||||||
--button-gray-3: #{$oc-gray-5};
|
--button-gray-3: #{$oc-gray-5};
|
||||||
--input-border-color: #{$oc-gray-3};
|
--button-special-active-bg-color: #{$oc-green-0};
|
||||||
--input-background-color: #{$oc-white};
|
--dialog-border-color: #{$oc-gray-6};
|
||||||
--input-hover-background-color: #{$oc-gray-1};
|
--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>');
|
||||||
--input-label-color: #{$oc-gray-7};
|
--focus-highlight-color: #{$oc-blue-2};
|
||||||
--icon-fill-color: #{$oc-black};
|
--icon-fill-color: #{$oc-black};
|
||||||
--icon-green-fill-color: #{$oc-green-9};
|
--icon-green-fill-color: #{$oc-green-9};
|
||||||
|
--input-bg-color: #{$oc-white};
|
||||||
|
--input-border-color: #{$oc-gray-3};
|
||||||
|
--input-hover-bg-color: #{$oc-gray-1};
|
||||||
|
--input-label-color: #{$oc-gray-7};
|
||||||
|
--island-bg-color: rgba(255, 255, 255, 0.9);
|
||||||
--keybinding-color: #{$oc-gray-5};
|
--keybinding-color: #{$oc-gray-5};
|
||||||
--sat: env(safe-area-inset-top);
|
--link-color: #{$oc-blue-7};
|
||||||
|
--overlay-bg-color: #{transparentize($oc-white, 0.12)};
|
||||||
|
--popup-bg-color: #{$oc-white};
|
||||||
|
--popup-secondary-bg-color: #{$oc-gray-1};
|
||||||
|
--popup-text-color: #{$oc-black};
|
||||||
|
--popup-text-inverted-color: #{$oc-white};
|
||||||
--sab: env(safe-area-inset-bottom);
|
--sab: env(safe-area-inset-bottom);
|
||||||
--sal: env(safe-area-inset-left);
|
--sal: env(safe-area-inset-left);
|
||||||
--sar: env(safe-area-inset-right);
|
--sar: env(safe-area-inset-right);
|
||||||
--text-color-primary: #{$oc-gray-8};
|
--sat: env(safe-area-inset-top);
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
|
||||||
--overlay-background-color: #{transparentize($oc-white, 0.12)};
|
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
|
||||||
--focus-highlight-color: #{$oc-blue-2};
|
|
||||||
--select-highlight-color: #{$oc-blue-5};
|
--select-highlight-color: #{$oc-blue-5};
|
||||||
--appearance-filter: none;
|
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.85)};
|
||||||
--button-special-active-background-color: #{$oc-green-0};
|
--space-factor: 0.25rem;
|
||||||
--button-destructive-color: #{$oc-red-9};
|
--text-primary-color: #{$oc-gray-8};
|
||||||
--button-destructive-background-color: #{$oc-red-1};
|
|
||||||
--popup-secondary-background-color: #{$oc-gray-1};
|
|
||||||
--popup-text-color: #{$oc-black};
|
|
||||||
--popup-text-inverted-color: #{$oc-white};
|
|
||||||
--dialog-border: #{$oc-gray-6};
|
|
||||||
--link-color: #{$oc-blue-7};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
&.Appearance_dark {
|
&.Appearance_dark {
|
||||||
background: #000;
|
background: $oc-black;
|
||||||
|
|
||||||
&.Appearance_dark-background-none {
|
&.Appearance_dark-background-none {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -45,31 +45,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.Appearance_dark {
|
&.Appearance_dark {
|
||||||
--text-color-primary: #{$oc-gray-4};
|
--appearance-filter: invert(93%) hue-rotate(180deg);
|
||||||
--bg-color-island: #1e1e1e;
|
--button-destructive-bg-color: #5a0000;
|
||||||
--popup-background-color: #2c2c2c;
|
--button-destructive-color: #{$oc-red-3};
|
||||||
--button-gray-1: #363636;
|
--button-gray-1: #363636;
|
||||||
--button-gray-2: #272727;
|
--button-gray-2: #272727;
|
||||||
--button-gray-3: #222;
|
--button-gray-3: #222;
|
||||||
--input-border-color: #2e2e2e;
|
--button-special-active-bg-color: #204624;
|
||||||
--input-background-color: #121212;
|
--dialog-border-color: #{$oc-gray-9};
|
||||||
--input-hover-background-color: #181818;
|
|
||||||
--input-label-color: #{$oc-gray-2};
|
|
||||||
--icon-fill-color: #{$oc-gray-4};
|
|
||||||
--icon-green-fill-color: #{$oc-green-4};
|
|
||||||
--keybinding-color: #{$oc-gray-6};
|
|
||||||
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
|
||||||
--overlay-background-color: rgba(30, 30, 30, 0.88);
|
|
||||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
--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};
|
--icon-fill-color: #{$oc-gray-4};
|
||||||
--appearance-filter: invert(93%) hue-rotate(180deg);
|
--icon-green-fill-color: #{$oc-green-4};
|
||||||
--button-special-active-background-color: #204624;
|
--input-bg-color: #121212;
|
||||||
--button-destructive-color: #{$oc-red-3};
|
--input-border-color: #2e2e2e;
|
||||||
--button-destructive-background-color: #5a0000;
|
--input-hover-bg-color: #181818;
|
||||||
--popup-secondary-background-color: #222;
|
--input-label-color: #{$oc-gray-2};
|
||||||
|
--island-bg-color: #1e1e1e;
|
||||||
|
--keybinding-color: #{$oc-gray-6};
|
||||||
|
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||||
|
--popup-bg-color: #2c2c2c;
|
||||||
|
--popup-secondary-bg-color: #222;
|
||||||
--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};
|
--select-highlight-color: #{$oc-blue-4};
|
||||||
|
--shadow-island: 0 1px 5px #{transparentize($oc-black, 0.7)};
|
||||||
|
--text-primary-color: #{$oc-gray-4};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,7 @@
|
|||||||
|
|
||||||
// keep up to date with is-mobile.tsx
|
// keep up to date with is-mobile.tsx
|
||||||
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
$is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)";
|
||||||
|
|
||||||
|
:export {
|
||||||
|
isMobileQuery: unquote($is-mobile-query);
|
||||||
|
}
|
||||||
+6
-1
@@ -1,4 +1,4 @@
|
|||||||
import { fileSave } from "browser-nativefs";
|
import { fileSave } from "browser-fs-access";
|
||||||
import {
|
import {
|
||||||
copyCanvasToClipboardAsPng,
|
copyCanvasToClipboardAsPng,
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
@@ -73,6 +73,11 @@ export const exportCanvas = async (
|
|||||||
scale,
|
scale,
|
||||||
shouldAddWatermark,
|
shouldAddWatermark,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (tempCanvas instanceof OffscreenCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tempCanvas.style.display = "none";
|
tempCanvas.style.display = "none";
|
||||||
document.body.appendChild(tempCanvas);
|
document.body.appendChild(tempCanvas);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { fileOpen, fileSave } from "browser-nativefs";
|
import { fileOpen, fileSave } from "browser-fs-access";
|
||||||
import { cleanAppStateForExport } from "../appState";
|
import { cleanAppStateForExport } from "../appState";
|
||||||
import { MIME_TYPES } from "../constants";
|
import { MIME_TYPES } from "../constants";
|
||||||
import { clearElementsForExport } from "../element";
|
import { clearElementsForExport } from "../element";
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@ export const restoreElements = (
|
|||||||
}, [] as ExcalidrawElement[]);
|
}, [] as ExcalidrawElement[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreAppState = (
|
export const restoreAppState = (
|
||||||
appState: ImportedDataState["appState"],
|
appState: ImportedDataState["appState"],
|
||||||
localAppState: Partial<AppState> | null,
|
localAppState: Partial<AppState> | null,
|
||||||
): AppState => {
|
): AppState => {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export class LinearElementEditor {
|
|||||||
isDragging &&
|
isDragging &&
|
||||||
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
|
(activePointIndex === 0 || activePointIndex === element.points.length - 1)
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(element.points)) {
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
LinearElementEditor.movePoint(
|
LinearElementEditor.movePoint(
|
||||||
element,
|
element,
|
||||||
activePointIndex,
|
activePointIndex,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const showSelectedShapeActions = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
) =>
|
) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
appState.editingElement ||
|
!appState.viewModeEnabled &&
|
||||||
getSelectedElements(elements, appState).length ||
|
(appState.editingElement ||
|
||||||
appState.elementType !== "selection",
|
getSelectedElements(elements, appState).length ||
|
||||||
|
appState.elementType !== "selection"),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const textWysiwyg = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
getViewportCoords,
|
getViewportCoords,
|
||||||
element,
|
element,
|
||||||
|
canvas,
|
||||||
}: {
|
}: {
|
||||||
id: ExcalidrawElement["id"];
|
id: ExcalidrawElement["id"];
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -45,6 +46,7 @@ export const textWysiwyg = ({
|
|||||||
onSubmit: (text: string) => void;
|
onSubmit: (text: string) => void;
|
||||||
getViewportCoords: (x: number, y: number) => [number, number];
|
getViewportCoords: (x: number, y: number) => [number, number];
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
|
canvas: HTMLCanvasElement | null;
|
||||||
}) => {
|
}) => {
|
||||||
const updateWysiwygStyle = () => {
|
const updateWysiwygStyle = () => {
|
||||||
const updatedElement = Scene.getScene(element)?.getElement(id);
|
const updatedElement = Scene.getScene(element)?.getElement(id);
|
||||||
@@ -89,9 +91,6 @@ export const textWysiwyg = ({
|
|||||||
editable.dataset.type = "wysiwyg";
|
editable.dataset.type = "wysiwyg";
|
||||||
// prevent line wrapping on Safari
|
// prevent line wrapping on Safari
|
||||||
editable.wrap = "off";
|
editable.wrap = "off";
|
||||||
editable.className = `excalidraw ${
|
|
||||||
appState.appearance === "dark" ? "Appearance_dark" : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
Object.assign(editable.style, {
|
Object.assign(editable.style, {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -107,6 +106,8 @@ export const textWysiwyg = ({
|
|||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
|
// must be specified because in dark mode canvas creates a stacking context
|
||||||
|
zIndex: "var(--zIndex-wysiwyg)",
|
||||||
});
|
});
|
||||||
|
|
||||||
updateWysiwygStyle();
|
updateWysiwygStyle();
|
||||||
@@ -152,6 +153,10 @@ export const textWysiwyg = ({
|
|||||||
editable.oninput = null;
|
editable.oninput = null;
|
||||||
editable.onkeydown = null;
|
editable.onkeydown = null;
|
||||||
|
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
window.removeEventListener("resize", updateWysiwygStyle);
|
window.removeEventListener("resize", updateWysiwygStyle);
|
||||||
window.removeEventListener("wheel", stopEvent, true);
|
window.removeEventListener("wheel", stopEvent, true);
|
||||||
window.removeEventListener("pointerdown", onPointerDown);
|
window.removeEventListener("pointerdown", onPointerDown);
|
||||||
@@ -160,7 +165,7 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
unbindUpdate();
|
unbindUpdate();
|
||||||
|
|
||||||
document.body.removeChild(editable);
|
editable.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rebindBlur = () => {
|
const rebindBlur = () => {
|
||||||
@@ -198,15 +203,27 @@ export const textWysiwyg = ({
|
|||||||
let isDestroyed = false;
|
let isDestroyed = false;
|
||||||
|
|
||||||
editable.onblur = handleSubmit;
|
editable.onblur = handleSubmit;
|
||||||
// reposition wysiwyg in case of window resize. Happens on mobile when
|
|
||||||
// device keyboard is opened.
|
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
|
||||||
window.addEventListener("resize", updateWysiwygStyle);
|
// is preferred so we catch changes from host, where window may not resize.
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
if (canvas && "ResizeObserver" in window) {
|
||||||
|
observer = new window.ResizeObserver(() => {
|
||||||
|
updateWysiwygStyle();
|
||||||
|
});
|
||||||
|
observer.observe(canvas);
|
||||||
|
} else {
|
||||||
|
window.addEventListener("resize", updateWysiwygStyle);
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("pointerdown", onPointerDown);
|
window.addEventListener("pointerdown", onPointerDown);
|
||||||
window.addEventListener("wheel", stopEvent, {
|
window.addEventListener("wheel", stopEvent, {
|
||||||
passive: false,
|
passive: false,
|
||||||
capture: true,
|
capture: true,
|
||||||
});
|
});
|
||||||
document.body.appendChild(editable);
|
document
|
||||||
|
.querySelector(".excalidraw-textEditorContainer")!
|
||||||
|
.appendChild(editable);
|
||||||
editable.focus();
|
editable.focus();
|
||||||
editable.select();
|
editable.select();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { APP_NAME, ENV, EVENT } from "../../constants";
|
|||||||
import { ImportedDataState } from "../../data/types";
|
import { ImportedDataState } from "../../data/types";
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import {
|
import {
|
||||||
|
getElementMap,
|
||||||
getSceneVersion,
|
getSceneVersion,
|
||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
} from "../../packages/excalidraw/index";
|
} from "../../packages/excalidraw/index";
|
||||||
import { AppState, Collaborator, Gesture } from "../../types";
|
import { Collaborator, Gesture } from "../../types";
|
||||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||||
import {
|
import {
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||||
@@ -18,12 +19,16 @@ import {
|
|||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
import {
|
import {
|
||||||
decryptAESGEM,
|
decryptAESGEM,
|
||||||
generateCollaborationLink,
|
generateCollaborationLinkData,
|
||||||
getCollaborationLinkData,
|
getCollaborationLink,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
SOCKET_SERVER,
|
SOCKET_SERVER,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
|
import {
|
||||||
|
isSavedToFirebase,
|
||||||
|
loadFromFirebase,
|
||||||
|
saveToFirebase,
|
||||||
|
} from "../data/firebase";
|
||||||
import {
|
import {
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
saveUsernameToLocalStorage,
|
saveUsernameToLocalStorage,
|
||||||
@@ -31,20 +36,26 @@ import {
|
|||||||
} from "../data/localStorage";
|
} from "../data/localStorage";
|
||||||
import Portal from "./Portal";
|
import Portal from "./Portal";
|
||||||
import RoomDialog from "./RoomDialog";
|
import RoomDialog from "./RoomDialog";
|
||||||
|
import { createInverseContext } from "../../createInverseContext";
|
||||||
|
import { t } from "../../i18n";
|
||||||
|
import { UserIdleState } from "./types";
|
||||||
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
||||||
|
|
||||||
interface CollabState {
|
interface CollabState {
|
||||||
isCollaborating: boolean;
|
|
||||||
modalIsShown: boolean;
|
modalIsShown: boolean;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
userState: UserIdleState;
|
||||||
activeRoomLink: string;
|
activeRoomLink: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
||||||
|
|
||||||
export interface CollabAPI {
|
export interface CollabAPI {
|
||||||
isCollaborating: CollabState["isCollaborating"];
|
/** function so that we can access the latest value from stale callbacks */
|
||||||
|
isCollaborating: () => boolean;
|
||||||
username: CollabState["username"];
|
username: CollabState["username"];
|
||||||
|
userState: CollabState["userState"];
|
||||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||||
@@ -56,31 +67,41 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: (collab: CollabAPI) => React.ReactNode;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
// NOTE not type-safe because the refObject may in fact not be initialized
|
|
||||||
// with ExcalidrawImperativeAPI yet
|
|
||||||
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
Context: CollabContext,
|
||||||
|
Consumer: CollabContextConsumer,
|
||||||
|
Provider: CollabContextProvider,
|
||||||
|
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
||||||
|
|
||||||
|
export { CollabContext, CollabContextConsumer };
|
||||||
|
|
||||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||||
portal: Portal;
|
portal: Portal;
|
||||||
|
excalidrawAPI: Props["excalidrawAPI"];
|
||||||
|
isCollaborating: boolean = false;
|
||||||
|
activeIntervalId: number | null;
|
||||||
|
idleTimeoutId: number | null;
|
||||||
|
|
||||||
private socketInitializationTimer?: NodeJS.Timeout;
|
private socketInitializationTimer?: NodeJS.Timeout;
|
||||||
private excalidrawRef: Props["excalidrawRef"];
|
|
||||||
excalidrawAppState?: AppState;
|
|
||||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||||
private collaborators = new Map<string, Collaborator>();
|
private collaborators = new Map<string, Collaborator>();
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
isCollaborating: false,
|
|
||||||
modalIsShown: false,
|
modalIsShown: false,
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
username: importUsernameFromLocalStorage() || "",
|
username: importUsernameFromLocalStorage() || "",
|
||||||
|
userState: UserIdleState.ACTIVE,
|
||||||
activeRoomLink: "",
|
activeRoomLink: "",
|
||||||
};
|
};
|
||||||
this.portal = new Portal(this);
|
this.portal = new Portal(this);
|
||||||
this.excalidrawRef = props.excalidrawRef;
|
this.excalidrawAPI = props.excalidrawAPI;
|
||||||
|
this.activeIntervalId = null;
|
||||||
|
this.idleTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -104,18 +125,32 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||||
|
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||||
|
window.removeEventListener(
|
||||||
|
EVENT.VISIBILITY_CHANGE,
|
||||||
|
this.onVisibilityChange,
|
||||||
|
);
|
||||||
|
if (this.activeIntervalId) {
|
||||||
|
window.clearInterval(this.activeIntervalId);
|
||||||
|
this.activeIntervalId = null;
|
||||||
|
}
|
||||||
|
if (this.idleTimeoutId) {
|
||||||
|
window.clearTimeout(this.idleTimeoutId);
|
||||||
|
this.idleTimeoutId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onUnload = () => {
|
private onUnload = () => {
|
||||||
this.destroySocketClient();
|
this.destroySocketClient({ isUnload: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||||
const syncableElements = getSyncableElements(
|
const syncableElements = getSyncableElements(
|
||||||
this.getSceneElementsIncludingDeleted(),
|
this.getSceneElementsIncludingDeleted(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.state.isCollaborating &&
|
this.isCollaborating &&
|
||||||
!isSavedToFirebase(this.portal, syncableElements)
|
!isSavedToFirebase(this.portal, syncableElements)
|
||||||
) {
|
) {
|
||||||
// this won't run in time if user decides to leave the site, but
|
// this won't run in time if user decides to leave the site, but
|
||||||
@@ -127,7 +162,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
event.returnValue = "";
|
event.returnValue = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.isCollaborating || this.portal.roomId) {
|
if (this.isCollaborating || this.portal.roomId) {
|
||||||
try {
|
try {
|
||||||
localStorage?.setItem(
|
localStorage?.setItem(
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||||
@@ -142,7 +177,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
|
|
||||||
saveCollabRoomToFirebase = async (
|
saveCollabRoomToFirebase = async (
|
||||||
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
||||||
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
),
|
),
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
@@ -153,143 +188,188 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
openPortal = async () => {
|
openPortal = async () => {
|
||||||
window.history.pushState({}, APP_NAME, await generateCollaborationLink());
|
return this.initializeSocketClient(null);
|
||||||
const elements = this.excalidrawRef.current!.getSceneElements();
|
|
||||||
// remove deleted elements from elements array & history to ensure we don't
|
|
||||||
// expose potentially sensitive user data in case user manually deletes
|
|
||||||
// existing elements (or clears scene), which would otherwise be persisted
|
|
||||||
// to database even if deleted before creating the room.
|
|
||||||
this.excalidrawRef.current!.history.clear();
|
|
||||||
this.excalidrawRef.current!.updateScene({
|
|
||||||
elements,
|
|
||||||
commitToHistory: true,
|
|
||||||
});
|
|
||||||
return this.initializeSocketClient();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
closePortal = () => {
|
closePortal = () => {
|
||||||
this.saveCollabRoomToFirebase();
|
this.saveCollabRoomToFirebase();
|
||||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
||||||
this.destroySocketClient();
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||||
|
this.destroySocketClient();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private destroySocketClient = () => {
|
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||||
this.collaborators = new Map();
|
if (!opts?.isUnload) {
|
||||||
this.excalidrawRef.current!.updateScene({
|
this.collaborators = new Map();
|
||||||
collaborators: this.collaborators,
|
this.excalidrawAPI.updateScene({
|
||||||
});
|
collaborators: this.collaborators,
|
||||||
this.setState({
|
});
|
||||||
isCollaborating: false,
|
this.setState({
|
||||||
activeRoomLink: "",
|
activeRoomLink: "",
|
||||||
});
|
});
|
||||||
|
this.isCollaborating = false;
|
||||||
|
}
|
||||||
this.portal.close();
|
this.portal.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
|
private initializeSocketClient = async (
|
||||||
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||||
|
): Promise<ImportedDataState | null> => {
|
||||||
if (this.portal.socket) {
|
if (this.portal.socket) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
let roomId;
|
||||||
|
let roomKey;
|
||||||
|
|
||||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
if (existingRoomLinkData) {
|
||||||
|
({ roomId, roomKey } = existingRoomLinkData);
|
||||||
if (roomMatch) {
|
} else {
|
||||||
const roomId = roomMatch[1];
|
({ roomId, roomKey } = await generateCollaborationLinkData());
|
||||||
const roomKey = roomMatch[2];
|
window.history.pushState(
|
||||||
|
{},
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
APP_NAME,
|
||||||
// initial SCENE_UPDATE message
|
getCollaborationLink({ roomId, roomKey }),
|
||||||
this.socketInitializationTimer = setTimeout(() => {
|
|
||||||
this.initializeSocket();
|
|
||||||
scenePromise.resolve(null);
|
|
||||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
|
||||||
|
|
||||||
const { default: socketIOClient }: any = await import(
|
|
||||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
|
||||||
|
|
||||||
// All socket listeners are moving to Portal
|
|
||||||
this.portal.socket!.on(
|
|
||||||
"client-broadcast",
|
|
||||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
|
||||||
if (!this.portal.roomKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const decryptedData = await decryptAESGEM(
|
|
||||||
encryptedData,
|
|
||||||
this.portal.roomKey,
|
|
||||||
iv,
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (decryptedData.type) {
|
|
||||||
case "INVALID_RESPONSE":
|
|
||||||
return;
|
|
||||||
case SCENE.INIT: {
|
|
||||||
if (!this.portal.socketInitialized) {
|
|
||||||
const remoteElements = decryptedData.payload.elements;
|
|
||||||
const reconciledElements = this.reconcileElements(
|
|
||||||
remoteElements,
|
|
||||||
);
|
|
||||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
|
||||||
init: true,
|
|
||||||
});
|
|
||||||
this.initializeSocket();
|
|
||||||
scenePromise.resolve({ elements: reconciledElements });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case SCENE.UPDATE:
|
|
||||||
this.handleRemoteSceneUpdate(
|
|
||||||
this.reconcileElements(decryptedData.payload.elements),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "MOUSE_LOCATION": {
|
|
||||||
const {
|
|
||||||
pointer,
|
|
||||||
button,
|
|
||||||
username,
|
|
||||||
selectedElementIds,
|
|
||||||
} = decryptedData.payload;
|
|
||||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
|
||||||
decryptedData.payload.socketId ||
|
|
||||||
// @ts-ignore legacy, see #2094 (#2097)
|
|
||||||
decryptedData.payload.socketID;
|
|
||||||
|
|
||||||
const collaborators = new Map(this.collaborators);
|
|
||||||
const user = collaborators.get(socketId) || {}!;
|
|
||||||
user.pointer = pointer;
|
|
||||||
user.button = button;
|
|
||||||
user.selectedElementIds = selectedElementIds;
|
|
||||||
user.username = username;
|
|
||||||
collaborators.set(socketId, user);
|
|
||||||
this.excalidrawRef.current!.updateScene({
|
|
||||||
collaborators,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
this.portal.socket!.on("first-in-room", () => {
|
|
||||||
if (this.portal.socket) {
|
|
||||||
this.portal.socket.off("first-in-room");
|
|
||||||
}
|
|
||||||
this.initializeSocket();
|
|
||||||
scenePromise.resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
isCollaborating: true,
|
|
||||||
activeRoomLink: window.location.href,
|
|
||||||
});
|
|
||||||
|
|
||||||
return scenePromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||||
|
|
||||||
|
this.isCollaborating = true;
|
||||||
|
|
||||||
|
const { default: socketIOClient }: any = await import(
|
||||||
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||||
|
);
|
||||||
|
|
||||||
|
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
||||||
|
|
||||||
|
if (existingRoomLinkData) {
|
||||||
|
this.excalidrawAPI.resetScene();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const elements = await loadFromFirebase(
|
||||||
|
roomId,
|
||||||
|
roomKey,
|
||||||
|
this.portal.socket,
|
||||||
|
);
|
||||||
|
if (elements) {
|
||||||
|
scenePromise.resolve({
|
||||||
|
elements,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// log the error and move on. other peers will sync us the scene.
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const elements = this.excalidrawAPI.getSceneElements();
|
||||||
|
// remove deleted elements from elements array & history to ensure we don't
|
||||||
|
// expose potentially sensitive user data in case user manually deletes
|
||||||
|
// existing elements (or clears scene), which would otherwise be persisted
|
||||||
|
// to database even if deleted before creating the room.
|
||||||
|
this.excalidrawAPI.history.clear();
|
||||||
|
this.excalidrawAPI.updateScene({
|
||||||
|
elements,
|
||||||
|
commitToHistory: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
|
// initial SCENE_UPDATE message
|
||||||
|
this.socketInitializationTimer = setTimeout(() => {
|
||||||
|
this.initializeSocket();
|
||||||
|
scenePromise.resolve(null);
|
||||||
|
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||||
|
|
||||||
|
// All socket listeners are moving to Portal
|
||||||
|
this.portal.socket!.on(
|
||||||
|
"client-broadcast",
|
||||||
|
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||||
|
if (!this.portal.roomKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decryptedData = await decryptAESGEM(
|
||||||
|
encryptedData,
|
||||||
|
this.portal.roomKey,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (decryptedData.type) {
|
||||||
|
case "INVALID_RESPONSE":
|
||||||
|
return;
|
||||||
|
case SCENE.INIT: {
|
||||||
|
if (!this.portal.socketInitialized) {
|
||||||
|
this.initializeSocket();
|
||||||
|
const remoteElements = decryptedData.payload.elements;
|
||||||
|
const reconciledElements = this.reconcileElements(remoteElements);
|
||||||
|
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||||
|
init: true,
|
||||||
|
});
|
||||||
|
// noop if already resolved via init from firebase
|
||||||
|
scenePromise.resolve({ elements: reconciledElements });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case SCENE.UPDATE:
|
||||||
|
this.handleRemoteSceneUpdate(
|
||||||
|
this.reconcileElements(decryptedData.payload.elements),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "MOUSE_LOCATION": {
|
||||||
|
const {
|
||||||
|
pointer,
|
||||||
|
button,
|
||||||
|
username,
|
||||||
|
selectedElementIds,
|
||||||
|
} = decryptedData.payload;
|
||||||
|
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||||
|
decryptedData.payload.socketId ||
|
||||||
|
// @ts-ignore legacy, see #2094 (#2097)
|
||||||
|
decryptedData.payload.socketID;
|
||||||
|
|
||||||
|
const collaborators = new Map(this.collaborators);
|
||||||
|
const user = collaborators.get(socketId) || {}!;
|
||||||
|
user.pointer = pointer;
|
||||||
|
user.button = button;
|
||||||
|
user.selectedElementIds = selectedElementIds;
|
||||||
|
user.username = username;
|
||||||
|
collaborators.set(socketId, user);
|
||||||
|
this.excalidrawAPI.updateScene({
|
||||||
|
collaborators,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "IDLE_STATUS": {
|
||||||
|
const { userState, socketId, username } = decryptedData.payload;
|
||||||
|
const collaborators = new Map(this.collaborators);
|
||||||
|
const user = collaborators.get(socketId) || {}!;
|
||||||
|
user.userState = userState;
|
||||||
|
user.username = username;
|
||||||
|
this.excalidrawAPI.updateScene({
|
||||||
|
collaborators,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.portal.socket!.on("first-in-room", () => {
|
||||||
|
if (this.portal.socket) {
|
||||||
|
this.portal.socket.off("first-in-room");
|
||||||
|
}
|
||||||
|
this.initializeSocket();
|
||||||
|
scenePromise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.initializeIdleDetector();
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
activeRoomLink: window.location.href,
|
||||||
|
});
|
||||||
|
|
||||||
|
return scenePromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
private initializeSocket = () => {
|
private initializeSocket = () => {
|
||||||
@@ -300,12 +380,60 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
private reconcileElements = (
|
private reconcileElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): ReconciledElements => {
|
): ReconciledElements => {
|
||||||
const newElements = this.portal.reconcileElements(elements);
|
const currentElements = this.getSceneElementsIncludingDeleted();
|
||||||
|
// create a map of ids so we don't have to iterate
|
||||||
|
// over the array more than once.
|
||||||
|
const localElementMap = getElementMap(currentElements);
|
||||||
|
|
||||||
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
// Reconcile
|
||||||
|
const newElements: readonly ExcalidrawElement[] = elements
|
||||||
|
.reduce((elements, element) => {
|
||||||
|
// if the remote element references one that's currently
|
||||||
|
// edited on local, skip it (it'll be added in the next step)
|
||||||
|
if (
|
||||||
|
element.id === appState.editingElement?.id ||
|
||||||
|
element.id === appState.resizingElement?.id ||
|
||||||
|
element.id === appState.draggingElement?.id
|
||||||
|
) {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
localElementMap.hasOwnProperty(element.id) &&
|
||||||
|
localElementMap[element.id].version > element.version
|
||||||
|
) {
|
||||||
|
elements.push(localElementMap[element.id]);
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
} else if (
|
||||||
|
localElementMap.hasOwnProperty(element.id) &&
|
||||||
|
localElementMap[element.id].version === element.version &&
|
||||||
|
localElementMap[element.id].versionNonce !== element.versionNonce
|
||||||
|
) {
|
||||||
|
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
||||||
|
if (localElementMap[element.id].versionNonce < element.versionNonce) {
|
||||||
|
elements.push(localElementMap[element.id]);
|
||||||
|
} else {
|
||||||
|
// it should be highly unlikely that the two versionNonces are the same. if we are
|
||||||
|
// really worried about this, we can replace the versionNonce with the socket id.
|
||||||
|
elements.push(element);
|
||||||
|
}
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
} else {
|
||||||
|
elements.push(element);
|
||||||
|
delete localElementMap[element.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}, [] as Mutable<typeof elements>)
|
||||||
|
// add local elements that weren't deleted or on remote
|
||||||
|
.concat(...Object.values(localElementMap));
|
||||||
|
|
||||||
// Avoid broadcasting to the rest of the collaborators the scene
|
// Avoid broadcasting to the rest of the collaborators the scene
|
||||||
// we just received!
|
// we just received!
|
||||||
// Note: this needs to be set before updating the scene as it
|
// Note: this needs to be set before updating the scene as it
|
||||||
// syncronously calls render.
|
// synchronously calls render.
|
||||||
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
||||||
|
|
||||||
return newElements as ReconciledElements;
|
return newElements as ReconciledElements;
|
||||||
@@ -319,10 +447,10 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
if (init || initFromSnapshot) {
|
if (init || initFromSnapshot) {
|
||||||
this.excalidrawRef.current!.setScrollToCenter(elements);
|
this.excalidrawAPI.setScrollToCenter(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.excalidrawRef.current!.updateScene({
|
this.excalidrawAPI.updateScene({
|
||||||
elements,
|
elements,
|
||||||
commitToHistory: !!init,
|
commitToHistory: !!init,
|
||||||
});
|
});
|
||||||
@@ -331,7 +459,59 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
||||||
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
||||||
// right now we think this is the right tradeoff.
|
// right now we think this is the right tradeoff.
|
||||||
this.excalidrawRef.current!.history.clear();
|
this.excalidrawAPI.history.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onPointerMove = () => {
|
||||||
|
if (this.idleTimeoutId) {
|
||||||
|
window.clearTimeout(this.idleTimeoutId);
|
||||||
|
this.idleTimeoutId = null;
|
||||||
|
}
|
||||||
|
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||||
|
if (!this.activeIntervalId) {
|
||||||
|
this.activeIntervalId = window.setInterval(
|
||||||
|
this.reportActive,
|
||||||
|
ACTIVE_THRESHOLD,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onVisibilityChange = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
if (this.idleTimeoutId) {
|
||||||
|
window.clearTimeout(this.idleTimeoutId);
|
||||||
|
this.idleTimeoutId = null;
|
||||||
|
}
|
||||||
|
if (this.activeIntervalId) {
|
||||||
|
window.clearInterval(this.activeIntervalId);
|
||||||
|
this.activeIntervalId = null;
|
||||||
|
}
|
||||||
|
this.onIdleStateChange(UserIdleState.AWAY);
|
||||||
|
} else {
|
||||||
|
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||||
|
this.activeIntervalId = window.setInterval(
|
||||||
|
this.reportActive,
|
||||||
|
ACTIVE_THRESHOLD,
|
||||||
|
);
|
||||||
|
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private reportIdle = () => {
|
||||||
|
this.onIdleStateChange(UserIdleState.IDLE);
|
||||||
|
if (this.activeIntervalId) {
|
||||||
|
window.clearInterval(this.activeIntervalId);
|
||||||
|
this.activeIntervalId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private reportActive = () => {
|
||||||
|
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||||
|
};
|
||||||
|
|
||||||
|
private initializeIdleDetector = () => {
|
||||||
|
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||||
|
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||||
};
|
};
|
||||||
|
|
||||||
setCollaborators(sockets: string[]) {
|
setCollaborators(sockets: string[]) {
|
||||||
@@ -347,7 +527,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.collaborators = collaborators;
|
this.collaborators = collaborators;
|
||||||
this.excalidrawRef.current!.updateScene({ collaborators });
|
this.excalidrawAPI.updateScene({ collaborators });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +540,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public getSceneElementsIncludingDeleted = () => {
|
public getSceneElementsIncludingDeleted = () => {
|
||||||
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
|
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
|
||||||
};
|
};
|
||||||
|
|
||||||
onPointerUpdate = (payload: {
|
onPointerUpdate = (payload: {
|
||||||
@@ -373,11 +553,12 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.portal.broadcastMouseLocation(payload);
|
this.portal.broadcastMouseLocation(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
broadcastElements = (
|
onIdleStateChange = (userState: UserIdleState) => {
|
||||||
elements: readonly ExcalidrawElement[],
|
this.setState({ userState });
|
||||||
state: AppState,
|
this.portal.broadcastIdleChange(userState);
|
||||||
) => {
|
};
|
||||||
this.excalidrawAppState = state;
|
|
||||||
|
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (
|
if (
|
||||||
getSceneVersion(elements) >
|
getSceneVersion(elements) >
|
||||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||||
@@ -396,7 +577,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
this.portal.broadcastScene(
|
this.portal.broadcastScene(
|
||||||
SCENE.UPDATE,
|
SCENE.UPDATE,
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -425,8 +606,25 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** PRIVATE. Use `this.getContextValue()` instead. */
|
||||||
|
private contextValue: CollabAPI | null = null;
|
||||||
|
|
||||||
|
/** Getter of context value. Returned object is stable. */
|
||||||
|
getContextValue = (): CollabAPI => {
|
||||||
|
if (!this.contextValue) {
|
||||||
|
this.contextValue = {} as CollabAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contextValue.isCollaborating = () => this.isCollaborating;
|
||||||
|
this.contextValue.username = this.state.username;
|
||||||
|
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
||||||
|
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
||||||
|
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
||||||
|
this.contextValue.broadcastElements = this.broadcastElements;
|
||||||
|
return this.contextValue;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { children } = this.props;
|
|
||||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -450,14 +648,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
|
|||||||
onClose={() => this.setState({ errorMessage: "" })}
|
onClose={() => this.setState({ errorMessage: "" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{children({
|
<CollabContextProvider
|
||||||
isCollaborating: this.state.isCollaborating,
|
value={{
|
||||||
username: this.state.username,
|
api: this.getContextValue(),
|
||||||
onPointerUpdate: this.onPointerUpdate,
|
}}
|
||||||
initializeSocketClient: this.initializeSocketClient,
|
/>
|
||||||
onCollabButtonClick: this.onCollabButtonClick,
|
|
||||||
broadcastElements: this.broadcastElements,
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,21 @@ import {
|
|||||||
|
|
||||||
import CollabWrapper from "./CollabWrapper";
|
import CollabWrapper from "./CollabWrapper";
|
||||||
|
|
||||||
import {
|
import { getSyncableElements } from "../../packages/excalidraw/index";
|
||||||
getElementMap,
|
|
||||||
getSyncableElements,
|
|
||||||
} from "../../packages/excalidraw/index";
|
|
||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import { BROADCAST, SCENE } from "../app_constants";
|
import { BROADCAST, SCENE } from "../app_constants";
|
||||||
|
import { UserIdleState } from "./types";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
app: CollabWrapper;
|
collab: CollabWrapper;
|
||||||
socket: SocketIOClient.Socket | null = null;
|
socket: SocketIOClient.Socket | null = null;
|
||||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||||
roomId: string | null = null;
|
roomId: string | null = null;
|
||||||
roomKey: string | null = null;
|
roomKey: string | null = null;
|
||||||
broadcastedElementVersions: Map<string, number> = new Map();
|
broadcastedElementVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(app: CollabWrapper) {
|
constructor(collab: CollabWrapper) {
|
||||||
this.app = app;
|
this.collab = collab;
|
||||||
}
|
}
|
||||||
|
|
||||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||||
@@ -30,7 +28,7 @@ class Portal {
|
|||||||
this.roomId = id;
|
this.roomId = id;
|
||||||
this.roomKey = key;
|
this.roomKey = key;
|
||||||
|
|
||||||
// Initialize socket listeners (moving from App)
|
// Initialize socket listeners
|
||||||
this.socket.on("init-room", () => {
|
this.socket.on("init-room", () => {
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.emit("join-room", this.roomId);
|
this.socket.emit("join-room", this.roomId);
|
||||||
@@ -39,12 +37,12 @@ class Portal {
|
|||||||
this.socket.on("new-user", async (_socketId: string) => {
|
this.socket.on("new-user", async (_socketId: string) => {
|
||||||
this.broadcastScene(
|
this.broadcastScene(
|
||||||
SCENE.INIT,
|
SCENE.INIT,
|
||||||
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
|
getSyncableElements(this.collab.getSceneElementsIncludingDeleted()),
|
||||||
/* syncAll */ true,
|
/* syncAll */ true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.socket.on("room-user-change", (clients: string[]) => {
|
this.socket.on("room-user-change", (clients: string[]) => {
|
||||||
this.app.setCollaborators(clients);
|
this.collab.setCollaborators(clients);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,16 +123,33 @@ class Portal {
|
|||||||
data as SocketUpdateData,
|
data as SocketUpdateData,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (syncAll && this.app.state.isCollaborating) {
|
if (syncAll && this.collab.isCollaborating) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
broadcastPromise,
|
broadcastPromise,
|
||||||
this.app.saveCollabRoomToFirebase(syncableElements),
|
this.collab.saveCollabRoomToFirebase(syncableElements),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await broadcastPromise;
|
await broadcastPromise;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
broadcastIdleChange = (userState: UserIdleState) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||||
|
type: "IDLE_STATUS",
|
||||||
|
payload: {
|
||||||
|
socketId: this.socket.id,
|
||||||
|
userState,
|
||||||
|
username: this.collab.state.username,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this._broadcastSocketData(
|
||||||
|
data as SocketUpdateData,
|
||||||
|
true, // volatile
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
broadcastMouseLocation = (payload: {
|
broadcastMouseLocation = (payload: {
|
||||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||||
@@ -146,9 +161,9 @@ class Portal {
|
|||||||
socketId: this.socket.id,
|
socketId: this.socket.id,
|
||||||
pointer: payload.pointer,
|
pointer: payload.pointer,
|
||||||
button: payload.button || "up",
|
button: payload.button || "up",
|
||||||
selectedElementIds:
|
selectedElementIds: this.collab.excalidrawAPI.getAppState()
|
||||||
this.app.excalidrawAppState?.selectedElementIds || {},
|
.selectedElementIds,
|
||||||
username: this.app.state.username,
|
username: this.collab.state.username,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return this._broadcastSocketData(
|
return this._broadcastSocketData(
|
||||||
@@ -157,62 +172,6 @@ class Portal {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
reconcileElements = (
|
|
||||||
sceneElements: readonly ExcalidrawElement[],
|
|
||||||
): readonly ExcalidrawElement[] => {
|
|
||||||
const currentElements = this.app.getSceneElementsIncludingDeleted();
|
|
||||||
// create a map of ids so we don't have to iterate
|
|
||||||
// over the array more than once.
|
|
||||||
const localElementMap = getElementMap(currentElements);
|
|
||||||
|
|
||||||
// Reconcile
|
|
||||||
return (
|
|
||||||
sceneElements
|
|
||||||
.reduce((elements, element) => {
|
|
||||||
// if the remote element references one that's currently
|
|
||||||
// edited on local, skip it (it'll be added in the next step)
|
|
||||||
if (
|
|
||||||
element.id === this.app.excalidrawAppState?.editingElement?.id ||
|
|
||||||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
|
|
||||||
element.id === this.app.excalidrawAppState?.draggingElement?.id
|
|
||||||
) {
|
|
||||||
return elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
localElementMap.hasOwnProperty(element.id) &&
|
|
||||||
localElementMap[element.id].version > element.version
|
|
||||||
) {
|
|
||||||
elements.push(localElementMap[element.id]);
|
|
||||||
delete localElementMap[element.id];
|
|
||||||
} else if (
|
|
||||||
localElementMap.hasOwnProperty(element.id) &&
|
|
||||||
localElementMap[element.id].version === element.version &&
|
|
||||||
localElementMap[element.id].versionNonce !== element.versionNonce
|
|
||||||
) {
|
|
||||||
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
|
|
||||||
if (
|
|
||||||
localElementMap[element.id].versionNonce < element.versionNonce
|
|
||||||
) {
|
|
||||||
elements.push(localElementMap[element.id]);
|
|
||||||
} else {
|
|
||||||
// it should be highly unlikely that the two versionNonces are the same. if we are
|
|
||||||
// really worried about this, we can replace the versionNonce with the socket id.
|
|
||||||
elements.push(element);
|
|
||||||
}
|
|
||||||
delete localElementMap[element.id];
|
|
||||||
} else {
|
|
||||||
elements.push(element);
|
|
||||||
delete localElementMap[element.id];
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements;
|
|
||||||
}, [] as Mutable<typeof sceneElements>)
|
|
||||||
// add local elements that weren't deleted or on remote
|
|
||||||
.concat(...Object.values(localElementMap))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Portal;
|
export default Portal;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "../../css/_variables";
|
@import "../../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.RoomDialog-linkContainer {
|
.RoomDialog-linkContainer {
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.RoomDialog-link {
|
.RoomDialog-link {
|
||||||
color: var(--text-color-primary);
|
color: var(--text-primary-color);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
margin-inline-start: 1em;
|
margin-inline-start: 1em;
|
||||||
@@ -32,15 +32,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
.RoomDialog-usernameLabel {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.RoomDialog-username {
|
.RoomDialog-username {
|
||||||
background-color: var(--input-background-color);
|
background-color: var(--input-bg-color);
|
||||||
border-color: var(--input-border-color);
|
border-color: var(--input-border-color);
|
||||||
appearance: none;
|
appearance: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
margin-inline-start: 1em;
|
margin-inline-start: 1em;
|
||||||
|
@media #{$is-mobile-query} {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-inline-start: 0;
|
||||||
|
}
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -53,7 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.Modal .RoomDialog-stopSession {
|
.Modal .RoomDialog-stopSession {
|
||||||
background-color: var(--button-destructive-background-color);
|
background-color: var(--button-destructive-bg-color);
|
||||||
|
|
||||||
.ToolIcon__label,
|
.ToolIcon__label,
|
||||||
.ToolIcon__icon svg {
|
.ToolIcon__icon svg {
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ const RoomDialog = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog small onCloseRequest={handleClose} title={t("labels.createRoom")}>
|
<Dialog
|
||||||
|
small
|
||||||
|
onCloseRequest={handleClose}
|
||||||
|
title={t("labels.liveCollaboration")}
|
||||||
|
>
|
||||||
{renderRoomDialog()}
|
{renderRoomDialog()}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum UserIdleState {
|
||||||
|
ACTIVE = "active",
|
||||||
|
AWAY = "away",
|
||||||
|
IDLE = "idle",
|
||||||
|
}
|
||||||
@@ -148,6 +148,7 @@ export const saveToFirebase = async (
|
|||||||
export const loadFromFirebase = async (
|
export const loadFromFirebase = async (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
|
socket: SocketIOClient.Socket | null,
|
||||||
): Promise<readonly ExcalidrawElement[] | null> => {
|
): Promise<readonly ExcalidrawElement[] | null> => {
|
||||||
const firebase = await getFirebase();
|
const firebase = await getFirebase();
|
||||||
const db = firebase.firestore();
|
const db = firebase.firestore();
|
||||||
@@ -160,5 +161,12 @@ export const loadFromFirebase = async (
|
|||||||
const storedScene = doc.data() as FirebaseStoredScene;
|
const storedScene = doc.data() as FirebaseStoredScene;
|
||||||
const ciphertext = storedScene.ciphertext.toUint8Array();
|
const ciphertext = storedScene.ciphertext.toUint8Array();
|
||||||
const iv = storedScene.iv.toUint8Array();
|
const iv = storedScene.iv.toUint8Array();
|
||||||
return restoreElements(await decryptElements(roomKey, iv, ciphertext));
|
|
||||||
|
const elements = await decryptElements(roomKey, iv, ciphertext);
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreElements(elements);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ImportedDataState } from "../../data/types";
|
|||||||
import { ExcalidrawElement } from "../../element/types";
|
import { ExcalidrawElement } from "../../element/types";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { AppState } from "../../types";
|
import { AppState } from "../../types";
|
||||||
|
import { UserIdleState } from "../collab/types";
|
||||||
|
|
||||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||||
|
|
||||||
@@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
|
|||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IDLE_STATUS: {
|
||||||
|
type: "IDLE_STATUS";
|
||||||
|
payload: {
|
||||||
|
socketId: string;
|
||||||
|
userState: UserIdleState;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SocketUpdateDataIncoming =
|
export type SocketUpdateDataIncoming =
|
||||||
@@ -125,17 +134,27 @@ export const decryptAESGEM = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCollaborationLinkData = (link: string) => {
|
export const getCollaborationLinkData = (link: string) => {
|
||||||
if (link.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hash = new URL(link).hash;
|
const hash = new URL(link).hash;
|
||||||
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||||
|
return match ? { roomId: match[1], roomKey: match[2] } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateCollaborationLink = async () => {
|
export const generateCollaborationLinkData = async () => {
|
||||||
const id = await generateRandomID();
|
const roomId = await generateRandomID();
|
||||||
const key = await generateEncryptionKey();
|
const roomKey = await generateEncryptionKey();
|
||||||
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
|
|
||||||
|
if (!roomKey) {
|
||||||
|
throw new Error("Couldn't generate room key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roomId, roomKey };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCollaborationLink = (data: {
|
||||||
|
roomId: string;
|
||||||
|
roomKey: string;
|
||||||
|
}) => {
|
||||||
|
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getImportedKey = (key: string, usage: KeyUsage) =>
|
export const getImportedKey = (key: string, usage: KeyUsage) =>
|
||||||
|
|||||||
+58
-129
@@ -1,6 +1,7 @@
|
|||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -17,12 +18,13 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
|
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||||
import { Language, t } from "../i18n";
|
import { Language, t } from "../i18n";
|
||||||
import Excalidraw, {
|
import Excalidraw, {
|
||||||
defaultLang,
|
defaultLang,
|
||||||
languages,
|
languages,
|
||||||
} from "../packages/excalidraw/index";
|
} from "../packages/excalidraw/index";
|
||||||
import { AppState, ExcalidrawAPIRefValue } from "../types";
|
import { AppState } from "../types";
|
||||||
import {
|
import {
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -30,14 +32,16 @@ import {
|
|||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
|
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
|
||||||
import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
|
import CollabWrapper, {
|
||||||
|
CollabAPI,
|
||||||
|
CollabContext,
|
||||||
|
CollabContextConsumer,
|
||||||
|
} from "./collab/CollabWrapper";
|
||||||
import { LanguageList } from "./components/LanguageList";
|
import { LanguageList } from "./components/LanguageList";
|
||||||
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
|
||||||
import { loadFromFirebase } from "./data/firebase";
|
|
||||||
import {
|
import {
|
||||||
importFromLocalStorage,
|
importFromLocalStorage,
|
||||||
saveToLocalStorage,
|
saveToLocalStorage,
|
||||||
STORAGE_KEYS,
|
|
||||||
} from "./data/localStorage";
|
} from "./data/localStorage";
|
||||||
|
|
||||||
const languageDetector = new LanguageDetector();
|
const languageDetector = new LanguageDetector();
|
||||||
@@ -49,15 +53,6 @@ languageDetector.init({
|
|||||||
checkWhitelist: false,
|
checkWhitelist: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const excalidrawRef: React.MutableRefObject<
|
|
||||||
MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
|
|
||||||
> = {
|
|
||||||
current: {
|
|
||||||
readyPromise: resolvablePromise(),
|
|
||||||
ready: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveDebounced = debounce(
|
const saveDebounced = debounce(
|
||||||
(elements: readonly ExcalidrawElement[], state: AppState) => {
|
(elements: readonly ExcalidrawElement[], state: AppState) => {
|
||||||
saveToLocalStorage(elements, state);
|
saveToLocalStorage(elements, state);
|
||||||
@@ -69,50 +64,9 @@ const onBlur = () => {
|
|||||||
saveDebounced.flush();
|
saveDebounced.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldForceLoadScene = (
|
|
||||||
scene: ResolutionType<typeof loadScene>,
|
|
||||||
): boolean => {
|
|
||||||
if (!scene.elements.length) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
|
||||||
|
|
||||||
if (!roomMatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomId = roomMatch[1];
|
|
||||||
|
|
||||||
let collabForceLoadFlag;
|
|
||||||
try {
|
|
||||||
collabForceLoadFlag = localStorage?.getItem(
|
|
||||||
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (collabForceLoadFlag) {
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
room: previousRoom,
|
|
||||||
timestamp,
|
|
||||||
}: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
|
|
||||||
// if loading same room as the one previously unloaded within 15sec
|
|
||||||
// force reload without prompting
|
|
||||||
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Scene = ImportedDataState & { commitToHistory: boolean };
|
|
||||||
|
|
||||||
const initializeScene = async (opts: {
|
const initializeScene = async (opts: {
|
||||||
resetScene: ExcalidrawImperativeAPI["resetScene"];
|
collabAPI: CollabAPI;
|
||||||
initializeSocketClient: CollabAPI["initializeSocketClient"];
|
}): Promise<ImportedDataState | null> => {
|
||||||
}): Promise<Scene | null> => {
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const jsonMatch = window.location.hash.match(
|
const jsonMatch = window.location.hash.match(
|
||||||
@@ -123,11 +77,15 @@ const initializeScene = async (opts: {
|
|||||||
|
|
||||||
let scene = await loadScene(null, null, initialData);
|
let scene = await loadScene(null, null, initialData);
|
||||||
|
|
||||||
let isCollabScene = !!getCollaborationLinkData(window.location.href);
|
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||||
const isExternalScene = !!(id || jsonMatch || isCollabScene);
|
const isExternalScene = !!(id || jsonMatch || roomLinkData);
|
||||||
if (isExternalScene) {
|
if (isExternalScene) {
|
||||||
if (
|
if (
|
||||||
shouldForceLoadScene(scene) ||
|
// don't prompt if scene is empty
|
||||||
|
!scene.elements.length ||
|
||||||
|
// don't prompt for collab scenes because we don't override local storage
|
||||||
|
roomLinkData ||
|
||||||
|
// otherwise, prompt whether user wants to override current scene
|
||||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||||
) {
|
) {
|
||||||
// Backwards compatibility with legacy url format
|
// Backwards compatibility with legacy url format
|
||||||
@@ -136,7 +94,7 @@ const initializeScene = async (opts: {
|
|||||||
} else if (jsonMatch) {
|
} else if (jsonMatch) {
|
||||||
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
|
||||||
}
|
}
|
||||||
if (!isCollabScene) {
|
if (!roomLinkData) {
|
||||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -153,45 +111,19 @@ const initializeScene = async (opts: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isCollabScene = false;
|
roomLinkData = null;
|
||||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isCollabScene) {
|
if (roomLinkData) {
|
||||||
// when joining a room we don't want user's local scene data to be merged
|
return opts.collabAPI.initializeSocketClient(roomLinkData);
|
||||||
// into the remote scene
|
|
||||||
opts.resetScene();
|
|
||||||
const scenePromise = opts.initializeSocketClient();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [, roomId, roomKey] = getCollaborationLinkData(
|
|
||||||
window.location.href,
|
|
||||||
)!;
|
|
||||||
const elements = await loadFromFirebase(roomId, roomKey);
|
|
||||||
if (elements) {
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(await scenePromise),
|
|
||||||
commitToHistory: true,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// log the error and move on. other peers will sync us the scene.
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} else if (scene) {
|
} else if (scene) {
|
||||||
return scene;
|
return scene;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
function ExcalidrawWrapper() {
|
||||||
// dimensions
|
// dimensions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -226,38 +158,35 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
|
initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { collab } = props;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Delayed so that the app has a time to load the latest SW
|
// Delayed so that the app has a time to load the latest SW
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
trackEvent("load", "version", getVersion());
|
trackEvent("load", "version", getVersion());
|
||||||
}, VERSION_TIMEOUT);
|
}, VERSION_TIMEOUT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
|
const [
|
||||||
initializeScene({
|
excalidrawAPI,
|
||||||
resetScene: excalidrawApi.resetScene,
|
excalidrawRefCallback,
|
||||||
initializeSocketClient: collab.initializeSocketClient,
|
] = useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||||
}).then((scene) => {
|
|
||||||
initialStatePromiseRef.current.promise.resolve(scene);
|
const collabAPI = useContext(CollabContext)?.api;
|
||||||
});
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!collabAPI || !excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeScene({ collabAPI }).then((scene) => {
|
||||||
|
initialStatePromiseRef.current.promise.resolve(scene);
|
||||||
});
|
});
|
||||||
|
|
||||||
const onHashChange = (_: HashChangeEvent) => {
|
const onHashChange = (_: HashChangeEvent) => {
|
||||||
const api = excalidrawRef.current!;
|
initializeScene({ collabAPI }).then((scene) => {
|
||||||
if (!api.ready) {
|
if (scene) {
|
||||||
return;
|
excalidrawAPI.updateScene(scene);
|
||||||
}
|
}
|
||||||
if (window.location.hash.length > 1) {
|
});
|
||||||
initializeScene({
|
|
||||||
resetScene: api.resetScene,
|
|
||||||
initializeSocketClient: collab.initializeSocketClient,
|
|
||||||
}).then((scene) => {
|
|
||||||
if (scene) {
|
|
||||||
api.updateScene(scene);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleTimeout = setTimeout(
|
const titleTimeout = setTimeout(
|
||||||
@@ -273,7 +202,7 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
window.removeEventListener(EVENT.BLUR, onBlur, false);
|
||||||
clearTimeout(titleTimeout);
|
clearTimeout(titleTimeout);
|
||||||
};
|
};
|
||||||
}, [collab.initializeSocketClient]);
|
}, [collabAPI, excalidrawAPI]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
languageDetector.cacheUserLanguage(langCode);
|
languageDetector.cacheUserLanguage(langCode);
|
||||||
@@ -283,9 +212,13 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
saveDebounced(elements, appState);
|
if (collabAPI?.isCollaborating()) {
|
||||||
if (collab.isCollaborating) {
|
collabAPI.broadcastElements(elements);
|
||||||
collab.broadcastElements(elements, appState);
|
} else {
|
||||||
|
// collab scenes are persisted to the server, so we don't have to persist
|
||||||
|
// them locally, which has the added benefit of not overwriting whatever
|
||||||
|
// the user was working on before joining
|
||||||
|
saveDebounced(elements, appState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,19 +276,19 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
ref={excalidrawRef}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
user={{ name: collab.username }}
|
onCollabButtonClick={collabAPI?.onCollabButtonClick}
|
||||||
onCollabButtonClick={collab.onCollabButtonClick}
|
isCollaborating={collabAPI?.isCollaborating()}
|
||||||
isCollaborating={collab.isCollaborating}
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||||
onPointerUpdate={collab.onPointerUpdate}
|
|
||||||
onExportToBackend={onExportToBackend}
|
onExportToBackend={onExportToBackend}
|
||||||
renderFooter={renderFooter}
|
renderFooter={renderFooter}
|
||||||
langCode={langCode}
|
langCode={langCode}
|
||||||
/>
|
/>
|
||||||
|
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
message={errorMessage}
|
message={errorMessage}
|
||||||
@@ -369,13 +302,9 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
|
|||||||
export default function ExcalidrawApp() {
|
export default function ExcalidrawApp() {
|
||||||
return (
|
return (
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<CollabWrapper
|
<CollabContextConsumer>
|
||||||
excalidrawRef={
|
<ExcalidrawWrapper />
|
||||||
excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
|
</CollabContextConsumer>
|
||||||
}
|
|
||||||
>
|
|
||||||
{(collab) => <ExcalidrawWrapper collab={collab} />}
|
|
||||||
</CollabWrapper>
|
|
||||||
</TopErrorBoundary>
|
</TopErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,11 +1,10 @@
|
|||||||
import { PointerCoords } from "./types";
|
import { PointerCoords } from "./types";
|
||||||
import { normalizeScroll } from "./scene";
|
|
||||||
|
|
||||||
export const getCenter = (pointers: Map<number, PointerCoords>) => {
|
export const getCenter = (pointers: Map<number, PointerCoords>) => {
|
||||||
const allCoords = Array.from(pointers.values());
|
const allCoords = Array.from(pointers.values());
|
||||||
return {
|
return {
|
||||||
x: normalizeScroll(sum(allCoords, (coords) => coords.x) / allCoords.length),
|
x: sum(allCoords, (coords) => coords.x) / allCoords.length,
|
||||||
y: normalizeScroll(sum(allCoords, (coords) => coords.y) / allCoords.length),
|
y: sum(allCoords, (coords) => coords.y) / allCoords.length,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+12
-1
@@ -85,6 +85,17 @@ type ForwardRef<T, P = any> = Parameters<
|
|||||||
// --------------------------------------------------------------------------—
|
// --------------------------------------------------------------------------—
|
||||||
|
|
||||||
interface Blob {
|
interface Blob {
|
||||||
handle?: import("browser-nativefs").FileSystemHandle;
|
handle?: import("browser-fs-acces").FileSystemHandle;
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "worker-loader!*" {
|
||||||
|
// You need to change `Worker`, if you specified a different value for the `workerType` option
|
||||||
|
class WebpackWorker extends Worker {
|
||||||
|
constructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment this if you set the `esModule` option to `false`
|
||||||
|
// export = WebpackWorker;
|
||||||
|
export default WebpackWorker;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export const useCallbackRefState = <T>() => {
|
||||||
|
const [refValue, setRefValue] = useState<T | null>(null);
|
||||||
|
const refCallback = useCallback((value: T | null) => setRefValue(value), []);
|
||||||
|
return [refValue, refCallback] as const;
|
||||||
|
};
|
||||||
+29
-12
@@ -1,5 +1,6 @@
|
|||||||
import fallbackLangData from "./locales/en.json";
|
import fallbackLangData from "./locales/en.json";
|
||||||
import percentages from "./locales/percentages.json";
|
import percentages from "./locales/percentages.json";
|
||||||
|
import { ENV } from "./constants";
|
||||||
|
|
||||||
const COMPLETION_THRESHOLD = 85;
|
const COMPLETION_THRESHOLD = 85;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const allLanguages: Language[] = [
|
|||||||
{ code: "id-ID", label: "Bahasa Indonesia" },
|
{ code: "id-ID", label: "Bahasa Indonesia" },
|
||||||
{ code: "it-IT", label: "Italiano" },
|
{ code: "it-IT", label: "Italiano" },
|
||||||
{ code: "ja-JP", label: "日本語" },
|
{ code: "ja-JP", label: "日本語" },
|
||||||
|
{ code: "kab-KAB", label: "Taqbaylit" },
|
||||||
{ code: "ko-KR", label: "한국어" },
|
{ code: "ko-KR", label: "한국어" },
|
||||||
{ code: "my-MM", label: "Burmese" },
|
{ code: "my-MM", label: "Burmese" },
|
||||||
{ code: "nb-NO", label: "Norsk bokmål" },
|
{ code: "nb-NO", label: "Norsk bokmål" },
|
||||||
@@ -54,25 +56,33 @@ export const languages: Language[] = allLanguages
|
|||||||
COMPLETION_THRESHOLD,
|
COMPLETION_THRESHOLD,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TEST_LANG_CODE = "__test__";
|
||||||
|
if (process.env.NODE_ENV === ENV.DEVELOPMENT) {
|
||||||
|
languages.unshift(
|
||||||
|
{ code: TEST_LANG_CODE, label: "test language" },
|
||||||
|
{
|
||||||
|
code: `${TEST_LANG_CODE}.rtl`,
|
||||||
|
label: "\u{202a}test language (rtl)\u{202c}",
|
||||||
|
rtl: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let currentLang: Language = defaultLang;
|
let currentLang: Language = defaultLang;
|
||||||
let currentLangData = {};
|
let currentLangData = {};
|
||||||
|
|
||||||
export const setLanguage = async (lang: Language) => {
|
export const setLanguage = async (lang: Language) => {
|
||||||
currentLang = lang;
|
currentLang = lang;
|
||||||
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||||
|
document.documentElement.lang = currentLang.code;
|
||||||
|
|
||||||
currentLangData = await import(
|
if (lang.code.startsWith(TEST_LANG_CODE)) {
|
||||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
currentLangData = {};
|
||||||
);
|
} else {
|
||||||
};
|
currentLangData = await import(
|
||||||
|
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
||||||
export const setLanguageFirstTime = async (lang: Language) => {
|
);
|
||||||
currentLang = lang;
|
}
|
||||||
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
|
||||||
|
|
||||||
currentLangData = await import(
|
|
||||||
/* webpackChunkName: "i18n-[request]" */ `./locales/${currentLang.code}.json`
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLanguage = () => currentLang;
|
export const getLanguage = () => currentLang;
|
||||||
@@ -92,6 +102,13 @@ const findPartsForData = (data: any, parts: string[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||||
|
if (currentLang.code.startsWith(TEST_LANG_CODE)) {
|
||||||
|
const name = replacement
|
||||||
|
? `${path}(${JSON.stringify(replacement).slice(1, -1)})`
|
||||||
|
: path;
|
||||||
|
return `\u{202a}[[${name}]]\u{202c}`;
|
||||||
|
}
|
||||||
|
|
||||||
const parts = path.split(".");
|
const parts = path.split(".");
|
||||||
let translation =
|
let translation =
|
||||||
findPartsForData(currentLangData, parts) ||
|
findPartsForData(currentLangData, parts) ||
|
||||||
|
|||||||
+14
-10
@@ -1,7 +1,18 @@
|
|||||||
import React, { useState, useEffect, useRef, useContext } from "react";
|
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||||
|
import variables from "./css/variables.module.scss";
|
||||||
|
|
||||||
const context = React.createContext(false);
|
const context = React.createContext(false);
|
||||||
|
|
||||||
|
const getIsMobileMatcher = () => {
|
||||||
|
return window.matchMedia
|
||||||
|
? window.matchMedia(variables.isMobileQuery)
|
||||||
|
: (({
|
||||||
|
matches: false,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
} as any) as MediaQueryList);
|
||||||
|
};
|
||||||
|
|
||||||
export const IsMobileProvider = ({
|
export const IsMobileProvider = ({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -9,16 +20,7 @@ export const IsMobileProvider = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const query = useRef<MediaQueryList>();
|
const query = useRef<MediaQueryList>();
|
||||||
if (!query.current) {
|
if (!query.current) {
|
||||||
query.current = window.matchMedia
|
query.current = getIsMobileMatcher();
|
||||||
? window.matchMedia(
|
|
||||||
// keep up to date with _variables.scss
|
|
||||||
"(max-width: 640px), (max-height: 500px) and (max-width: 1000px)",
|
|
||||||
)
|
|
||||||
: (({
|
|
||||||
matches: false,
|
|
||||||
addListener: () => {},
|
|
||||||
removeListener: () => {},
|
|
||||||
} as any) as MediaQueryList);
|
|
||||||
}
|
}
|
||||||
const [isMobile, setMobile] = useState(query.current.matches);
|
const [isMobile, setMobile] = useState(query.current.matches);
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ export const IsMobileProvider = ({
|
|||||||
return <context.Provider value={isMobile}>{children}</context.Provider>;
|
return <context.Provider value={isMobile}>{children}</context.Provider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isMobile = () => getIsMobileMatcher().matches;
|
||||||
|
|
||||||
export default function useIsMobile() {
|
export default function useIsMobile() {
|
||||||
return useContext(context);
|
return useContext(context);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,4 +1,7 @@
|
|||||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
|
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(
|
||||||
|
globalThis.navigator.platform,
|
||||||
|
);
|
||||||
|
export const isWindows = /^Win/.test(globalThis.navigator.platform);
|
||||||
|
|
||||||
export const CODES = {
|
export const CODES = {
|
||||||
EQUAL: "Equal",
|
EQUAL: "Equal",
|
||||||
@@ -18,7 +21,9 @@ export const CODES = {
|
|||||||
F: "KeyF",
|
F: "KeyF",
|
||||||
H: "KeyH",
|
H: "KeyH",
|
||||||
V: "KeyV",
|
V: "KeyV",
|
||||||
|
X: "KeyX",
|
||||||
Z: "KeyZ",
|
Z: "KeyZ",
|
||||||
|
R: "KeyR",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const KEYS = {
|
export const KEYS = {
|
||||||
@@ -40,6 +45,7 @@ export const KEYS = {
|
|||||||
D: "d",
|
D: "d",
|
||||||
E: "e",
|
E: "e",
|
||||||
L: "l",
|
L: "l",
|
||||||
|
M: "m",
|
||||||
O: "o",
|
O: "o",
|
||||||
P: "p",
|
P: "p",
|
||||||
Q: "q",
|
Q: "q",
|
||||||
@@ -48,6 +54,7 @@ export const KEYS = {
|
|||||||
T: "t",
|
T: "t",
|
||||||
V: "v",
|
V: "v",
|
||||||
X: "x",
|
X: "x",
|
||||||
|
Y: "y",
|
||||||
Z: "z",
|
Z: "z",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
+17
-8
@@ -68,7 +68,7 @@
|
|||||||
"layers": "الطبقات",
|
"layers": "الطبقات",
|
||||||
"actions": "الإجراءات",
|
"actions": "الإجراءات",
|
||||||
"language": "اللغة",
|
"language": "اللغة",
|
||||||
"createRoom": "مشاركة الجلسة مباشرة",
|
"liveCollaboration": "",
|
||||||
"duplicateSelection": "تكرار",
|
"duplicateSelection": "تكرار",
|
||||||
"untitled": "غير معنون",
|
"untitled": "غير معنون",
|
||||||
"name": "الاسم",
|
"name": "الاسم",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "تحديد مجموعة",
|
"group": "تحديد مجموعة",
|
||||||
"ungroup": "إلغاء تحديد مجموعة",
|
"ungroup": "إلغاء تحديد مجموعة",
|
||||||
"collaborators": "المتعاونون",
|
"collaborators": "المتعاونون",
|
||||||
"gridMode": "وضع الشبكة",
|
"showGrid": "",
|
||||||
"addToLibrary": "أضف إلى المكتبة",
|
"addToLibrary": "أضف إلى المكتبة",
|
||||||
"removeFromLibrary": "حذف من المكتبة",
|
"removeFromLibrary": "حذف من المكتبة",
|
||||||
"libraryLoadingMessage": "جارٍ تحميل المكتبة...",
|
"libraryLoadingMessage": "جارٍ تحميل المكتبة…",
|
||||||
"libraries": "تصفح المكتبات",
|
"libraries": "تصفح المكتبات",
|
||||||
"loadingScene": "جاري تحميل المشهد...",
|
"loadingScene": "جاري تحميل المشهد…",
|
||||||
"align": "محاذاة",
|
"align": "محاذاة",
|
||||||
"alignTop": "محاذاة إلى اﻷعلى",
|
"alignTop": "محاذاة إلى اﻷعلى",
|
||||||
"alignBottom": "محاذاة إلى اﻷسفل",
|
"alignBottom": "محاذاة إلى اﻷسفل",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "توسيط عمودي",
|
"centerVertically": "توسيط عمودي",
|
||||||
"centerHorizontally": "توسيط أفقي",
|
"centerHorizontally": "توسيط أفقي",
|
||||||
"distributeHorizontally": "التوزيع الأفقي",
|
"distributeHorizontally": "التوزيع الأفقي",
|
||||||
"distributeVertically": "التوزيع عمودياً"
|
"distributeVertically": "التوزيع عمودياً",
|
||||||
|
"viewMode": ""
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "إعادة تعيين اللوحة",
|
"clearReset": "إعادة تعيين اللوحة",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "تعديل",
|
"edit": "تعديل",
|
||||||
"undo": "تراجع",
|
"undo": "تراجع",
|
||||||
"redo": "إعادة تنفيذ",
|
"redo": "إعادة تنفيذ",
|
||||||
"roomDialog": "بدء المشاركة الحية",
|
"resetLibrary": "",
|
||||||
"createNewRoom": "إنشاء غرفة جديدة",
|
"createNewRoom": "إنشاء غرفة جديدة",
|
||||||
"fullScreen": "شاشة كاملة",
|
"fullScreen": "شاشة كاملة",
|
||||||
"darkMode": "الوضع المظلم",
|
"darkMode": "الوضع المظلم",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "تعذر فك تشفير البيانات.",
|
"decryptFailed": "تعذر فك تشفير البيانات.",
|
||||||
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
|
||||||
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
|
||||||
|
"collabStopOverridePrompt": "",
|
||||||
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
|
||||||
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
|
||||||
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
|
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
|
||||||
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة"
|
"cannotRestoreFromImage": "تعذر استعادة المشهد من ملف الصورة",
|
||||||
|
"resetLibrary": ""
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "تحديد",
|
"selection": "تحديد",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "التخزين",
|
"storage": "التخزين",
|
||||||
"title": "إحصائيات للمهووسين",
|
"title": "إحصائيات للمهووسين",
|
||||||
"total": "المجموع",
|
"total": "المجموع",
|
||||||
|
"version": "",
|
||||||
|
"versionCopy": "",
|
||||||
|
"versionNotAvailable": "",
|
||||||
"width": "العرض"
|
"width": "العرض"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "",
|
"copyStyles": "",
|
||||||
"copyToClipboardAsPng": ""
|
"copyToClipboard": "",
|
||||||
|
"copyToClipboardAsPng": "",
|
||||||
|
"fileSaved": "",
|
||||||
|
"fileSavedToFilename": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-32
@@ -8,11 +8,11 @@
|
|||||||
"cut": "Изрежи",
|
"cut": "Изрежи",
|
||||||
"copy": "Копирай",
|
"copy": "Копирай",
|
||||||
"copyAsPng": "Копиране в клипборда",
|
"copyAsPng": "Копиране в клипборда",
|
||||||
"copyAsSvg": "Копиране в клипборда",
|
"copyAsSvg": "Копирано в клипборда като SVG",
|
||||||
"bringForward": "Преместване на~пред",
|
"bringForward": "Преместване напред",
|
||||||
"sendToBack": "Изнасяне назад",
|
"sendToBack": "Изнасяне назад",
|
||||||
"bringToFront": "~Изнасяне отпред",
|
"bringToFront": "Изнасяне отпред",
|
||||||
"sendBackward": "Изпрати назад",
|
"sendBackward": "Изпрати отзад",
|
||||||
"delete": "Изтрий",
|
"delete": "Изтрий",
|
||||||
"copyStyles": "Копирайте стилове",
|
"copyStyles": "Копирайте стилове",
|
||||||
"pasteStyles": "Постави стилове",
|
"pasteStyles": "Постави стилове",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Слоеве",
|
"layers": "Слоеве",
|
||||||
"actions": "Действия",
|
"actions": "Действия",
|
||||||
"language": "Език",
|
"language": "Език",
|
||||||
"createRoom": "Споделете сесия за сътрудничество на живо",
|
"liveCollaboration": "",
|
||||||
"duplicateSelection": "Дублирай",
|
"duplicateSelection": "Дублирай",
|
||||||
"untitled": "Неозаглавено",
|
"untitled": "Неозаглавено",
|
||||||
"name": "Име",
|
"name": "Име",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Групирай селекцията",
|
"group": "Групирай селекцията",
|
||||||
"ungroup": "Спри групирането на селекцията",
|
"ungroup": "Спри групирането на селекцията",
|
||||||
"collaborators": "Сътрудници",
|
"collaborators": "Сътрудници",
|
||||||
"gridMode": "Решетъчен режим",
|
"showGrid": "Показване на мрежа",
|
||||||
"addToLibrary": "Добавяне към библиотеката",
|
"addToLibrary": "Добавяне към библиотеката",
|
||||||
"removeFromLibrary": "Премахване от библиотеката",
|
"removeFromLibrary": "Премахване от библиотеката",
|
||||||
"libraryLoadingMessage": "Зареждане на библиотеката...",
|
"libraryLoadingMessage": "Зареждане на библиотеката…",
|
||||||
"libraries": "Разглеждане на библиотеките",
|
"libraries": "Разглеждане на библиотеките",
|
||||||
"loadingScene": "Зареждане на сцена...",
|
"loadingScene": "Зареждане на сцена…",
|
||||||
"align": "Подравняване",
|
"align": "Подравняване",
|
||||||
"alignTop": "Подравняване отгоре",
|
"alignTop": "Подравняване отгоре",
|
||||||
"alignBottom": "Подравняване отдолу",
|
"alignBottom": "Подравняване отдолу",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Центрирай вертикално",
|
"centerVertically": "Центрирай вертикално",
|
||||||
"centerHorizontally": "Центрирай хоризонтално",
|
"centerHorizontally": "Центрирай хоризонтално",
|
||||||
"distributeHorizontally": "Разпредели хоризонтално",
|
"distributeHorizontally": "Разпредели хоризонтално",
|
||||||
"distributeVertically": "Разпредели вертикално"
|
"distributeVertically": "Разпредели вертикално",
|
||||||
|
"viewMode": "Изглед"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Нулиране на платно",
|
"clearReset": "Нулиране на платно",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Редактиране",
|
"edit": "Редактиране",
|
||||||
"undo": "Отмяна",
|
"undo": "Отмяна",
|
||||||
"redo": "Повтори",
|
"redo": "Повтори",
|
||||||
"roomDialog": "Започнете сътрудничество на живо",
|
"resetLibrary": "",
|
||||||
"createNewRoom": "Създай нова стая",
|
"createNewRoom": "Създай нова стая",
|
||||||
"fullScreen": "На цял екран",
|
"fullScreen": "На цял екран",
|
||||||
"darkMode": "Тъмен режим",
|
"darkMode": "Тъмен режим",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "Данните не можаха да се дешифрират.",
|
"decryptFailed": "Данните не можаха да се дешифрират.",
|
||||||
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
|
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
|
||||||
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
|
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
|
||||||
|
"collabStopOverridePrompt": "Прекратяването на сесията ще презапише предишната, локално запазена, рисунка. Сигурни ли сте?\n\n(Ако искате да продължите с локалната рисунка, просто затворете таба на браузъра.)",
|
||||||
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
|
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
|
||||||
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
|
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
|
||||||
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
|
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
|
||||||
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл"
|
"cannotRestoreFromImage": "Не може да бъде възстановена сцена от този файл",
|
||||||
|
"resetLibrary": ""
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Селекция",
|
"selection": "Селекция",
|
||||||
@@ -200,25 +203,25 @@
|
|||||||
"title": "Грешка"
|
"title": "Грешка"
|
||||||
},
|
},
|
||||||
"helpDialog": {
|
"helpDialog": {
|
||||||
"blog": "",
|
"blog": "Прочетете нашия блог",
|
||||||
"click": "",
|
"click": "клик",
|
||||||
"curvedArrow": "",
|
"curvedArrow": "Извита стрелка",
|
||||||
"curvedLine": "",
|
"curvedLine": "Извита линия",
|
||||||
"documentation": "",
|
"documentation": "Документация",
|
||||||
"drag": "",
|
"drag": "плъзнете",
|
||||||
"editor": "",
|
"editor": "Редактор",
|
||||||
"github": "",
|
"github": "Намерихте проблем? Изпратете",
|
||||||
"howto": "",
|
"howto": "Следвайте нашите ръководства",
|
||||||
"or": "",
|
"or": "или",
|
||||||
"preventBinding": "",
|
"preventBinding": "Спри прилепяне на стрелките",
|
||||||
"shapes": "",
|
"shapes": "Фигури",
|
||||||
"shortcuts": "",
|
"shortcuts": "Клавиши за бърз достъп",
|
||||||
"textFinish": "",
|
"textFinish": "Завършете редактирането (текст)",
|
||||||
"textNewLine": "",
|
"textNewLine": "Добавяне на нов ред (текст)",
|
||||||
"title": "",
|
"title": "Помощ",
|
||||||
"view": "",
|
"view": "Преглед",
|
||||||
"zoomToFit": "",
|
"zoomToFit": "Приближи докато се виждат всички елементи",
|
||||||
"zoomToSelection": ""
|
"zoomToSelection": "Приближи селекцията"
|
||||||
},
|
},
|
||||||
"encrypted": {
|
"encrypted": {
|
||||||
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
|
"tooltip": "Вашите рисунки са криптирани от край до край, така че сървърите на Excalidraw няма да могат да ги виждат."
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Съхранение на данни",
|
"storage": "Съхранение на данни",
|
||||||
"title": "Статистика за хакери",
|
"title": "Статистика за хакери",
|
||||||
"total": "Общо",
|
"total": "Общо",
|
||||||
|
"version": "Версия",
|
||||||
|
"versionCopy": "Настисни за да копираш",
|
||||||
|
"versionNotAvailable": "Версията не е налична",
|
||||||
"width": "Широчина"
|
"width": "Широчина"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "",
|
"copyStyles": "Копирани стилове.",
|
||||||
"copyToClipboardAsPng": ""
|
"copyToClipboard": "Копирано в клипборда.",
|
||||||
|
"copyToClipboardAsPng": "Копирано в клипборда като PNG.",
|
||||||
|
"fileSaved": "",
|
||||||
|
"fileSavedToFilename": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-8
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Capes",
|
"layers": "Capes",
|
||||||
"actions": "Accions",
|
"actions": "Accions",
|
||||||
"language": "Llengua",
|
"language": "Llengua",
|
||||||
"createRoom": "Compartir una sessió de col·laboració en directe",
|
"liveCollaboration": "",
|
||||||
"duplicateSelection": "Duplicar",
|
"duplicateSelection": "Duplicar",
|
||||||
"untitled": "Sense títol",
|
"untitled": "Sense títol",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Agrupar la selecció",
|
"group": "Agrupar la selecció",
|
||||||
"ungroup": "Desagrupar la selecció",
|
"ungroup": "Desagrupar la selecció",
|
||||||
"collaborators": "Col·laboradors",
|
"collaborators": "Col·laboradors",
|
||||||
"gridMode": "Mode quadrícula",
|
"showGrid": "",
|
||||||
"addToLibrary": "Afegir a la biblioteca",
|
"addToLibrary": "Afegir a la biblioteca",
|
||||||
"removeFromLibrary": "Eliminar de la biblioteca",
|
"removeFromLibrary": "Eliminar de la biblioteca",
|
||||||
"libraryLoadingMessage": "Carregant la biblioteca...",
|
"libraryLoadingMessage": "Carregant la biblioteca…",
|
||||||
"libraries": "Explorar biblioteques",
|
"libraries": "Explorar biblioteques",
|
||||||
"loadingScene": "Carregant escena...",
|
"loadingScene": "Carregant escena…",
|
||||||
"align": "Alinear",
|
"align": "Alinear",
|
||||||
"alignTop": "Alinear a dalt",
|
"alignTop": "Alinear a dalt",
|
||||||
"alignBottom": "Alinear a baix",
|
"alignBottom": "Alinear a baix",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Centrar verticalment",
|
"centerVertically": "Centrar verticalment",
|
||||||
"centerHorizontally": "Centrar horitzontalment",
|
"centerHorizontally": "Centrar horitzontalment",
|
||||||
"distributeHorizontally": "Distribuir horitzontalment",
|
"distributeHorizontally": "Distribuir horitzontalment",
|
||||||
"distributeVertically": "Distribuir verticalment"
|
"distributeVertically": "Distribuir verticalment",
|
||||||
|
"viewMode": ""
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Netejar el llenç",
|
"clearReset": "Netejar el llenç",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"undo": "Desfer",
|
"undo": "Desfer",
|
||||||
"redo": "Refer",
|
"redo": "Refer",
|
||||||
"roomDialog": "Començar col·laboració en directe",
|
"resetLibrary": "",
|
||||||
"createNewRoom": "Crear sala nova",
|
"createNewRoom": "Crear sala nova",
|
||||||
"fullScreen": "Pantalla completa",
|
"fullScreen": "Pantalla completa",
|
||||||
"darkMode": "Mode fosc",
|
"darkMode": "Mode fosc",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "No s'ha pogut desencriptar.",
|
"decryptFailed": "No s'ha pogut desencriptar.",
|
||||||
"uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
|
"uploadedSecurly": "La càrrega s'ha assegurat amb xifratge punta a punta, cosa que significa que el servidor Excalidraw i tercers no poden llegir el contingut.",
|
||||||
"loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
|
"loadSceneOverridePrompt": "Si carregas aquest dibuix extern, substituirá el que tens. Vols continuar?",
|
||||||
|
"collabStopOverridePrompt": "",
|
||||||
"errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.",
|
"errorLoadingLibrary": "S'ha produït un error en carregar la biblioteca de tercers.",
|
||||||
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
|
"confirmAddLibrary": "Això afegirà {{numShapes}} forma(es) a la vostra biblioteca. Estàs segur?",
|
||||||
"imageDoesNotContainScene": "En aquest moment no s’admet la importació d’imatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada d’escena. Ho has activat durant l'exportació?",
|
"imageDoesNotContainScene": "En aquest moment no s’admet la importació d’imatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada d’escena. Ho has activat durant l'exportació?",
|
||||||
"cannotRestoreFromImage": "L’escena no s’ha pogut restaurar des d’aquest fitxer d’imatge"
|
"cannotRestoreFromImage": "L’escena no s’ha pogut restaurar des d’aquest fitxer d’imatge",
|
||||||
|
"resetLibrary": ""
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Selecció",
|
"selection": "Selecció",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Emmagatzematge",
|
"storage": "Emmagatzematge",
|
||||||
"title": "Estadístiques per nerds",
|
"title": "Estadístiques per nerds",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
"version": "",
|
||||||
|
"versionCopy": "",
|
||||||
|
"versionNotAvailable": "",
|
||||||
"width": "Amplada"
|
"width": "Amplada"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "",
|
"copyStyles": "",
|
||||||
"copyToClipboardAsPng": ""
|
"copyToClipboard": "",
|
||||||
|
"copyToClipboardAsPng": "",
|
||||||
|
"fileSaved": "",
|
||||||
|
"fileSavedToFilename": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Ebenen",
|
"layers": "Ebenen",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"createRoom": "Live-Kollaborationssitzung teilen",
|
"liveCollaboration": "Live-Zusammenarbeit",
|
||||||
"duplicateSelection": "Duplizieren",
|
"duplicateSelection": "Duplizieren",
|
||||||
"untitled": "Unbenannt",
|
"untitled": "Unbenannt",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -76,13 +76,13 @@
|
|||||||
"madeWithExcalidraw": "Made with Excalidraw",
|
"madeWithExcalidraw": "Made with Excalidraw",
|
||||||
"group": "Auswahl gruppieren",
|
"group": "Auswahl gruppieren",
|
||||||
"ungroup": "Gruppierung aufheben",
|
"ungroup": "Gruppierung aufheben",
|
||||||
"collaborators": "Mitarbeitende",
|
"collaborators": "Kollaboratoren",
|
||||||
"gridMode": "Rastermodus",
|
"showGrid": "Raster anzeigen",
|
||||||
"addToLibrary": "Zur Bibliothek hinzufügen",
|
"addToLibrary": "Zur Bibliothek hinzufügen",
|
||||||
"removeFromLibrary": "Aus Bibliothek entfernen",
|
"removeFromLibrary": "Aus Bibliothek entfernen",
|
||||||
"libraryLoadingMessage": "Lade Bibliothek...",
|
"libraryLoadingMessage": "Lade Bibliothek…",
|
||||||
"libraries": "Bibliotheken durchsuchen",
|
"libraries": "Bibliotheken durchsuchen",
|
||||||
"loadingScene": "Lade Zeichnung...",
|
"loadingScene": "Lade Zeichnung…",
|
||||||
"align": "Ausrichten",
|
"align": "Ausrichten",
|
||||||
"alignTop": "Obere Kanten",
|
"alignTop": "Obere Kanten",
|
||||||
"alignBottom": "Untere Kanten",
|
"alignBottom": "Untere Kanten",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Vertikal zentrieren",
|
"centerVertically": "Vertikal zentrieren",
|
||||||
"centerHorizontally": "Horizontal zentrieren",
|
"centerHorizontally": "Horizontal zentrieren",
|
||||||
"distributeHorizontally": "Horizontal verteilen",
|
"distributeHorizontally": "Horizontal verteilen",
|
||||||
"distributeVertically": "Vertikal verteilen"
|
"distributeVertically": "Vertikal verteilen",
|
||||||
|
"viewMode": "Ansichtsmodus"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
|
"clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
"exportToSvg": "Als SVG exportieren",
|
"exportToSvg": "Als SVG exportieren",
|
||||||
"copyToClipboard": "In Zwischenablage kopieren",
|
"copyToClipboard": "In Zwischenablage kopieren",
|
||||||
"copyPngToClipboard": "PNG in die Zwischenablage kopieren",
|
"copyPngToClipboard": "PNG in die Zwischenablage kopieren",
|
||||||
"scale": "Skalieren",
|
"scale": "Skalierung",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"saveAs": "Speichern unter",
|
"saveAs": "Speichern unter",
|
||||||
"load": "Laden",
|
"load": "Laden",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"undo": "Rückgängig machen",
|
"undo": "Rückgängig machen",
|
||||||
"redo": "Wiederholen",
|
"redo": "Wiederholen",
|
||||||
"roomDialog": "Live-Kollaborationssitzung starten",
|
"resetLibrary": "Bibliothek zurücksetzen",
|
||||||
"createNewRoom": "Neuen Raum erstellen",
|
"createNewRoom": "Neuen Raum erstellen",
|
||||||
"fullScreen": "Vollbildanzeige",
|
"fullScreen": "Vollbildanzeige",
|
||||||
"darkMode": "Dunkles Design",
|
"darkMode": "Dunkles Design",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "Daten konnten nicht entschlüsselt werden.",
|
"decryptFailed": "Daten konnten nicht entschlüsselt werden.",
|
||||||
"uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.",
|
"uploadedSecurly": "Der Upload wurde mit Ende-zu-Ende-Verschlüsselung gespeichert. Weder Excalidraw noch Dritte können den Inhalt einsehen.",
|
||||||
"loadSceneOverridePrompt": "Das Laden der externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest Du fortfahren?",
|
"loadSceneOverridePrompt": "Das Laden der externen Zeichnung ersetzt den vorhandenen Inhalt. Möchtest Du fortfahren?",
|
||||||
|
"collabStopOverridePrompt": "Das Stoppen der Sitzung wird Deine vorherige, lokal gespeicherte Zeichnung überschreiben. Bist Du sicher?\n\n(Wenn Du Deine lokale Zeichnung behalten möchtest, schließe einfach stattdessen den Browser-Tab.)",
|
||||||
"errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.",
|
"errorLoadingLibrary": "Beim Laden der Drittanbieter-Bibliothek ist ein Fehler aufgetreten.",
|
||||||
"confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du sicher?",
|
"confirmAddLibrary": "Dieses fügt {{numShapes}} Form(en) zu deiner Bibliothek hinzu. Bist du sicher?",
|
||||||
"imageDoesNotContainScene": "Das Importieren von Bildern wird derzeit nicht unterstützt.\n\nMöchtest du eine Szene importieren? Dieses Bild scheint keine Zeichnungsdaten zu enthalten. Hast du dies beim Exportieren aktiviert?",
|
"imageDoesNotContainScene": "Das Importieren von Bildern wird derzeit nicht unterstützt.\n\nMöchtest du eine Szene importieren? Dieses Bild scheint keine Zeichnungsdaten zu enthalten. Hast du dies beim Exportieren aktiviert?",
|
||||||
"cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden"
|
"cannotRestoreFromImage": "Die Zeichnung konnte aus dieser Bilddatei nicht wiederhergestellt werden",
|
||||||
|
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Auswahl",
|
"selection": "Auswahl",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Speicher",
|
"storage": "Speicher",
|
||||||
"title": "Statistiken für Nerds",
|
"title": "Statistiken für Nerds",
|
||||||
"total": "Gesamt",
|
"total": "Gesamt",
|
||||||
|
"version": "Version",
|
||||||
|
"versionCopy": "Zum Kopieren klicken",
|
||||||
|
"versionNotAvailable": "Version nicht verfügbar",
|
||||||
"width": "Breite"
|
"width": "Breite"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "Formatierung kopiert.",
|
"copyStyles": "Formatierung kopiert.",
|
||||||
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert."
|
"copyToClipboard": "In die Zwischenablage kopiert.",
|
||||||
|
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert.",
|
||||||
|
"fileSaved": "Datei gespeichert.",
|
||||||
|
"fileSavedToFilename": "Als {filename} gespeichert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-13
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Στρώματα",
|
"layers": "Στρώματα",
|
||||||
"actions": "Ενέργειες",
|
"actions": "Ενέργειες",
|
||||||
"language": "Γλώσσα",
|
"language": "Γλώσσα",
|
||||||
"createRoom": "Έναρξη ζωντανής συνεδρίας",
|
"liveCollaboration": "Ζωντανή συνεργασία",
|
||||||
"duplicateSelection": "Δημιουργία αντιγράφου",
|
"duplicateSelection": "Δημιουργία αντιγράφου",
|
||||||
"untitled": "Χωρίς τίτλο",
|
"untitled": "Χωρίς τίτλο",
|
||||||
"name": "Όνομα",
|
"name": "Όνομα",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Δημιουργία ομάδας από επιλογή",
|
"group": "Δημιουργία ομάδας από επιλογή",
|
||||||
"ungroup": "Κατάργηση ομάδας από επιλογή",
|
"ungroup": "Κατάργηση ομάδας από επιλογή",
|
||||||
"collaborators": "Συνεργάτες",
|
"collaborators": "Συνεργάτες",
|
||||||
"gridMode": "Εμφάνιση σε πλέγμα",
|
"showGrid": "Προβολή πλέγματος",
|
||||||
"addToLibrary": "Προσθήκη στη βιβλιοθήκη",
|
"addToLibrary": "Προσθήκη στη βιβλιοθήκη",
|
||||||
"removeFromLibrary": "Αφαίρεση από τη βιβλιοθήκη",
|
"removeFromLibrary": "Αφαίρεση από τη βιβλιοθήκη",
|
||||||
"libraryLoadingMessage": "Φόρτωση βιβλιοθήκης...",
|
"libraryLoadingMessage": "Φόρτωση βιβλιοθήκης…",
|
||||||
"libraries": "Άλλες βιβλιοθήκες",
|
"libraries": "Άλλες βιβλιοθήκες",
|
||||||
"loadingScene": "Φόρτωση σκηνής...",
|
"loadingScene": "Φόρτωση σκηνής…",
|
||||||
"align": "Στοίχιση",
|
"align": "Στοίχιση",
|
||||||
"alignTop": "Στοίχιση πάνω",
|
"alignTop": "Στοίχιση πάνω",
|
||||||
"alignBottom": "Στοίχιση κάτω",
|
"alignBottom": "Στοίχιση κάτω",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Κέντρο κάθετα",
|
"centerVertically": "Κέντρο κάθετα",
|
||||||
"centerHorizontally": "Κέντρο οριζόντια",
|
"centerHorizontally": "Κέντρο οριζόντια",
|
||||||
"distributeHorizontally": "Οριζόντια κατανομή",
|
"distributeHorizontally": "Οριζόντια κατανομή",
|
||||||
"distributeVertically": "Κατακόρυφη κατανομή"
|
"distributeVertically": "Κατακόρυφη κατανομή",
|
||||||
|
"viewMode": "Λειτουργία προβολής"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Επαναφορά του καμβά",
|
"clearReset": "Επαναφορά του καμβά",
|
||||||
@@ -116,13 +117,13 @@
|
|||||||
"edit": "Επεξεργασία",
|
"edit": "Επεξεργασία",
|
||||||
"undo": "Αναίρεση",
|
"undo": "Αναίρεση",
|
||||||
"redo": "Επαναφορά",
|
"redo": "Επαναφορά",
|
||||||
"roomDialog": "Έναρξη ζωντανής συνεργασίας",
|
"resetLibrary": "Καθαρισμός βιβλιοθήκης",
|
||||||
"createNewRoom": "Δημιουργία νέου χώρου",
|
"createNewRoom": "Δημιουργία νέου χώρου",
|
||||||
"fullScreen": "Πλήρης οθόνη",
|
"fullScreen": "Πλήρης οθόνη",
|
||||||
"darkMode": "Σκοτεινή λειτουργία",
|
"darkMode": "Σκοτεινή λειτουργία",
|
||||||
"lightMode": "Φωτεινή λειτουργία",
|
"lightMode": "Φωτεινή λειτουργία",
|
||||||
"zenMode": "Λειτουργία Zεν",
|
"zenMode": "Λειτουργία Zεν",
|
||||||
"exitZenMode": "Έξοδος απο την λειτουργία Zen"
|
"exitZenMode": "Έξοδος από την λειτουργία Zen"
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
|
"clearReset": "Αυτό θα σβήσει ολόκληρο τον καμβά. Είσαι σίγουρος;",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
|
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
|
||||||
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
|
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
|
||||||
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
|
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
|
||||||
|
"collabStopOverridePrompt": "Η διακοπή της συνεδρίας θα αντικαταστήσει το προηγούμενο, τοπικά αποθηκευμένο σχέδιο. Είστε σίγουροι?\n\n(Αν θέλετε να διατηρήσετε το τοπικό σας σχέδιο, απλά κλείστε την καρτέλα του προγράμματος περιήγησης.)",
|
||||||
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
|
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
|
||||||
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβιλιοθήκη σας. Είστε σίγουροι;",
|
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||||
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
|
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
|
||||||
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας"
|
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
|
||||||
|
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Επιλογή",
|
"selection": "Επιλογή",
|
||||||
@@ -159,7 +162,7 @@
|
|||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
"linearElement": "Κάνε κλικ για να ξεκινήσεις πολλαπλά σημεία, σύρε για μια γραμμή",
|
"linearElement": "Κάνε κλικ για να ξεκινήσεις πολλαπλά σημεία, σύρε για μια γραμμή",
|
||||||
"freeDraw": "Κάντε κλικ και σύρατε, απελευθερώσατε όταν έχετε τελειώσει",
|
"freeDraw": "Κάντε κλικ και σύρτε, απελευθερώσατε όταν έχετε τελειώσει",
|
||||||
"text": "Tip: μπορείτε επίσης να προσθέστε κείμενο με διπλό-κλικ οπουδήποτε με το εργαλείο επιλογών",
|
"text": "Tip: μπορείτε επίσης να προσθέστε κείμενο με διπλό-κλικ οπουδήποτε με το εργαλείο επιλογών",
|
||||||
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
|
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
|
||||||
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
|
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
|
||||||
@@ -182,7 +185,7 @@
|
|||||||
"clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ",
|
"clearCanvasCaveat": " Αυτό θα προκαλέσει απώλεια της δουλειάς σου ",
|
||||||
"trackedToSentry_pre": "Το σφάλμα με αναγνωριστικό ",
|
"trackedToSentry_pre": "Το σφάλμα με αναγνωριστικό ",
|
||||||
"trackedToSentry_post": " παρακολουθήθηκε στο σύστημά μας.",
|
"trackedToSentry_post": " παρακολουθήθηκε στο σύστημά μας.",
|
||||||
"openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψουν να ακολουθήσεις το δικό μας ",
|
"openIssueMessage_pre": "Ήμασταν πολύ προσεκτικοί για να μην συμπεριλάβουμε τις πληροφορίες της σκηνής σου στο σφάλμα. Αν η σκηνή σου δεν είναι ιδιωτική, παρακαλώ σκέψου να ακολουθήσεις το δικό μας ",
|
||||||
"openIssueMessage_button": "ανιχνευτής σφαλμάτων.",
|
"openIssueMessage_button": "ανιχνευτής σφαλμάτων.",
|
||||||
"openIssueMessage_post": " Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.",
|
"openIssueMessage_post": " Παρακαλώ να συμπεριλάβετε τις παρακάτω πληροφορίες, αντιγράφοντας και επικολλώντας το ζήτημα στο GitHub.",
|
||||||
"sceneContent": "Περιεχόμενο σκηνής:"
|
"sceneContent": "Περιεχόμενο σκηνής:"
|
||||||
@@ -221,7 +224,7 @@
|
|||||||
"zoomToSelection": "Ζουμ στην επιλογή"
|
"zoomToSelection": "Ζουμ στην επιλογή"
|
||||||
},
|
},
|
||||||
"encrypted": {
|
"encrypted": {
|
||||||
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα έιναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
|
"tooltip": "Τα σχέδιά σου είναι κρυπτογραφημένα από άκρο σε άκρο, έτσι δεν θα είναι ποτέ ορατά μέσα από τους διακομιστές του Excalidraw."
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"angle": "Γωνία",
|
"angle": "Γωνία",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Χώρος",
|
"storage": "Χώρος",
|
||||||
"title": "Στατιστικά για σπασίκλες",
|
"title": "Στατιστικά για σπασίκλες",
|
||||||
"total": "Σύνολο ",
|
"total": "Σύνολο ",
|
||||||
|
"version": "Έκδοση",
|
||||||
|
"versionCopy": "Κάνε κλικ για αντιγραφή",
|
||||||
|
"versionNotAvailable": "Έκδοση μη διαθέσιμη",
|
||||||
"width": "Πλάτος"
|
"width": "Πλάτος"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "Αντιγράφηκαν στυλ.",
|
"copyStyles": "Αντιγράφηκαν στυλ.",
|
||||||
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG."
|
"copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
|
||||||
|
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG.",
|
||||||
|
"fileSaved": "Το αρχείο αποθηκεύτηκε.",
|
||||||
|
"fileSavedToFilename": "Αποθηκεύτηκε στο {filename}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-8
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Layers",
|
"layers": "Layers",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"createRoom": "Share a live-collaboration session",
|
"liveCollaboration": "Live collaboration",
|
||||||
"duplicateSelection": "Duplicate",
|
"duplicateSelection": "Duplicate",
|
||||||
"untitled": "Untitled",
|
"untitled": "Untitled",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Group selection",
|
"group": "Group selection",
|
||||||
"ungroup": "Ungroup selection",
|
"ungroup": "Ungroup selection",
|
||||||
"collaborators": "Collaborators",
|
"collaborators": "Collaborators",
|
||||||
"gridMode": "Grid mode",
|
"showGrid": "Show grid",
|
||||||
"addToLibrary": "Add to library",
|
"addToLibrary": "Add to library",
|
||||||
"removeFromLibrary": "Remove from library",
|
"removeFromLibrary": "Remove from library",
|
||||||
"libraryLoadingMessage": "Loading library...",
|
"libraryLoadingMessage": "Loading library…",
|
||||||
"libraries": "Browse libraries",
|
"libraries": "Browse libraries",
|
||||||
"loadingScene": "Loading scene...",
|
"loadingScene": "Loading scene…",
|
||||||
"align": "Align",
|
"align": "Align",
|
||||||
"alignTop": "Align top",
|
"alignTop": "Align top",
|
||||||
"alignBottom": "Align bottom",
|
"alignBottom": "Align bottom",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Center vertically",
|
"centerVertically": "Center vertically",
|
||||||
"centerHorizontally": "Center horizontally",
|
"centerHorizontally": "Center horizontally",
|
||||||
"distributeHorizontally": "Distribute horizontally",
|
"distributeHorizontally": "Distribute horizontally",
|
||||||
"distributeVertically": "Distribute vertically"
|
"distributeVertically": "Distribute vertically",
|
||||||
|
"viewMode": "View mode"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Reset the canvas",
|
"clearReset": "Reset the canvas",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"redo": "Redo",
|
"redo": "Redo",
|
||||||
"roomDialog": "Start live collaboration",
|
"resetLibrary": "Reset library",
|
||||||
"createNewRoom": "Create new room",
|
"createNewRoom": "Create new room",
|
||||||
"fullScreen": "Full screen",
|
"fullScreen": "Full screen",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "Couldn't decrypt data.",
|
"decryptFailed": "Couldn't decrypt data.",
|
||||||
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
|
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
|
||||||
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
|
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
|
||||||
|
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
|
||||||
"errorLoadingLibrary": "There was an error loading the third party library.",
|
"errorLoadingLibrary": "There was an error loading the third party library.",
|
||||||
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
|
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
|
||||||
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
|
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
|
||||||
"cannotRestoreFromImage": "Scene couldn't be restored from this image file"
|
"cannotRestoreFromImage": "Scene couldn't be restored from this image file",
|
||||||
|
"resetLibrary": "This will clear your library. Are you sure?"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Selection",
|
"selection": "Selection",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"title": "Stats for nerds",
|
"title": "Stats for nerds",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
"version": "Version",
|
||||||
|
"versionCopy": "Click to copy",
|
||||||
|
"versionNotAvailable": "Version not available",
|
||||||
"width": "Width"
|
"width": "Width"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "Copied styles.",
|
"copyStyles": "Copied styles.",
|
||||||
"copyToClipboardAsPng": "Copied to clipboard as PNG."
|
"copyToClipboard": "Copied to clipboard.",
|
||||||
|
"copyToClipboardAsPng": "Copied to clipboard as PNG.",
|
||||||
|
"fileSaved": "File saved.",
|
||||||
|
"fileSavedToFilename": "Saved to {filename}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user