Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e9dc80e3 | ||
|
|
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
+1625
-1460
File diff suppressed because it is too large
Load Diff
+15
-14
@@ -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,20 +43,21 @@
|
|||||||
"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"
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
|
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
|
||||||
],
|
],
|
||||||
"resetMocks": false
|
"resetMocks": false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const actionShortcuts = register({
|
|||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
showHelpDialog: true,
|
showHelpDialog: !appState.showHelpDialog,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -65,3 +65,15 @@ export {
|
|||||||
distributeHorizontally,
|
distributeHorizontally,
|
||||||
distributeVertically,
|
distributeVertically,
|
||||||
} from "./actionDistribute";
|
} from "./actionDistribute";
|
||||||
|
|
||||||
|
export {
|
||||||
|
actionCopy,
|
||||||
|
actionCut,
|
||||||
|
actionCopyAsPng,
|
||||||
|
actionCopyAsSvg,
|
||||||
|
} from "./actionClipboard";
|
||||||
|
|
||||||
|
export { actionToggleGridMode } from "./actionToggleGridMode";
|
||||||
|
export { actionToggleZenMode } from "./actionToggleZenMode";
|
||||||
|
|
||||||
|
export { actionToggleStats } from "./actionToggleStats";
|
||||||
|
|||||||
+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) => {
|
||||||
|
|||||||
+12
-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,8 @@ export type ActionName =
|
|||||||
| "alignVerticallyCentered"
|
| "alignVerticallyCentered"
|
||||||
| "alignHorizontallyCentered"
|
| "alignHorizontallyCentered"
|
||||||
| "distributeHorizontally"
|
| "distributeHorizontally"
|
||||||
| "distributeVertically";
|
| "distributeVertically"
|
||||||
|
| "viewMode";
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: ActionName;
|
name: ActionName;
|
||||||
@@ -93,19 +103,16 @@ export interface Action {
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
) => boolean;
|
) => boolean;
|
||||||
contextItemLabel?: string;
|
contextItemLabel?: string;
|
||||||
contextMenuOrder?: number;
|
|
||||||
contextItemPredicate?: (
|
contextItemPredicate?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
|
checked?: (appState: Readonly<AppState>) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionsManagerInterface {
|
export interface ActionsManagerInterface {
|
||||||
actions: Record<ActionName, Action>;
|
actions: Record<ActionName, Action>;
|
||||||
registerAction: (action: Action) => void;
|
registerAction: (action: Action) => void;
|
||||||
handleKeyDown: (event: KeyboardEvent) => boolean;
|
handleKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
getContextMenuItems: (
|
|
||||||
actionFilter: ActionFilterFn,
|
|
||||||
) => { label: string; action: () => void }[];
|
|
||||||
renderAction: (name: ActionName) => React.ReactElement | null;
|
renderAction: (name: ActionName) => React.ReactElement | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -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<
|
||||||
@@ -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,
|
||||||
@@ -72,6 +72,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
width: window.innerWidth,
|
width: window.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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ 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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
+336
-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,6 +967,7 @@ 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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -879,6 +1000,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 +1031,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 +1233,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 +1330,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 +1556,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 +1847,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 +1865,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 +1963,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 +2150,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 +2209,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 +2263,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 +2325,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 +2635,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 +3081,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 +3091,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 +3684,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 +3776,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 +3855,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 +3886,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+136
-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
|
||||||
@@ -536,24 +605,12 @@ const LayerUI = ({
|
|||||||
</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 +656,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { fileSave } from "browser-nativefs";
|
import { fileSave } from "browser-fs-access";
|
||||||
import {
|
import {
|
||||||
copyCanvasToClipboardAsPng,
|
copyCanvasToClipboardAsPng,
|
||||||
copyTextToSystemClipboard,
|
copyTextToSystemClipboard,
|
||||||
|
|||||||
+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
+1
-1
@@ -85,6 +85,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
|
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
|
||||||
|
export const isWindows = /^Win/.test(window.navigator.platform);
|
||||||
|
|
||||||
export const CODES = {
|
export const CODES = {
|
||||||
EQUAL: "Equal",
|
EQUAL: "Equal",
|
||||||
@@ -18,7 +19,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 = {
|
||||||
@@ -48,6 +51,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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-8
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Capas",
|
"layers": "Capas",
|
||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"createRoom": "Compartir una sesión de colaboración en vivo",
|
"liveCollaboration": "Colaboración en directo",
|
||||||
"duplicateSelection": "Duplicar",
|
"duplicateSelection": "Duplicar",
|
||||||
"untitled": "Sin título",
|
"untitled": "Sin título",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Agrupar selección",
|
"group": "Agrupar selección",
|
||||||
"ungroup": "Desagrupar selección",
|
"ungroup": "Desagrupar selección",
|
||||||
"collaborators": "Colaboradores",
|
"collaborators": "Colaboradores",
|
||||||
"gridMode": "Modo cuadrícula",
|
"showGrid": "Mostrar cuadrícula",
|
||||||
"addToLibrary": "Añadir a la biblioteca",
|
"addToLibrary": "Añadir a la biblioteca",
|
||||||
"removeFromLibrary": "Eliminar de la biblioteca",
|
"removeFromLibrary": "Eliminar de la biblioteca",
|
||||||
"libraryLoadingMessage": "Cargando biblioteca...",
|
"libraryLoadingMessage": "Cargando librería…",
|
||||||
"libraries": "Explorar bibliotecas",
|
"libraries": "Explorar bibliotecas",
|
||||||
"loadingScene": "Cargando escena...",
|
"loadingScene": "Cargando escena…",
|
||||||
"align": "Alinear",
|
"align": "Alinear",
|
||||||
"alignTop": "Alineación superior",
|
"alignTop": "Alineación superior",
|
||||||
"alignBottom": "Alineación inferior",
|
"alignBottom": "Alineación inferior",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Centrar verticalmente",
|
"centerVertically": "Centrar verticalmente",
|
||||||
"centerHorizontally": "Centrar horizontalmente",
|
"centerHorizontally": "Centrar horizontalmente",
|
||||||
"distributeHorizontally": "Distribuir horizontalmente",
|
"distributeHorizontally": "Distribuir horizontalmente",
|
||||||
"distributeVertically": "Distribuir verticalmente"
|
"distributeVertically": "Distribuir verticalmente",
|
||||||
|
"viewMode": "Modo presentación"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
|
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"undo": "Deshacer",
|
"undo": "Deshacer",
|
||||||
"redo": "Rehacer",
|
"redo": "Rehacer",
|
||||||
"roomDialog": "Iniciar colaboración en vivo",
|
"resetLibrary": "Resetear librería",
|
||||||
"createNewRoom": "Crear nueva sala",
|
"createNewRoom": "Crear nueva sala",
|
||||||
"fullScreen": "Pantalla completa",
|
"fullScreen": "Pantalla completa",
|
||||||
"darkMode": "Modo oscuro",
|
"darkMode": "Modo oscuro",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "No se pudieron descifrar los datos.",
|
"decryptFailed": "No se pudieron descifrar los datos.",
|
||||||
"uploadedSecurly": "La carga ha sido asegurada con cifrado de principio a fin, lo que significa que el servidor de Excalidraw y terceros no pueden leer el contenido.",
|
"uploadedSecurly": "La carga ha sido asegurada con cifrado de principio a fin, lo que significa que el servidor de Excalidraw y terceros no pueden leer el contenido.",
|
||||||
"loadSceneOverridePrompt": "Si carga este dibujo externo, reemplazará el que tiene. ¿Desea continuar?",
|
"loadSceneOverridePrompt": "Si carga este dibujo externo, reemplazará el que tiene. ¿Desea continuar?",
|
||||||
|
"collabStopOverridePrompt": "Detener la sesión sobrescribirá su dibujo anterior almacenado en local. ¿Estás seguro?\n\n(Si quieres mantener tu dibujo en local, simplemente cierre la pestaña del navegador en su lugar.)",
|
||||||
"errorLoadingLibrary": "Se ha producido un error al cargar la biblioteca de terceros.",
|
"errorLoadingLibrary": "Se ha producido un error al cargar la biblioteca de terceros.",
|
||||||
"confirmAddLibrary": "Esto añadirá {{numShapes}} forma(s) a tu biblioteca. ¿Estás seguro?",
|
"confirmAddLibrary": "Esto añadirá {{numShapes}} forma(s) a tu biblioteca. ¿Estás seguro?",
|
||||||
"imageDoesNotContainScene": "La importación de imágenes no está homologada en este momento.\n\n¿Deseas importar una escena? Esta imagen no parece contener ningún dato de escena. ¿Lo has activado durante la exportación?",
|
"imageDoesNotContainScene": "La importación de imágenes no está homologada en este momento.\n\n¿Deseas importar una escena? Esta imagen no parece contener ningún dato de escena. ¿Lo has activado durante la exportación?",
|
||||||
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen"
|
"cannotRestoreFromImage": "No se pudo restaurar la escena desde este archivo de imagen",
|
||||||
|
"resetLibrary": "Esto eliminará tu librería. ¿Estás seguro?"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Selección",
|
"selection": "Selección",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Almacenamiento",
|
"storage": "Almacenamiento",
|
||||||
"title": "Estadísticas para nerds",
|
"title": "Estadísticas para nerds",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
"version": "Versión",
|
||||||
|
"versionCopy": "Clic para copiar",
|
||||||
|
"versionNotAvailable": "Versión no disponible",
|
||||||
"width": "Ancho"
|
"width": "Ancho"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "Estilos copiados.",
|
"copyStyles": "Estilos copiados.",
|
||||||
"copyToClipboardAsPng": "Copiado al portapapeles como PNG."
|
"copyToClipboard": "Copiado en el portapapeles.",
|
||||||
|
"copyToClipboardAsPng": "Copiado al portapapeles como PNG.",
|
||||||
|
"fileSaved": "Archivo guardado.",
|
||||||
|
"fileSavedToFilename": "Guardado en {filename}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-25
@@ -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آیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟",
|
"imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\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": "Tasot",
|
"layers": "Tasot",
|
||||||
"actions": "Toiminnot",
|
"actions": "Toiminnot",
|
||||||
"language": "Kieli",
|
"language": "Kieli",
|
||||||
"createRoom": "Jaa yhteistyöistunto",
|
"liveCollaboration": "Live-yhteistyö",
|
||||||
"duplicateSelection": "Monista",
|
"duplicateSelection": "Monista",
|
||||||
"untitled": "Nimetön",
|
"untitled": "Nimetön",
|
||||||
"name": "Nimi",
|
"name": "Nimi",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Ryhmitä valinta",
|
"group": "Ryhmitä valinta",
|
||||||
"ungroup": "Pura valittu ryhmä",
|
"ungroup": "Pura valittu ryhmä",
|
||||||
"collaborators": "Yhteistyökumppanit",
|
"collaborators": "Yhteistyökumppanit",
|
||||||
"gridMode": "Ruudukkotila",
|
"showGrid": "Näytä ruudukko",
|
||||||
"addToLibrary": "Lisää kirjastoon",
|
"addToLibrary": "Lisää kirjastoon",
|
||||||
"removeFromLibrary": "Poista kirjastosta",
|
"removeFromLibrary": "Poista kirjastosta",
|
||||||
"libraryLoadingMessage": "Ladataan kirjastoa...",
|
"libraryLoadingMessage": "Ladataan kirjastoa…",
|
||||||
"libraries": "Selaa kirjastoja",
|
"libraries": "Selaa kirjastoja",
|
||||||
"loadingScene": "Ladataan työtä...",
|
"loadingScene": "Ladataan työtä…",
|
||||||
"align": "Tasaa",
|
"align": "Tasaa",
|
||||||
"alignTop": "Tasaa ylös",
|
"alignTop": "Tasaa ylös",
|
||||||
"alignBottom": "Tasaa alas",
|
"alignBottom": "Tasaa alas",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Keskitä pystysuunnassa",
|
"centerVertically": "Keskitä pystysuunnassa",
|
||||||
"centerHorizontally": "Keskitä vaakasuunnassa",
|
"centerHorizontally": "Keskitä vaakasuunnassa",
|
||||||
"distributeHorizontally": "Jaa vaakasuunnassa",
|
"distributeHorizontally": "Jaa vaakasuunnassa",
|
||||||
"distributeVertically": "Jaa pystysuunnassa"
|
"distributeVertically": "Jaa pystysuunnassa",
|
||||||
|
"viewMode": "Katselutila"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Tyhjennä piirtoalue",
|
"clearReset": "Tyhjennä piirtoalue",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Muokkaa",
|
"edit": "Muokkaa",
|
||||||
"undo": "Kumoa",
|
"undo": "Kumoa",
|
||||||
"redo": "Tee uudelleen",
|
"redo": "Tee uudelleen",
|
||||||
"roomDialog": "Aloita live-yhteistyö",
|
"resetLibrary": "Tyhjennä kirjasto",
|
||||||
"createNewRoom": "Luo huone",
|
"createNewRoom": "Luo huone",
|
||||||
"fullScreen": "Koko näyttö",
|
"fullScreen": "Koko näyttö",
|
||||||
"darkMode": "Tumma tila",
|
"darkMode": "Tumma tila",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "Salauksen purkaminen epäonnistui.",
|
"decryptFailed": "Salauksen purkaminen epäonnistui.",
|
||||||
"uploadedSecurly": "Lähetys on turvattu päästä päähän salauksella. Excalidrawin palvelin ja kolmannet osapuolet eivät voi lukea sisältöä.",
|
"uploadedSecurly": "Lähetys on turvattu päästä päähän salauksella. Excalidrawin palvelin ja kolmannet osapuolet eivät voi lukea sisältöä.",
|
||||||
"loadSceneOverridePrompt": "Ulkopuolisen piirroksen lataaminen korvaa nykyisen sisältösi. Haluatko jatkaa?",
|
"loadSceneOverridePrompt": "Ulkopuolisen piirroksen lataaminen korvaa nykyisen sisältösi. Haluatko jatkaa?",
|
||||||
|
"collabStopOverridePrompt": "Istunnon lopettaminen korvaa aiemman, paikallisesti tallennetun piirustuksen. Oletko varma?\n\n(Jos haluat pitää paikallisen piirustuksen, sulje selaimen välilehti sen sijaan.)",
|
||||||
"errorLoadingLibrary": "Kolmannen osapuolen kirjastoa ladattaessa tapahtui virhe.",
|
"errorLoadingLibrary": "Kolmannen osapuolen kirjastoa ladattaessa tapahtui virhe.",
|
||||||
"confirmAddLibrary": "Tämä lisää {{numShapes}} muotoa kirjastoosi. Oletko varma?",
|
"confirmAddLibrary": "Tämä lisää {{numShapes}} muotoa kirjastoosi. Oletko varma?",
|
||||||
"imageDoesNotContainScene": "Kuvien lisääminen ei ole tällä hetkellä mahdollista.\n\nHaluatko tuoda piirroksen? Tämä kuva ei näytä sisältävän tarvittavia tietoja. Oletko ottanut piirrostietojen tallennuksen käyttöön viennin aikana?",
|
"imageDoesNotContainScene": "Kuvien lisääminen ei ole tällä hetkellä mahdollista.\n\nHaluatko tuoda piirroksen? Tämä kuva ei näytä sisältävän tarvittavia tietoja. Oletko ottanut piirrostietojen tallennuksen käyttöön viennin aikana?",
|
||||||
"cannotRestoreFromImage": "Teosta ei voitu palauttaa tästä kuvatiedostosta"
|
"cannotRestoreFromImage": "Teosta ei voitu palauttaa tästä kuvatiedostosta",
|
||||||
|
"resetLibrary": "Tämä tyhjentää kirjastosi. Oletko varma?"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Valinta",
|
"selection": "Valinta",
|
||||||
@@ -233,10 +236,16 @@
|
|||||||
"storage": "Tallennustila",
|
"storage": "Tallennustila",
|
||||||
"title": "Nörttien tilastot",
|
"title": "Nörttien tilastot",
|
||||||
"total": "Yhteensä",
|
"total": "Yhteensä",
|
||||||
|
"version": "Versio",
|
||||||
|
"versionCopy": "Klikkaa kopioidaksesi",
|
||||||
|
"versionNotAvailable": "Versio ei saatavilla",
|
||||||
"width": "Leveys"
|
"width": "Leveys"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "Tyylit kopioitu.",
|
"copyStyles": "Tyylit kopioitu.",
|
||||||
"copyToClipboardAsPng": "Kopioitu leikepöydälle PNG-tiedostona."
|
"copyToClipboard": "Kopioitu leikepöydälle.",
|
||||||
|
"copyToClipboardAsPng": "Kopioitu leikepöydälle PNG-tiedostona.",
|
||||||
|
"fileSaved": "Tiedosto tallennettu.",
|
||||||
|
"fileSavedToFilename": "Tallennettu kohteeseen {filename}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-9
@@ -68,7 +68,7 @@
|
|||||||
"layers": "Calques",
|
"layers": "Calques",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"createRoom": "Partager une session de collaboration en direct",
|
"liveCollaboration": "Collaboration en direct",
|
||||||
"duplicateSelection": "Dupliquer",
|
"duplicateSelection": "Dupliquer",
|
||||||
"untitled": "Sans-titre",
|
"untitled": "Sans-titre",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
@@ -77,12 +77,12 @@
|
|||||||
"group": "Grouper la sélection",
|
"group": "Grouper la sélection",
|
||||||
"ungroup": "Dégrouper la sélection",
|
"ungroup": "Dégrouper la sélection",
|
||||||
"collaborators": "Collaborateurs",
|
"collaborators": "Collaborateurs",
|
||||||
"gridMode": "Mode grille",
|
"showGrid": "Afficher la grille",
|
||||||
"addToLibrary": "Ajouter à la bibliothèque",
|
"addToLibrary": "Ajouter à la bibliothèque",
|
||||||
"removeFromLibrary": "Supprimer de la bibliothèque",
|
"removeFromLibrary": "Supprimer de la bibliothèque",
|
||||||
"libraryLoadingMessage": "Chargement de la bibliothèque...",
|
"libraryLoadingMessage": "Chargement de la bibliothèque…",
|
||||||
"libraries": "Parcourir les bibliothèques",
|
"libraries": "Parcourir les bibliothèques",
|
||||||
"loadingScene": "Chargement de la scène...",
|
"loadingScene": "Chargement de la scène…",
|
||||||
"align": "Aligner",
|
"align": "Aligner",
|
||||||
"alignTop": "Aligner en haut",
|
"alignTop": "Aligner en haut",
|
||||||
"alignBottom": "Aligner en bas",
|
"alignBottom": "Aligner en bas",
|
||||||
@@ -91,7 +91,8 @@
|
|||||||
"centerVertically": "Centrer verticalement",
|
"centerVertically": "Centrer verticalement",
|
||||||
"centerHorizontally": "Centrer horizontalement",
|
"centerHorizontally": "Centrer horizontalement",
|
||||||
"distributeHorizontally": "Distribuer horizontalement",
|
"distributeHorizontally": "Distribuer horizontalement",
|
||||||
"distributeVertically": "Distribuer verticalement"
|
"distributeVertically": "Distribuer verticalement",
|
||||||
|
"viewMode": "Mode présentation"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Réinitialiser le canevas",
|
"clearReset": "Réinitialiser le canevas",
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"undo": "Annuler",
|
"undo": "Annuler",
|
||||||
"redo": "Rétablir",
|
"redo": "Rétablir",
|
||||||
"roomDialog": "Démarrer la collaboration en direct",
|
"resetLibrary": "Réinitialiser la bibliothèque",
|
||||||
"createNewRoom": "Créer une nouvelle salle",
|
"createNewRoom": "Créer une nouvelle salle",
|
||||||
"fullScreen": "Plein écran",
|
"fullScreen": "Plein écran",
|
||||||
"darkMode": "Mode sombre",
|
"darkMode": "Mode sombre",
|
||||||
@@ -135,10 +136,12 @@
|
|||||||
"decryptFailed": "Les données n'ont pas pu être déchiffrées.",
|
"decryptFailed": "Les données n'ont pas pu être déchiffrées.",
|
||||||
"uploadedSecurly": "Le téléchargement a été sécurisé avec un chiffrement de bout en bout, ce qui signifie que ni Excalidraw ni personne d'autre ne peut en lire le contenu.",
|
"uploadedSecurly": "Le téléchargement a été sécurisé avec un chiffrement de bout en bout, ce qui signifie que ni Excalidraw ni personne d'autre ne peut en lire le contenu.",
|
||||||
"loadSceneOverridePrompt": "Le chargement d'un dessin externe remplacera votre contenu actuel. Souhaitez-vous continuer ?",
|
"loadSceneOverridePrompt": "Le chargement d'un dessin externe remplacera votre contenu actuel. Souhaitez-vous continuer ?",
|
||||||
|
"collabStopOverridePrompt": "Arrêter la session écrasera votre précédent dessin stocké localement. Êtes-vous sûr·e ?\n\n(Si vous voulez garder votre dessin local, fermez simplement l'onglet du navigateur à la place.)",
|
||||||
"errorLoadingLibrary": "Une erreur s'est produite lors du chargement de la bibliothèque tierce.",
|
"errorLoadingLibrary": "Une erreur s'est produite lors du chargement de la bibliothèque tierce.",
|
||||||
"confirmAddLibrary": "Cela va ajouter {{numShapes}} forme(s) à votre bibliothèque. Êtes-vous sûr·e ?",
|
"confirmAddLibrary": "Cela va ajouter {{numShapes}} forme(s) à votre bibliothèque. Êtes-vous sûr·e ?",
|
||||||
"imageDoesNotContainScene": "L'importation d'images n'est pas prise en charge pour le moment.\n\nVouliez-vous importer une scène ? Cette image ne semble pas contenir de données de scène. Avez-vous activé cette option lors de l'exportation ?",
|
"imageDoesNotContainScene": "L'importation d'images n'est pas prise en charge pour le moment.\n\nVouliez-vous importer une scène ? Cette image ne semble pas contenir de données de scène. Avez-vous activé cette option lors de l'exportation ?",
|
||||||
"cannotRestoreFromImage": "Impossible de restaurer la scène depuis ce fichier image"
|
"cannotRestoreFromImage": "Impossible de restaurer la scène depuis ce fichier image",
|
||||||
|
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?"
|
||||||
},
|
},
|
||||||
"toolBar": {
|
"toolBar": {
|
||||||
"selection": "Sélection",
|
"selection": "Sélection",
|
||||||
@@ -229,14 +232,20 @@
|
|||||||
"elements": "Éléments",
|
"elements": "Éléments",
|
||||||
"height": "Hauteur",
|
"height": "Hauteur",
|
||||||
"scene": "Scène",
|
"scene": "Scène",
|
||||||
"selected": "Sélectionné",
|
"selected": "Sélection",
|
||||||
"storage": "Stockage",
|
"storage": "Stockage",
|
||||||
"title": "Stats pour les nerds",
|
"title": "Stats pour les nerds",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
"version": "Version",
|
||||||
|
"versionCopy": "Cliquer pour copier",
|
||||||
|
"versionNotAvailable": "Version non disponible",
|
||||||
"width": "Largeur"
|
"width": "Largeur"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"copyStyles": "Styles copiés.",
|
"copyStyles": "Styles copiés.",
|
||||||
"copyToClipboardAsPng": "Copié vers le presse-papier en PNG."
|
"copyToClipboard": "Copié vers le presse-papiers.",
|
||||||
|
"copyToClipboardAsPng": "Copié vers le presse-papier en PNG.",
|
||||||
|
"fileSaved": "Fichier enregistré.",
|
||||||
|
"fileSavedToFilename": "Enregistré sous {filename}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user