Compare commits

...

57 Commits

Author SHA1 Message Date
Preet Shihn b20fe944e7 add max shape count 2021-02-05 10:27:06 -08:00
Preet Shihn 1156ef6b96 pixelated images initial experiment 2021-02-04 23:27:35 -08:00
Excalidraw Bot dc25fe06d0 chore: Update translations from Crowdin (#2906) 2021-02-04 20:18:57 +02:00
David Luzar 1e17c1967b fix: toolbar unnecessarily eats too much width (#2924) 2021-02-04 16:52:16 +01:00
David Luzar 23a8891e0e fix: mistakenly hardcoding scale (#2925) 2021-02-04 16:52:00 +01:00
David Luzar 6c81a32d62 fix: text editor not visible in dark mode (#2920) 2021-02-04 16:21:48 +01:00
David Luzar f0f5430313 feat: support supplying custom scale when exporting canvas (#2904) 2021-02-04 14:54:08 +01:00
David Luzar e18e945cd3 fix: incorrect z-index of text editor (#2914) 2021-02-04 14:54:00 +01:00
Lipis bd0c6e63ff feat: Show version in the stats dialog (#2908)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:47:46 +01:00
Christophe pasquier dbae33e4f8 fix: make scrollbars draggable when offsets are set (#2916)
Co-authored-by: dwelle <luzar.david@gmail.com>
2021-02-04 13:07:29 +01:00
Lipis 0f4a053759 chore: Run actions on pull requests as well (#2917)
* chore: Run actions on pull requests as well

* Update cancel.yml

* Update lint.yml

* Update test.yml

* Update cancel.yml
2021-02-04 13:01:20 +01:00
Thomas Steiner 1837147c55 feat: Add idle detection to collaboration feature (#2877)
* Start idle detection implementation

* First working version

* Add screen state

* Add type safety

* Better rendering, enum types, localization

* Add origin trial token

* Fix

* Refactor idle detection to no longer use IdleDetector API

* Cleanup some leftovers

* Fix

* Apply suggestions from code review

* Three state: active 🟢, idle 💤, away ️

* Address feedback from code review
Thanks, @lipis

* Deal with unmount

Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-02-04 11:55:43 +01:00
David Luzar 15f698dc21 fix: pointer-events being disabled on free-draw (#2912) 2021-02-03 20:43:16 +01:00
David Luzar ce507b0a0b feat: don't store to LS during collab (#2909) 2021-02-03 19:14:26 +01:00
Excalidraw Bot 02598c6163 chore: Update translations from Crowdin (#2898) 2021-02-02 22:06:44 +00:00
Aakansha Doshi 3e2890bd21 fix: track zenmode and grid mode usage (#2900) 2021-02-02 20:57:22 +05:30
Lipis 210649f383 refactor: Use the latest vercel configuration instead of now (#2893)
* refactor: Use the latest vercel configuration instead of now

* Remove redict
2021-02-02 14:38:33 +01:00
Excalidraw Bot f8087e01c8 chore: Update translations from Crowdin (#2894) 2021-02-02 09:41:57 +00:00
Excalidraw Bot e2522645f7 Update i18n.ts 2021-02-02 11:35:52 +02:00
Excalidraw Bot d46a9166be Update locales-coverage-description.js 2021-02-02 11:35:00 +02:00
Aakansha Doshi 675da16ca4 feat: add view mode in Excalidraw (#2840)
Co-authored-by: Lipis <lipiridis@gmail.com>
2021-02-01 21:56:42 +01:00
dependabot[bot] 2b1b62d8f2 chore(deps): bump @sentry/integrations from 6.0.1 to 6.0.3 (#2889)
* chore(deps): bump @sentry/integrations from 6.0.1 to 6.0.3

Bumps [@sentry/integrations](https://github.com/getsentry/sentry-javascript) from 6.0.1 to 6.0.3.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/6.0.1...6.0.3)

Signed-off-by: dependabot[bot] <support@github.com>

* Update both

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Panayiotis Lipiridis <lipiridis@gmail.com>
2021-02-01 12:59:03 +00:00
David Luzar 627c56ef1c fix: disable UI pointer-events on canvas drag (#2856) 2021-02-01 14:55:38 +02:00
Excalidraw Bot 0d0fe32485 chore: Update translations from Crowdin (#2875) 2021-02-01 13:23:08 +02:00
dependabot[bot] 6576b9442e chore(deps): bump firebase from 8.2.4 to 8.2.5 (#2888)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.4 to 8.2.5.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/compare/firebase@8.2.4...firebase@8.2.5)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:58:05 +02:00
dependabot[bot] ee4cb2d4a9 chore(deps-dev): bump webpack from 5.17.0 to 5.19.0 in /src/packages/utils (#2887)
Bumps [webpack](https://github.com/webpack/webpack) from 5.17.0 to 5.19.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.17.0...v5.19.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:53 +02:00
dependabot[bot] cdcc91faa5 chore(deps-dev): bump mini-css-extract-plugin from 1.3.4 to 1.3.5 in /src/packages/excalidraw (#2885)
Bumps [mini-css-extract-plugin](https://github.com/webpack-contrib/mini-css-extract-plugin) from 1.3.4 to 1.3.5.
- [Release notes](https://github.com/webpack-contrib/mini-css-extract-plugin/releases)
- [Changelog](https://github.com/webpack-contrib/mini-css-extract-plugin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webpack-contrib/mini-css-extract-plugin/compare/v1.3.4...v1.3.5)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:36 +02:00
dependabot[bot] 9093341dc1 chore(deps-dev): bump webpack from 5.17.0 to 5.19.0 in /src/packages/excalidraw (#2886)
Bumps [webpack](https://github.com/webpack/webpack) from 5.17.0 to 5.19.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.17.0...v5.19.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-31 20:57:19 +02:00
David Luzar 1973ae9444 fix: stop flooring scroll positions (#2883) 2021-01-31 10:47:43 +01:00
David Luzar 10cd6a24b0 feat: increase max zoom (#2881) 2021-01-30 18:03:23 +01:00
David Luzar 6abf4f52ff refactor: remove duplicate key handling (#2878) 2021-01-30 10:30:00 +01:00
Aakansha Doshi 4624ec2bd6 fix: apply initialData appState for zenmode and grid stats and refactor check param for actions (#2871)
* fix: pass default value for grid mode / zen mode so it sets the value from initialData appState

fixes #2870

* change checked from boolean to be a function which recieves appState and returns boolean

* fix

* use clsx

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-29 23:38:37 +05:30
Arun e8685c5236 feat: Remove copy & paste from context menu on desktop (#2872)
* Remove copy & paste from context menu on desktop

* fix build

* Make requested changes

* More changes

* make into function

* update changelog

* fix tests

Co-authored-by: dwelle <luzar.david@gmail.com>
2021-01-28 22:02:00 +01:00
Aakansha Doshi 6e9df2bae7 fix(app.tsx): show correct state of Nerd stats in context menu when nerd stats dialog closed (#2874)
fixes #2873
2021-01-29 02:21:10 +05:30
Lipis ed0bec41dc chore: Update workflows to use the latest Node (#2863) 2021-01-28 19:20:48 +05:30
David Luzar d4e12a2962 reuse scss variables in js for SSOT (#2867) 2021-01-28 17:28:35 +05:30
Kartik Prajapati 978e85a33b feat: Add separators on context menu (#2659)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
2021-01-27 20:11:17 +01:00
Thomas Steiner b5e26ba81f refactor: Rename browser-nativefs to browser-fs-access (#2862) 2021-01-27 20:47:55 +02:00
Excalidraw Bot 4392a4644a chore: Update translations from Crowdin (#2861) 2021-01-27 14:22:37 +02:00
Lipis b1c8c538ee fix: Change the timeout for reporting the version to 30s (#2858) 2021-01-26 22:22:41 +02:00
Excalidraw Bot f6492895df chore: Update translations from Crowdin (#2839)
Co-authored-by: Excalidraw Bot <77840495+excalidrawbot@users.noreply.github.com>
2021-01-26 14:48:36 +02:00
David Luzar e75f5f20e7 fix: remote pointers not accounting for offset (#2855) 2021-01-25 10:47:48 +01:00
David Luzar 0a0be839b9 refactor: rewrite collabWrapper to remove TDZs and simplify (#2834) 2021-01-25 10:47:35 +01:00
dependabot[bot] 03f6d9c783 chore(deps-dev): bump webpack-cli from 4.3.1 to 4.4.0 in /src/packages/excalidraw (#2847)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 09:00:02 +00:00
dependabot[bot] df745c1098 chore(deps-dev): bump webpack from 5.15.0 to 5.17.0 in /src/packages/excalidraw (#2844)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 08:59:37 +00:00
dependabot[bot] b888f7e7ba chore(deps-dev): bump webpack-bundle-analyzer from 4.3.0 to 4.4.0 in /src/packages/utils (#2848)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 08:58:30 +00:00
dependabot[bot] 3010253f72 chore(deps): bump @sentry/browser from 5.30.0 to 6.0.1 (#2852)
Bumps [@sentry/browser](https://github.com/getsentry/sentry-javascript) from 5.30.0 to 6.0.1.
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/5.30.0...6.0.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 08:57:12 +00:00
dependabot[bot] 0815f3282e chore(deps-dev): bump webpack-cli from 4.3.1 to 4.4.0 in /src/packages/utils (#2849)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:55:38 +02:00
dependabot[bot] 9dd58288de chore(deps-dev): bump webpack-bundle-analyzer from 4.3.0 to 4.4.0 in /src/packages/excalidraw (#2845)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:54:48 +02:00
dependabot[bot] c2e2bb495c chore(deps-dev): bump webpack from 5.15.0 to 5.17.0 in /src/packages/utils (#2846)
Bumps [webpack](https://github.com/webpack/webpack) from 5.15.0 to 5.17.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.15.0...v5.17.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:54:36 +02:00
dependabot[bot] a31cfe1f07 chore(deps-dev): bump firebase-tools from 9.2.1 to 9.2.2 (#2850)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:53:35 +02:00
dependabot[bot] d3367bfe12 chore(deps-dev): bump eslint-config-prettier from 7.1.0 to 7.2.0 (#2851)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:53:23 +02:00
dependabot[bot] 70791dfa7b chore(deps): bump firebase from 8.2.3 to 8.2.4 (#2853)
Bumps [firebase](https://github.com/firebase/firebase-js-sdk) from 8.2.3 to 8.2.4.
- [Release notes](https://github.com/firebase/firebase-js-sdk/releases)
- [Changelog](https://github.com/firebase/firebase-js-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/firebase/firebase-js-sdk/compare/firebase@8.2.3...firebase@8.2.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:52:52 +02:00
dependabot[bot] d63ec678db chore(deps): bump @sentry/integrations from 5.30.0 to 6.0.1 (#2854)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-24 10:52:41 +02:00
Lipis 26acebcdb6 chore: Update translations from Crowdin (#2817) 2021-01-22 18:06:21 +02:00
David Luzar 9dc930b447 feat: add ctrl-y to redo (#2831) 2021-01-21 16:21:54 +01:00
Aakansha Doshi 6e767fc949 fix(actionmenu): toggle help dialog when "shift+?" is pressed (#2828) 2021-01-21 13:13:47 +02:00
134 changed files with 3008 additions and 1611 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- master - master
jobs: jobs:
build-docker: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+2 -2
View File
@@ -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: |
+9 -3
View File
@@ -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
+3 -9
View File
@@ -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
+4 -4
View File
@@ -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
+1
View File
@@ -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:
+3 -2
View File
@@ -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: |
+3 -9
View File
@@ -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
+86 -125
View File
@@ -4,9 +4,9 @@
"lockfileVersion": 1, "lockfileVersion": 1,
"dependencies": { "dependencies": {
"@apidevtools/json-schema-ref-parser": { "@apidevtools/json-schema-ref-parser": {
"version": "9.0.6", "version": "9.0.7",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.7.tgz",
"integrity": "sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==", "integrity": "sha512-QdwOGF1+eeyFh+17v2Tz626WX0nucd1iKOm6JUTUvCZdbolblCOOQCxGrQPY0f7jEhn36PiAWqZnsC2r5vmUWg==",
"dev": true, "dev": true,
"requires": { "requires": {
"@jsdevtools/ono": "^7.1.3", "@jsdevtools/ono": "^7.1.3",
@@ -1308,9 +1308,9 @@
"integrity": "sha512-Jj2xW+8+8XPfWGkv9HPv/uR+Qrmq37NPYT352wf7MvE9LrstpLVmFg3LqG6MCRr5miLAom5sen2gZ+iOhVDeRA==" "integrity": "sha512-Jj2xW+8+8XPfWGkv9HPv/uR+Qrmq37NPYT352wf7MvE9LrstpLVmFg3LqG6MCRr5miLAom5sen2gZ+iOhVDeRA=="
}, },
"@firebase/app": { "@firebase/app": {
"version": "0.6.13", "version": "0.6.14",
"resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.6.13.tgz", "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.6.14.tgz",
"integrity": "sha512-xGrJETzvCb89VYbGSHFHCW7O/y067HRxT7MGehUE1xMxdPVBDNayHnxEuKwzfGvXAjVmajXBKFlKxaCWpgSjCQ==", "integrity": "sha512-ZQKuiJ+fzr4tULgWoXbW+AZVTGsejOkSrlQ+zx78WiGKIubpFJLklnP3S0oYr/1nHzr4vaKuM4G8IL1Wv/+MpQ==",
"requires": { "requires": {
"@firebase/app-types": "0.6.1", "@firebase/app-types": "0.6.1",
"@firebase/component": "0.1.21", "@firebase/component": "0.1.21",
@@ -1334,9 +1334,9 @@
"integrity": "sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg==" "integrity": "sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg=="
}, },
"@firebase/auth": { "@firebase/auth": {
"version": "0.16.1", "version": "0.16.2",
"resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.16.1.tgz", "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.16.2.tgz",
"integrity": "sha512-7juD7D/kaxNti/xa5G+ZGJJs+bdJUWOW0MlNBtXwiG+TjMh69EDmwJnQmmc9h/32QVvXt1qo1OGWOoMMpF/2Gg==", "integrity": "sha512-68TlDL0yh3kF8PiCzI8m8RWd/bf/xCLUsdz1NZ2Dwea0sp6e2WAhu0sem1GfhwuEwL+Ns4jCdX7qbe/OQlkVEA==",
"requires": { "requires": {
"@firebase/auth-types": "0.10.1" "@firebase/auth-types": "0.10.1"
} }
@@ -1368,9 +1368,9 @@
} }
}, },
"@firebase/database": { "@firebase/database": {
"version": "0.8.3", "version": "0.9.1",
"resolved": "https://registry.npmjs.org/@firebase/database/-/database-0.8.3.tgz", "resolved": "https://registry.npmjs.org/@firebase/database/-/database-0.9.1.tgz",
"integrity": "sha512-i29rr3kcPltIkA8La9M1lgsSxx9bfu5lCQ0T+tbJptZ3UpqpcL1NzCcZa24cJjiLgq3HQNPyLvUvCtcPSFDlRg==", "integrity": "sha512-JdxgNvniSZiAx+lrdAQxkCZOTv+UfdmhRm9JA4RTs4XOpvwzmRtJTAIGBn+9CWXUAkWkjt5CYHLmYysD7NGj6g==",
"requires": { "requires": {
"@firebase/auth-interop-types": "0.1.5", "@firebase/auth-interop-types": "0.1.5",
"@firebase/component": "0.1.21", "@firebase/component": "0.1.21",
@@ -1405,9 +1405,9 @@
} }
}, },
"@firebase/firestore": { "@firebase/firestore": {
"version": "2.1.2", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-2.1.4.tgz",
"integrity": "sha512-8yUdBLLr6UhE+IjPR+fxLBD0bDnEqF9GalohfURZeLQPaL3b+LtqqGCLvvXC4MKT0lJAHOV8J9LA6rHj8vI0/Q==", "integrity": "sha512-chSOvJyVoS7HmH7YOyqQP66wMwmsYNo2nPbFkrmQM/fRGXntNxXD1Greu1uts2hNyNeDLNrFHW5y7PlE3LAbwQ==",
"requires": { "requires": {
"@firebase/component": "0.1.21", "@firebase/component": "0.1.21",
"@firebase/firestore-types": "2.1.0", "@firebase/firestore-types": "2.1.0",
@@ -2663,125 +2663,86 @@
} }
}, },
"@sentry/browser": { "@sentry/browser": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.0.3.tgz",
"integrity": "sha512-rOb58ZNVJWh1VuMuBG1mL9r54nZqKeaIlwSlvzJfc89vyfd7n6tQ1UXMN383QBz/MS5H5z44Hy5eE+7pCrYAfw==", "integrity": "sha512-Ukxh83Twql4UmUgds9wPWllE62NG71cYvm5AM6daTojvM8wFR2jh7G6GiA0WYfgMb2fw6SlbevB2xb6RDG5DzQ==",
"requires": { "requires": {
"@sentry/core": "5.30.0", "@sentry/core": "6.0.3",
"@sentry/types": "5.30.0", "@sentry/types": "6.0.3",
"@sentry/utils": "5.30.0", "@sentry/utils": "6.0.3",
"tslib": "^1.9.3" "tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz",
"integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw=="
},
"@sentry/utils": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz",
"integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==",
"requires": {
"@sentry/types": "5.30.0",
"tslib": "^1.9.3"
}
}
} }
}, },
"@sentry/core": { "@sentry/core": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.0.3.tgz",
"integrity": "sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==", "integrity": "sha512-UykB/4/98y2DkNvwTiL2ofFPuK3KDHc7rIRNsdj6dg6D+Cf7FRexgmWUUkZrpC/y+QBj0TPqkcFDcZAuQDa3Ag==",
"requires": { "requires": {
"@sentry/hub": "5.30.0", "@sentry/hub": "6.0.3",
"@sentry/minimal": "5.30.0", "@sentry/minimal": "6.0.3",
"@sentry/types": "5.30.0", "@sentry/types": "6.0.3",
"@sentry/utils": "5.30.0", "@sentry/utils": "6.0.3",
"tslib": "^1.9.3" "tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz",
"integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw=="
},
"@sentry/utils": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz",
"integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==",
"requires": {
"@sentry/types": "5.30.0",
"tslib": "^1.9.3"
}
}
} }
}, },
"@sentry/hub": { "@sentry/hub": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.0.3.tgz",
"integrity": "sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==", "integrity": "sha512-BfV32tE09rjTWM9W0kk8gzxUC2k1h57Z5dNWJ35na79+LguNNtCcI6fHlFQ3PkJca6ITYof9FI8iQHUfsHFZnw==",
"requires": { "requires": {
"@sentry/types": "5.30.0", "@sentry/types": "6.0.3",
"@sentry/utils": "5.30.0", "@sentry/utils": "6.0.3",
"tslib": "^1.9.3"
}
},
"@sentry/integrations": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.0.3.tgz",
"integrity": "sha512-SE/rQ+ttfoC6FlHDibB4e9lV95j78YkjQ6PvYNUe+zGkGIretCJREqgaS+W3qTNYvOdbUViuiiqtdfyvW9nM2g==",
"requires": {
"@sentry/types": "6.0.3",
"@sentry/utils": "6.0.3",
"localforage": "^1.8.1",
"tslib": "^1.9.3" "tslib": "^1.9.3"
}, },
"dependencies": { "dependencies": {
"@sentry/types": { "@sentry/types": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.0.3.tgz",
"integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==" "integrity": "sha512-266aBQbk9AGedhG2dzXshWbn23LYLElXqlI74DLku48UrU2v7TGKdyik/8/nfOfquCoRSp0GFGYHbItwU124XQ=="
}, },
"@sentry/utils": { "@sentry/utils": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.0.3.tgz",
"integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==", "integrity": "sha512-lvuBFvZHYs1zYwI8dkC8Z8ryb0aYnwPFUl1rbZiMwJpYI2Dgl1jpqqZWv9luux2rSRYOMid74uGedV708rvEgA==",
"requires": { "requires": {
"@sentry/types": "5.30.0", "@sentry/types": "6.0.3",
"tslib": "^1.9.3" "tslib": "^1.9.3"
} }
} }
} }
}, },
"@sentry/integrations": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-5.30.0.tgz",
"integrity": "sha512-Fqh4ALLoQWdd+1ih0iBduANWFyNmFWMxwvBu3V/wLDRi8OcquI0lEzWai1InzTJTiNhRHPnhuU++l/vkO0OCww==",
"requires": {
"@sentry/types": "5.30.0",
"@sentry/utils": "5.30.0",
"localforage": "1.8.1",
"tslib": "^1.9.3"
}
},
"@sentry/minimal": { "@sentry/minimal": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.0.3.tgz",
"integrity": "sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==", "integrity": "sha512-YsW+nw0SMyyb7UQdjZeKlZjxbGsJFpXNLh9iIp6fHKnoLTTv17YPm2ej9sOikDsQuVotaPg/xn/Qt5wySGHIxw==",
"requires": { "requires": {
"@sentry/hub": "5.30.0", "@sentry/hub": "6.0.3",
"@sentry/types": "5.30.0", "@sentry/types": "6.0.3",
"tslib": "^1.9.3" "tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz",
"integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw=="
}
} }
}, },
"@sentry/types": { "@sentry/types": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.0.3.tgz",
"integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==" "integrity": "sha512-266aBQbk9AGedhG2dzXshWbn23LYLElXqlI74DLku48UrU2v7TGKdyik/8/nfOfquCoRSp0GFGYHbItwU124XQ=="
}, },
"@sentry/utils": { "@sentry/utils": {
"version": "5.30.0", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.0.3.tgz",
"integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==", "integrity": "sha512-lvuBFvZHYs1zYwI8dkC8Z8ryb0aYnwPFUl1rbZiMwJpYI2Dgl1jpqqZWv9luux2rSRYOMid74uGedV708rvEgA==",
"requires": { "requires": {
"@sentry/types": "5.30.0", "@sentry/types": "6.0.3",
"tslib": "^1.9.3" "tslib": "^1.9.3"
} }
}, },
@@ -5155,10 +5116,10 @@
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
}, },
"browser-nativefs": { "browser-fs-access": {
"version": "0.12.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/browser-nativefs/-/browser-nativefs-0.12.0.tgz", "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.13.0.tgz",
"integrity": "sha512-ZCHJcQI6bBm9YjB+6wMT1nWg+/mnWnz7r3gJ8sx7RjgLtWROFq+BuD12cAncD6y45MIbUqFM8eMKXoHXOxSFxA==" "integrity": "sha512-qP8zFVhRQThxYgBXdlFHbzIrWb1us0G5kL2ZL0vW4BO5llKE4qBAcQsQrw4KN+6vjw8sKeWaGWJtzijfRT4N0Q=="
}, },
"browser-process-hrtime": { "browser-process-hrtime": {
"version": "1.0.0", "version": "1.0.0",
@@ -7987,9 +7948,9 @@
} }
}, },
"eslint-config-prettier": { "eslint-config-prettier": {
"version": "7.1.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-7.1.0.tgz", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz",
"integrity": "sha512-9sm5/PxaFG7qNJvJzTROMM1Bk1ozXVTKI0buKOyb0Bsr1hrwi0H/TzxF/COtf1uxikIK8SwhX7K6zg78jAzbeA==", "integrity": "sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==",
"dev": true "dev": true
}, },
"eslint-config-react-app": { "eslint-config-react-app": {
@@ -9067,16 +9028,16 @@
} }
}, },
"firebase": { "firebase": {
"version": "8.2.3", "version": "8.2.5",
"resolved": "https://registry.npmjs.org/firebase/-/firebase-8.2.3.tgz", "resolved": "https://registry.npmjs.org/firebase/-/firebase-8.2.5.tgz",
"integrity": "sha512-WdbcGSiLxiW/kGZT+EyqD9z3Md7kR35+k9qMjDn/twiIrm6Hh7Qi/Z69cqxhKW6+4uK5ghXIF28CjK67OyD9Qw==", "integrity": "sha512-x9KUJR8PvqLUNzNKWHjAnO7rJVgK546G0F+vjlJTNl+J/8oFTdWh8X4PvYda0z0XM68A2Y9xPGf3blz5qHCn0A==",
"requires": { "requires": {
"@firebase/analytics": "0.6.2", "@firebase/analytics": "0.6.2",
"@firebase/app": "0.6.13", "@firebase/app": "0.6.14",
"@firebase/app-types": "0.6.1", "@firebase/app-types": "0.6.1",
"@firebase/auth": "0.16.1", "@firebase/auth": "0.16.2",
"@firebase/database": "0.8.3", "@firebase/database": "0.9.1",
"@firebase/firestore": "2.1.2", "@firebase/firestore": "2.1.4",
"@firebase/functions": "0.6.1", "@firebase/functions": "0.6.1",
"@firebase/installations": "0.4.19", "@firebase/installations": "0.4.19",
"@firebase/messaging": "0.7.3", "@firebase/messaging": "0.7.3",
@@ -9088,9 +9049,9 @@
} }
}, },
"firebase-tools": { "firebase-tools": {
"version": "9.2.1", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/firebase-tools/-/firebase-tools-9.2.1.tgz", "resolved": "https://registry.npmjs.org/firebase-tools/-/firebase-tools-9.2.2.tgz",
"integrity": "sha512-sD4wfB5hs/8IKXV6AJOmkpvXf/St7gVc9QeW4Qz21PG7CkirgRf6FqcYkPKtBcro4wfj48dihnYx/IO1+XPTGg==", "integrity": "sha512-AFjf7S9NjEM+u8ZByJEKASxRG1g+LLg/A0CrzA3V91P92MN+8cyrCigEs7mCdtFknLaShrCgzROyo/OEwd4xdA==",
"dev": true, "dev": true,
"requires": { "requires": {
"@google-cloud/pubsub": "^2.7.0", "@google-cloud/pubsub": "^2.7.0",
@@ -11562,9 +11523,9 @@
}, },
"dependencies": { "dependencies": {
"ip-regex": { "ip-regex": {
"version": "4.2.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz",
"integrity": "sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A==", "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==",
"dev": true "dev": true
} }
} }
@@ -14301,9 +14262,9 @@
} }
}, },
"localforage": { "localforage": {
"version": "1.8.1", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.8.1.tgz", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.9.0.tgz",
"integrity": "sha512-azSSJJfc7h4bVpi0PGi+SmLQKJl2/8NErI+LhJsrORNikMZnhaQ7rv9fHj+ofwgSHrKRlsDCL/639a6nECIKuQ==", "integrity": "sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==",
"requires": { "requires": {
"lie": "3.1.1" "lie": "3.1.1"
} }
+7 -7
View File
@@ -19,17 +19,17 @@
] ]
}, },
"dependencies": { "dependencies": {
"@sentry/browser": "5.30.0", "@sentry/browser": "6.0.3",
"@sentry/integrations": "5.30.0", "@sentry/integrations": "6.0.3",
"@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.3",
"@types/jest": "26.0.20", "@types/jest": "26.0.20",
"@types/react": "17.0.0", "@types/react": "17.0.0",
"@types/react-dom": "17.0.0", "@types/react-dom": "17.0.0",
"@types/socket.io-client": "1.4.35", "@types/socket.io-client": "1.4.35",
"browser-nativefs": "0.12.0", "browser-fs-access": "0.13.0",
"clsx": "1.1.1", "clsx": "1.1.1",
"firebase": "8.2.3", "firebase": "8.2.5",
"i18next-browser-languagedetector": "6.0.1", "i18next-browser-languagedetector": "6.0.1",
"lodash.throttle": "4.1.1", "lodash.throttle": "4.1.1",
"nanoid": "3.1.20", "nanoid": "3.1.20",
@@ -51,9 +51,9 @@
"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", "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.2.2",
"husky": "4.3.8", "husky": "4.3.8",
"jest-canvas-mock": "2.3.0", "jest-canvas-mock": "2.3.0",
"lint-staged": "10.5.3", "lint-staged": "10.5.3",
@@ -72,7 +72,7 @@
}, },
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)" "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)"
], ],
"resetMocks": false "resetMocks": false
}, },
+1
View File
@@ -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"
+4 -1
View File
@@ -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",
-1
View File
@@ -17,6 +17,5 @@ export const actionAddToLibrary = register({
}); });
return false; return false;
}, },
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary", contextItemLabel: "labels.addToLibrary",
}); });
+1 -2
View File
@@ -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) => {
+114
View File
@@ -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,
});
-1
View File
@@ -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
-2
View File
@@ -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,
+10 -6
View File
@@ -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"
+1 -1
View File
@@ -74,7 +74,7 @@ export const actionShortcuts = register({
return { return {
appState: { appState: {
...appState, ...appState,
showHelpDialog: true, showHelpDialog: !appState.showHelpDialog,
}, },
commitToHistory: false, commitToHistory: false,
}; };
-2
View File
@@ -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,
}); });
+22
View File
@@ -0,0 +1,22 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { trackEvent } from "../analytics";
export const actionToggleGridMode = register({
name: "gridMode",
perform(elements, appState) {
trackEvent("view", "mode", "grid");
return {
appState: {
...appState,
gridSize: this.checked!(appState) ? null : GRID_SIZE,
},
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
contextItemLabel: "labels.gridMode",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});
+16
View File
@@ -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",
});
+22
View File
@@ -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,
});
+22
View File
@@ -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,
});
+12
View File
@@ -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";
+17 -38
View File
@@ -3,14 +3,15 @@ import {
Action, Action,
ActionsManagerInterface, ActionsManagerInterface,
UpdaterFn, UpdaterFn,
ActionFilterFn,
ActionName, ActionName,
ActionResult, ActionResult,
} from "./types"; } from "./types";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import { t } from "../i18n";
import { ShortcutName } from "./shortcuts"; // This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"]; actions = {} as ActionsManagerInterface["actions"];
@@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void; updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>; getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: App;
constructor( constructor(
updater: UpdaterFn, updater: UpdaterFn,
getAppState: () => AppState, getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[], getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: App,
) { ) {
this.updater = (actionResult) => { this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) { if (actionResult && "then" in actionResult) {
@@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface {
}; };
this.getAppState = getAppState; this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted; this.getElementsIncludingDeleted = getElementsIncludingDeleted;
this.app = app;
} }
registerAction(action: Action) { registerAction(action: Action) {
@@ -63,6 +66,12 @@ export class ActionManager implements ActionsManagerInterface {
if (data.length === 0) { if (data.length === 0) {
return false; return false;
} }
const { viewModeEnabled } = this.getAppState();
if (viewModeEnabled) {
if (data[0].name !== "viewMode") {
return false;
}
}
event.preventDefault(); event.preventDefault();
this.updater( this.updater(
@@ -70,6 +79,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
return true; return true;
@@ -81,43 +91,11 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
null, null,
this.app,
), ),
); );
} }
getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
action.perform(
this.getElementsIncludingDeleted(),
this.getAppState(),
null,
),
);
},
}));
}
// Id is an attribute that we can use to pass in data like keys. // Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components // This is needed for dynamically generated action components
// like the user list. We can use this key to extract more // like the user list. We can use this key to extract more
@@ -132,6 +110,7 @@ export class ActionManager implements ActionsManagerInterface {
this.getElementsIncludingDeleted(), this.getElementsIncludingDeleted(),
this.getAppState(), this.getAppState(),
formState, formState,
this.app,
), ),
); );
}; };
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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">(
+327 -206
View File
@@ -2,8 +2,31 @@ 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 clsx from "clsx";
import "../actions"; import "../actions";
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 +41,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,
@@ -26,15 +48,15 @@ import {
ELEMENT_TRANSLATE_AMOUNT, ELEMENT_TRANSLATE_AMOUNT,
ENV, ENV,
EVENT, EVENT,
GRID_SIZE,
LINE_CONFIRM_THRESHOLD, LINE_CONFIRM_THRESHOLD,
MIME_TYPES, MIME_TYPES,
POINTER_BUTTON, POINTER_BUTTON,
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 +149,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 +176,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 +271,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 +298,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
offsetLeft, offsetLeft,
offsetTop, offsetTop,
excalidrawRef, excalidrawRef,
viewModeEnabled = false,
} = props; } = props;
this.state = { this.state = {
...defaultAppState, ...defaultAppState,
@@ -281,6 +306,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
width, width,
height, height,
...this.getCanvasOffsets({ offsetLeft, offsetTop }), ...this.getCanvasOffsets({ offsetLeft, offsetTop }),
viewModeEnabled,
}; };
if (excalidrawRef) { if (excalidrawRef) {
const readyPromise = const readyPromise =
@@ -298,6 +324,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 +339,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 +347,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 +410,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 +452,13 @@ 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}
/> />
<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 +469,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 +509,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (actionResult.commitToHistory) { if (actionResult.commitToHistory) {
history.resumeRecording(); history.resumeRecording();
} }
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
if (typeof this.props.viewModeEnabled !== "undefined") {
viewModeEnabled = this.props.viewModeEnabled;
}
this.setState( this.setState(
(state) => ({ (state) => ({
...actionResult.appState, ...actionResult.appState,
@@ -453,6 +525,7 @@ 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,
}), }),
() => { () => {
if (actionResult.syncHistory) { if (actionResult.syncHistory) {
@@ -635,7 +708,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 +774,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 +800,18 @@ 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);
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 +834,17 @@ 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();
}
document document
.querySelector(".excalidraw") .querySelector(".excalidraw")
?.classList.toggle("Appearance_dark", this.state.appearance === "dark"); ?.classList.toggle("Appearance_dark", this.state.appearance === "dark");
@@ -798,6 +884,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 +900,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 +937,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,
}, },
{ {
@@ -906,44 +997,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 +1199,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 +1296,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 });
} }
@@ -1778,8 +1812,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 +1830,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) {
@@ -2080,14 +2115,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 +2174,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 +2228,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 +2290,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 },
@@ -3009,9 +3043,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 +3053,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;
@@ -3423,11 +3453,40 @@ class App extends React.Component<ExcalidrawProps, AppState> {
} }
}; };
private handleCanvasImageDrop = async (
event: React.DragEvent<HTMLCanvasElement>,
file: File,
) => {
try {
const shapes = await (
await import(
/* webpackChunkName: "pixelated-image" */ "../data/pixelated-image"
)
).pixelateImage(file, 20, 1200, event.clientX, event.clientY);
const nextElements = [
...this.scene.getElementsIncludingDeleted(),
...shapes,
];
this.scene.replaceAllElements(nextElements);
} catch (error) {
return this.setState({
isLoading: false,
errorMessage: error.message,
});
}
};
private handleCanvasOnDrop = async ( private handleCanvasOnDrop = async (
event: React.DragEvent<HTMLCanvasElement>, event: React.DragEvent<HTMLCanvasElement>,
) => { ) => {
let imageFile: File | null = null;
try { try {
const file = event.dataTransfer.files[0]; const file = event.dataTransfer.files[0];
if (file?.type.indexOf("image/") === 0) {
imageFile = file;
}
if (file?.type === "image/png" || file?.type === "image/svg+xml") { if (file?.type === "image/png" || file?.type === "image/svg+xml") {
const { elements, appState } = await loadFromBlob(file, this.state); const { elements, appState } = await loadFromBlob(file, this.state);
this.syncActionResult({ this.syncActionResult({
@@ -3439,8 +3498,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
commitToHistory: true, commitToHistory: true,
}); });
return; return;
} else if (imageFile) {
return await this.handleCanvasImageDrop(event, imageFile);
} }
} catch (error) { } catch (error) {
if (imageFile) {
return await this.handleCanvasImageDrop(event, imageFile);
}
return this.setState({ return this.setState({
isLoading: false, isLoading: false,
errorMessage: error.message, errorMessage: error.message,
@@ -3616,52 +3680,87 @@ 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: ContextMenuOption[] = [
...options,
actionToggleStats,
];
if (typeof this.props.viewModeEnabled === "undefined") {
viewModeOptions.push(actionToggleViewMode);
}
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,
), actionToggleGridMode,
{ actionToggleZenMode,
checked: this.state.gridSize !== null, typeof this.props.viewModeEnabled === "undefined" &&
shortcutName: "gridMode", actionToggleViewMode,
label: t("labels.gridMode"), actionToggleStats,
action: this.toggleGridMode,
},
{
checked: this.state.zenModeEnabled,
shortcutName: "zenMode",
label: t("buttons.zenMode"),
action: this.toggleZenMode,
},
{
checked: this.state.showStats,
shortcutName: "stats",
label: t("stats.title"),
action: this.toggleStats,
},
], ],
top: clientY, top: clientY,
left: clientX, left: clientX,
actionManager: this.actionManager,
appState: this.state,
}); });
return; return;
} }
@@ -3670,39 +3769,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 +3848,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 +3879,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 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Avatar { .Avatar {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.CollabButton.is-collaborating { .CollabButton.is-collaborating {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.color-picker { .color-picker {
+8 -2
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.context-menu { .context-menu {
@@ -9,9 +9,10 @@
list-style: none; list-style: none;
user-select: none; user-select: none;
margin: -0.25rem 0 0 0.125rem; margin: -0.25rem 0 0 0.125rem;
padding: 0.25rem 0; padding: 0.5rem 0;
background-color: var(--popup-secondary-background-color); background-color: var(--popup-secondary-background-color);
border: 1px solid var(--button-gray-3); border: 1px solid var(--button-gray-3);
cursor: default;
} }
.context-menu button { .context-menu button {
@@ -88,4 +89,9 @@
} }
} }
} }
.context-menu-option-separator {
border: none;
border-top: 1px solid $oc-gray-5;
}
} }
+50 -27
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Dialog { .Dialog {
+3 -10
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.ExportDialog__preview { .ExportDialog__preview {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.HelpDialog h3 { .HelpDialog h3 {
+13 -2
View File
@@ -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";
@@ -227,6 +227,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("labels.gridMode")} label={t("labels.gridMode")}
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 -1
View File
@@ -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;
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.picker-container { .picker-container {
+2
View File
@@ -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;
+83 -44
View File
@@ -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";
@@ -61,6 +61,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 = (
@@ -299,6 +300,7 @@ const LayerUI = ({
isCollaborating, isCollaborating,
onExportToBackend, onExportToBackend,
renderCustomFooter, renderCustomFooter,
viewModeEnabled,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -358,6 +360,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"
@@ -448,38 +472,42 @@ const LayerUI = ({
gap={4} gap={4}
className={clsx({ "disable-pointerEvents": zenModeEnabled })} className={clsx({ "disable-pointerEvents": zenModeEnabled })}
> >
{renderCanvasActions()} {viewModeEnabled
? renderViewModeCanvasActions()
: renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col> </Stack.Col>
<Section heading="shapes"> {!viewModeEnabled && (
{(heading) => ( <Section heading="shapes">
<Stack.Col gap={4} align="start"> {(heading) => (
<Stack.Row gap={1}> <Stack.Col gap={4} align="start">
<Island <Stack.Row gap={1}>
padding={1} <Island
className={clsx({ "zen-mode": zenModeEnabled })} padding={1}
> className={clsx({ "zen-mode": zenModeEnabled })}
<HintViewer appState={appState} elements={elements} /> >
{heading} <HintViewer appState={appState} elements={elements} />
<Stack.Row gap={1}> {heading}
<ShapesSwitcher <Stack.Row gap={1}>
elementType={appState.elementType} <ShapesSwitcher
setAppState={setAppState} elementType={appState.elementType}
isLibraryOpen={appState.isLibraryOpen} setAppState={setAppState}
/> isLibraryOpen={appState.isLibraryOpen}
</Stack.Row> />
</Island> </Stack.Row>
<LockIcon </Island>
zenModeEnabled={zenModeEnabled} <LockIcon
checked={appState.elementLocked} zenModeEnabled={zenModeEnabled}
onChange={onLockToggle} checked={appState.elementLocked}
title={t("toolBar.lock")} onChange={onLockToggle}
/> title={t("toolBar.lock")}
</Stack.Row> />
{libraryMenu} </Stack.Row>
</Stack.Col> {libraryMenu}
)} </Stack.Col>
</Section> )}
</Section>
)}
<UserList <UserList
className={clsx("zen-mode-transition", { className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled, "transition-right": zenModeEnabled,
@@ -524,6 +552,20 @@ const LayerUI = ({
); );
}; };
const renderGitHubCorner = () => {
return (
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
>
<GitHubCorner appearance={appState.appearance} />
</aside>
);
};
const renderFooter = () => ( const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer"> <footer role="contentinfo" className="layer-ui__wrapper__footer">
<div <div
@@ -599,25 +641,22 @@ 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()}
</div> </div>
); );
+160 -116
View File
@@ -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,164 @@ 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)}
<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 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Modal { .Modal {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.PasteChartDialog { .PasteChartDialog {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.Stats { .Stats {
position: fixed; position: fixed;
+37 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables.scss"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.TextInput { .TextInput {
+1 -1
View File
@@ -1,4 +1,4 @@
@import "../css/_variables"; @import "../css/variables.module";
.excalidraw { .excalidraw {
.Toast { .Toast {
+1 -1
View File
@@ -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 {
+2 -10
View File
@@ -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;
} }
+9 -1
View File
@@ -46,6 +46,7 @@ export enum EVENT {
TOUCH_START = "touchstart", TOUCH_START = "touchstart",
TOUCH_END = "touchend", TOUCH_END = "touchend",
HASHCHANGE = "hashchange", HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
} }
export const ENV = { export const ENV = {
@@ -90,4 +91,11 @@ 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 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;
+42
View File
@@ -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,
};
};
+24 -3
View File
@@ -1,6 +1,12 @@
@import "./_variables"; @import "./variables.module";
@import "./theme"; @import "./theme";
:root {
--zIndex-canvas: 1;
--zIndex-wysiwyg: 2;
--zIndex-layerUI: 3;
}
.excalidraw { .excalidraw {
color: var(--text-color-primary); color: var(--text-color-primary);
display: flex; display: flex;
@@ -30,6 +36,8 @@
image-rendering: pixelated; // chromium image-rendering: pixelated; // chromium
// NOTE: must be declared *after* the above // NOTE: must be declared *after* the above
image-rendering: -moz-crisp-edges; // FF image-rendering: -moz-crisp-edges; // FF
z-index: var(--zIndex-canvas);
} }
&.Appearance_dark { &.Appearance_dark {
@@ -216,6 +224,12 @@
} }
} }
.App-top-bar {
z-index: var(--zIndex-layerUI);
display: flex;
justify-content: center;
}
.App-bottom-bar { .App-bottom-bar {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -282,7 +296,7 @@
pointer-events: none !important; pointer-events: none !important;
} }
.App-menu_top > * { .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * {
pointer-events: all; pointer-events: all;
} }
@@ -323,7 +337,7 @@
} }
} }
.App-menu_bottom > * { .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * {
pointer-events: all; pointer-events: all;
} }
@@ -492,6 +506,13 @@
pointer-events: none !important; pointer-events: none !important;
} }
&.excalidraw--view-mode {
.App-menu {
display: flex;
justify-content: space-between;
}
}
@media print { @media print {
.App-bottom-bar, .App-bottom-bar,
.FixedSideContainer, .FixedSideContainer,
@@ -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
View File
@@ -1,4 +1,4 @@
import { fileSave } from "browser-nativefs"; import { fileSave } from "browser-fs-access";
import { import {
copyCanvasToClipboardAsPng, copyCanvasToClipboardAsPng,
copyTextToSystemClipboard, copyTextToSystemClipboard,
+1 -1
View File
@@ -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";
+106
View File
@@ -0,0 +1,106 @@
import { ExcalidrawGenericElement, NonDeleted } from "../element/types";
import { newElement } from "../element";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "../constants";
import { randomId } from "../random";
const loadImage = async (url: string): Promise<HTMLImageElement> => {
const image = new Image();
return new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = (err) =>
reject(
new Error(
`Failed to load image: ${err ? err.toString : "unknown error"}`,
),
);
image.onabort = () =>
reject(new Error(`Failed to load image: image load aborted`));
image.src = url;
});
};
const commonProps = {
fillStyle: "solid",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
opacity: 100,
roughness: 1,
strokeColor: "transparent",
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
export const pixelateImage = async (
blob: Blob,
cellSize: number,
suggestedMaxShapeCount: number,
x: number,
y: number,
) => {
const url = URL.createObjectURL(blob);
try {
const image = await loadImage(url);
// initialize canvas for pixelation
const { width, height } = image;
let canvasWidth = Math.floor(width / cellSize);
let canvasHeight = Math.floor(height / cellSize);
const shapeCount = canvasHeight * canvasWidth;
if (shapeCount > suggestedMaxShapeCount) {
canvasWidth = Math.floor(
canvasWidth * (suggestedMaxShapeCount / shapeCount),
);
canvasHeight = Math.floor(
canvasHeight * (suggestedMaxShapeCount / shapeCount),
);
}
const xOffset = x - (canvasWidth * cellSize) / 2;
const yOffset = y - (canvasHeight * cellSize) / 2;
const canvas =
"OffscreenCanvas" in window
? new OffscreenCanvas(canvasWidth, canvasHeight)
: document.createElement("canvas");
canvas.width = canvasWidth;
canvas.height = canvasHeight;
// Draw image on canvas
const ctx = canvas.getContext("2d")!;
ctx.drawImage(image, 0, 0, width, height, 0, 0, canvasWidth, canvasHeight);
const imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
const buffer = imageData.data;
const groupId = randomId();
const shapes: NonDeleted<ExcalidrawGenericElement>[] = [];
for (let row = 0; row < canvasHeight; row++) {
for (let col = 0; col < canvasWidth; col++) {
const offset = row * canvasWidth * 4 + col * 4;
const r = buffer[offset];
const g = buffer[offset + 1];
const b = buffer[offset + 2];
const alpha = buffer[offset + 3];
if (alpha) {
const color = `rgba(${r}, ${g}, ${b}, ${alpha})`;
const rectangle = newElement({
backgroundColor: color,
groupIds: [groupId],
...commonProps,
type: "rectangle",
x: xOffset + col * cellSize,
y: yOffset + row * cellSize,
width: cellSize,
height: cellSize,
});
shapes.push(rectangle);
}
}
}
return shapes;
} finally {
URL.revokeObjectURL(url);
}
};
+4 -3
View File
@@ -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"),
); );
+6 -5
View File
@@ -89,9 +89,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 +104,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();
@@ -160,7 +159,7 @@ export const textWysiwyg = ({
unbindUpdate(); unbindUpdate();
document.body.removeChild(editable); editable.remove();
}; };
const rebindBlur = () => { const rebindBlur = () => {
@@ -206,7 +205,9 @@ export const textWysiwyg = ({
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();
}; };
+355 -160
View File
@@ -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,
})}
</> </>
); );
} }
+30 -71
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
@import "../../css/_variables"; @import "../../css/variables.module";
.excalidraw { .excalidraw {
.RoomDialog-linkContainer { .RoomDialog-linkContainer {
+5
View File
@@ -0,0 +1,5 @@
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}
+9 -1
View File
@@ -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);
}; };
+27 -8
View File
@@ -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) =>
+55 -132
View File
@@ -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,20 +77,17 @@ 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 (roomLinkData || window.confirm(t("alerts.loadSceneOverridePrompt"))) {
shouldForceLoadScene(scene) ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
// Backwards compatibility with legacy url format // Backwards compatibility with legacy url format
if (id) { if (id) {
scene = await loadScene(id, null, initialData); scene = await loadScene(id, null, initialData);
} 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 +104,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 +151,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 +195,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 +205,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 +269,20 @@ 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 }} user={{ name: collabAPI?.username }}
onCollabButtonClick={collab.onCollabButtonClick} onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collab.isCollaborating} isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collab.onPointerUpdate} onPointerUpdate={collabAPI?.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 +296,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
View File
@@ -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,
}; };
}; };
+1 -1
View File
@@ -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;
} }
+7
View File
@@ -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;
};
+1
View File
@@ -27,6 +27,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" },
+14 -10
View File
@@ -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);
} }
+4
View File
@@ -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;
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "وضع الشبكة", "gridMode": "وضع الشبكة",
"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": "إعادة تعيين اللوحة",
@@ -135,6 +136,7 @@
"decryptFailed": "تعذر فك تشفير البيانات.", "decryptFailed": "تعذر فك تشفير البيانات.",
"uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.", "uploadedSecurly": "تم تأمين التحميل بتشفير النهاية إلى النهاية، مما يعني أن خادوم Excalidraw والأطراف الثالثة لا يمكنها قراءة المحتوى.",
"loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟", "loadSceneOverridePrompt": "تحميل الرسم الخارجي سيحل محل المحتوى الموجود لديك. هل ترغب في المتابعة؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.", "errorLoadingLibrary": "حصل خطأ أثناء تحميل مكتبة الطرف الثالث.",
"confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟", "confirmAddLibrary": "هذا سيضيف {{numShapes}} شكل إلى مكتبتك. هل أنت متأكد؟",
"imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟", "imageDoesNotContainScene": "استيراد الصور غير مدعوم في الوقت الراهن.\n\nهل تريد استيراد مشهد؟ لا يبدو أن هذه الصورة تحتوي على أي بيانات مشهد. هل قمت بسماح هذا أثناء التصدير؟",
@@ -233,10 +235,14 @@
"storage": "التخزين", "storage": "التخزين",
"title": "إحصائيات للمهووسين", "title": "إحصائيات للمهووسين",
"total": "المجموع", "total": "المجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "العرض" "width": "العرض"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+17 -11
View File
@@ -80,9 +80,9 @@
"gridMode": "Решетъчен режим", "gridMode": "Решетъчен режим",
"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": "Нулиране на платно",
@@ -135,6 +136,7 @@
"decryptFailed": "Данните не можаха да се дешифрират.", "decryptFailed": "Данните не можаха да се дешифрират.",
"uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.", "uploadedSecurly": "Качването е защитено с криптиране от край до край, което означава, че сървърът Excalidraw и трети страни не могат да четат съдържанието.",
"loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?", "loadSceneOverridePrompt": "Зареждането на външна рисунка ще презапише настоящото ви съдържание. Желаете ли да продължите?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.", "errorLoadingLibrary": "Възникна грешка при зареждането на външна библиотека.",
"confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?", "confirmAddLibrary": "Ще се добавят {{numShapes}} фигура(и) във вашата библиотека. Сигурни ли сте?",
"imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?", "imageDoesNotContainScene": "Импортирането на картинки не се поддържва в момента.\n\nИскате да импортнете сцена? Тази картинка не съдържа данни от сцена. Разрешили ли сте последното при експортирането?",
@@ -201,24 +203,24 @@
}, },
"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 +235,14 @@
"storage": "Съхранение на данни", "storage": "Съхранение на данни",
"title": "Статистика за хакери", "title": "Статистика за хакери",
"total": "Общо", "total": "Общо",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Широчина" "width": "Широчина"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Mode quadrícula", "gridMode": "Mode quadrícula",
"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ç",
@@ -135,6 +136,7 @@
"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 sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?", "imageDoesNotContainScene": "En aquest moment no sadmet la importació dimatges.\n\nVolies importar una escena? Sembla que aquesta imatge no conté cap dada descena. Ho has activat durant l'exportació?",
@@ -233,10 +235,14 @@
"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": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Rastermodus", "gridMode": "Rastermodus",
"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",
@@ -135,6 +136,7 @@
"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?",
@@ -233,10 +235,14 @@
"storage": "Speicher", "storage": "Speicher",
"title": "Statistiken für Nerds", "title": "Statistiken für Nerds",
"total": "Gesamt", "total": "Gesamt",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Breite" "width": "Breite"
}, },
"toast": { "toast": {
"copyStyles": "Formatierung kopiert.", "copyStyles": "Formatierung kopiert.",
"copyToClipboard": "",
"copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert." "copyToClipboardAsPng": "In die Zwischenablage als PNG kopiert."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Εμφάνιση σε πλέγμα", "gridMode": "Εμφάνιση σε πλέγμα",
"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": "Επαναφορά του καμβά",
@@ -135,6 +136,7 @@
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.", "decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.", "uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;", "loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.", "errorLoadingLibrary": "Υπήρξε ένα σφάλμα κατά τη φόρτωση της βιβλιοθήκης τρίτου μέρους.",
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβιλιοθήκη σας. Είστε σίγουροι;", "confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβιλιοθήκη σας. Είστε σίγουροι;",
"imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;", "imageDoesNotContainScene": "Η εισαγωγή εικόνων δεν υποστηρίζεται αυτή τη στιγμή.\n\nΜήπως θέλετε να εισαγάγετε μια σκηνή; Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει αυτό κατά την εξαγωγή;",
@@ -233,10 +235,14 @@
"storage": "Χώρος", "storage": "Χώρος",
"title": "Στατιστικά για σπασίκλες", "title": "Στατιστικά για σπασίκλες",
"total": "Σύνολο ", "total": "Σύνολο ",
"version": "Έκδοση",
"versionCopy": "Κάνε κλικ για αντιγραφή",
"versionNotAvailable": "Έκδοση μη διαθέσιμη",
"width": "Πλάτος" "width": "Πλάτος"
}, },
"toast": { "toast": {
"copyStyles": "Αντιγράφηκαν στυλ.", "copyStyles": "Αντιγράφηκαν στυλ.",
"copyToClipboard": "Αντιγράφηκε στο πρόχειρο.",
"copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG." "copyToClipboardAsPng": "Αντιγράφτηκε στο πρόχειρο ως PNG."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Grid mode", "gridMode": "Grid mode",
"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",
@@ -135,6 +136,7 @@
"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?",
@@ -233,10 +235,14 @@
"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.",
"copyToClipboard": "Copied to clipboard.",
"copyToClipboardAsPng": "Copied to clipboard as PNG." "copyToClipboardAsPng": "Copied to clipboard as PNG."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Modo cuadrícula", "gridMode": "Modo 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 biblioteca",
"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",
@@ -135,6 +136,7 @@
"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?",
@@ -233,10 +235,14 @@
"storage": "Almacenamiento", "storage": "Almacenamiento",
"title": "Estadísticas para nerds", "title": "Estadísticas para nerds",
"total": "Total", "total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Ancho" "width": "Ancho"
}, },
"toast": { "toast": {
"copyStyles": "Estilos copiados.", "copyStyles": "Estilos copiados.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Copiado al portapapeles como PNG." "copyToClipboardAsPng": "Copiado al portapapeles como PNG."
} }
} }
+27 -21
View File
@@ -80,9 +80,9 @@
"gridMode": "حالت شبکه ای", "gridMode": "حالت شبکه ای",
"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": "پاکسازی بوم نقاشی",
@@ -135,6 +136,7 @@
"decryptFailed": "رمزگشایی داده ها امکان پذیر نیست.", "decryptFailed": "رمزگشایی داده ها امکان پذیر نیست.",
"uploadedSecurly": "آپلود با رمزگذاری دو طرفه انجام میشود، به این معنی که سرور Excalidraw و اشخاص ثالث نمی توانند مطالب شما را بخوانند.", "uploadedSecurly": "آپلود با رمزگذاری دو طرفه انجام میشود، به این معنی که سرور Excalidraw و اشخاص ثالث نمی توانند مطالب شما را بخوانند.",
"loadSceneOverridePrompt": "بارگزاری یک طرح خارجی محتوای فعلی رو از بین میبرد. آیا میخواهید ادامه دهید؟", "loadSceneOverridePrompt": "بارگزاری یک طرح خارجی محتوای فعلی رو از بین میبرد. آیا میخواهید ادامه دهید؟",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "خطایی در بارگذاری کتابخانه ثالث وجود داشت.", "errorLoadingLibrary": "خطایی در بارگذاری کتابخانه ثالث وجود داشت.",
"confirmAddLibrary": "{{numShapes}} از اشکال به کتابخانه شما اضافه خواهد شد. مطمئن هستید؟", "confirmAddLibrary": "{{numShapes}} از اشکال به کتابخانه شما اضافه خواهد شد. مطمئن هستید؟",
"imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\nآیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟", "imageDoesNotContainScene": "وارد کردن تصویر در این لحظه امکان پذیر نمی باشد.\nآیا مایل به وارد کردن یک صحنه هستید؟ این تصویر به نظر می رسد که فاقد هرگونه اطلاعاتی مربوط به صحنه باشد. آیا این گزینه را در زمان وارد کردن تصویر فعال کرده اید؟",
@@ -200,25 +202,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 +235,14 @@
"storage": "حافظه", "storage": "حافظه",
"title": "آمار برای نردها", "title": "آمار برای نردها",
"total": "مجموع", "total": "مجموع",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "عرض" "width": "عرض"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "کپی سبک.",
"copyToClipboardAsPng": "" "copyToClipboard": "",
"copyToClipboardAsPng": "کپی در حافطه موقت به صورت PNG."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Ruudukkotila", "gridMode": "Ruudukkotila",
"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",
@@ -135,6 +136,7 @@
"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?",
@@ -233,10 +235,14 @@
"storage": "Tallennustila", "storage": "Tallennustila",
"title": "Nörttien tilastot", "title": "Nörttien tilastot",
"total": "Yhteensä", "total": "Yhteensä",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Leveys" "width": "Leveys"
}, },
"toast": { "toast": {
"copyStyles": "Tyylit kopioitu.", "copyStyles": "Tyylit kopioitu.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Kopioitu leikepöydälle PNG-tiedostona." "copyToClipboardAsPng": "Kopioitu leikepöydälle PNG-tiedostona."
} }
} }
+10 -4
View File
@@ -80,9 +80,9 @@
"gridMode": "Mode grille", "gridMode": "Mode 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",
@@ -135,6 +136,7 @@
"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 ?",
@@ -229,14 +231,18 @@
"elements": "Éléments", "elements": "Éléments",
"height": "Hauteur", "height": "Hauteur",
"scene": "Scène", "scene": "Scène",
"selected": "Sélection", "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.",
"copyToClipboard": "Copié vers le presse-papiers.",
"copyToClipboardAsPng": "Copié vers le presse-papier en PNG." "copyToClipboardAsPng": "Copié vers le presse-papier en PNG."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "מצב רשת", "gridMode": "מצב רשת",
"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": "אפס את הלוח",
@@ -135,6 +136,7 @@
"decryptFailed": "לא ניתן לפענח מידע.", "decryptFailed": "לא ניתן לפענח מידע.",
"uploadedSecurly": "ההעלאה הוצפנה מקצה לקצה, ולכן שרת Excalidraw וצד שלישי לא יכולים לקרוא את התוכן.", "uploadedSecurly": "ההעלאה הוצפנה מקצה לקצה, ולכן שרת Excalidraw וצד שלישי לא יכולים לקרוא את התוכן.",
"loadSceneOverridePrompt": "טעינה של ציור חיצוני תחליף את התוכן הקיים שלך. האם תרצה להמשיך?", "loadSceneOverridePrompt": "טעינה של ציור חיצוני תחליף את התוכן הקיים שלך. האם תרצה להמשיך?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "קרתה שגיאה בטעינת הספריה החיצונית.", "errorLoadingLibrary": "קרתה שגיאה בטעינת הספריה החיצונית.",
"confirmAddLibrary": "הפעולה תוסיף {{numShapes}} צורה(ות) לספריה שלך. האם אתה בטוח?", "confirmAddLibrary": "הפעולה תוסיף {{numShapes}} צורה(ות) לספריה שלך. האם אתה בטוח?",
"imageDoesNotContainScene": "אין תמיכה בייבוא תמונות כעת.\n\nהאם אתה רוצה לייבא תצוגה? התמונה הזאת אינה מכילה מידע על תצוגה. האם הפעלת את האפשרות הזאת בזמן הוצאת המידע?", "imageDoesNotContainScene": "אין תמיכה בייבוא תמונות כעת.\n\nהאם אתה רוצה לייבא תצוגה? התמונה הזאת אינה מכילה מידע על תצוגה. האם הפעלת את האפשרות הזאת בזמן הוצאת המידע?",
@@ -233,10 +235,14 @@
"storage": "אחסון", "storage": "אחסון",
"title": "סטטיסטיקות לחנונים", "title": "סטטיסטיקות לחנונים",
"total": "סה״כ", "total": "סה״כ",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "רוחב" "width": "רוחב"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+27 -21
View File
@@ -91,7 +91,8 @@
"centerVertically": "लंबवत केन्द्रित", "centerVertically": "लंबवत केन्द्रित",
"centerHorizontally": "क्षैतिज केन्द्रित", "centerHorizontally": "क्षैतिज केन्द्रित",
"distributeHorizontally": "क्षैतिज रूप से वितरित करें", "distributeHorizontally": "क्षैतिज रूप से वितरित करें",
"distributeVertically": "खड़ी रूप से वितरित करें" "distributeVertically": "खड़ी रूप से वितरित करें",
"viewMode": ""
}, },
"buttons": { "buttons": {
"clearReset": "कैनवास रीसेट करें", "clearReset": "कैनवास रीसेट करें",
@@ -135,6 +136,7 @@
"decryptFailed": "डेटा को डिक्रिप्ट नहीं किया जा सका।", "decryptFailed": "डेटा को डिक्रिप्ट नहीं किया जा सका।",
"uploadedSecurly": "अपलोड को एंड-टू-एंड एन्क्रिप्शन के साथ सुरक्षित किया गया है, जिसका मतलब है कि एक्सक्लूसिव सर्वर और थर्ड पार्टी कंटेंट नहीं पढ़ सकते हैं।", "uploadedSecurly": "अपलोड को एंड-टू-एंड एन्क्रिप्शन के साथ सुरक्षित किया गया है, जिसका मतलब है कि एक्सक्लूसिव सर्वर और थर्ड पार्टी कंटेंट नहीं पढ़ सकते हैं।",
"loadSceneOverridePrompt": "लोड हो रहा है बाहरी ड्राइंग आपके मौजूदा सामग्री को बदल देगा। क्या आप जारी रखना चाहते हैं?", "loadSceneOverridePrompt": "लोड हो रहा है बाहरी ड्राइंग आपके मौजूदा सामग्री को बदल देगा। क्या आप जारी रखना चाहते हैं?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "लाइब्रेरी लोड करने में त्रुटि", "errorLoadingLibrary": "लाइब्रेरी लोड करने में त्रुटि",
"confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्‍टि करें आकार संख्या", "confirmAddLibrary": "लाइब्रेरी जोड़ें पुष्‍टि करें आकार संख्या",
"imageDoesNotContainScene": "दृश्य में छवि नहीं है", "imageDoesNotContainScene": "दृश्य में छवि नहीं है",
@@ -200,25 +202,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": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।" "tooltip": "आपके चित्र अंत-से-अंत एन्क्रिप्टेड हैं, इसलिए एक्सक्लूसिव्रॉव के सर्वर उन्हें कभी नहीं देखेंगे।"
@@ -233,10 +235,14 @@
"storage": "संग्रह", "storage": "संग्रह",
"title": "बेवकूफ के लिए आँकड़े", "title": "बेवकूफ के लिए आँकड़े",
"total": "कुल", "total": "कुल",
"version": "संस्करण",
"versionCopy": "काॅपी करने के लिए क्लिक करें",
"versionNotAvailable": "संस्करण उपलब्ध नहीं है",
"width": "चौड़ाई" "width": "चौड़ाई"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "काॅपी कीए स्टाइल",
"copyToClipboardAsPng": "" "copyToClipboard": "क्लिपबोर्ड में कॉपी कीए",
"copyToClipboardAsPng": "क्लिपबोर्ड में PNG के रूप में कॉपी किए"
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Hálómód", "gridMode": "Hálómód",
"addToLibrary": "Hozzáadás a könyvtárhoz", "addToLibrary": "Hozzáadás a könyvtárhoz",
"removeFromLibrary": "Eltávólítás a könyvtárból", "removeFromLibrary": "Eltávólítás a könyvtárból",
"libraryLoadingMessage": "Könyvtár betöltése...", "libraryLoadingMessage": "Könyvtár betöltése",
"libraries": "Könyvtárak böngészése", "libraries": "Könyvtárak böngészése",
"loadingScene": "Jelenet betöltése...", "loadingScene": "Jelenet betöltése",
"align": "Igazítás", "align": "Igazítás",
"alignTop": "Felülre igazítás", "alignTop": "Felülre igazítás",
"alignBottom": "Alulra igazítás", "alignBottom": "Alulra igazítás",
@@ -91,7 +91,8 @@
"centerVertically": "Függőlegesen középre igazított", "centerVertically": "Függőlegesen középre igazított",
"centerHorizontally": "Vízszintesen középre igazított", "centerHorizontally": "Vízszintesen középre igazított",
"distributeHorizontally": "Vízszintes elosztás", "distributeHorizontally": "Vízszintes elosztás",
"distributeVertically": "Függőleges elosztás" "distributeVertically": "Függőleges elosztás",
"viewMode": ""
}, },
"buttons": { "buttons": {
"clearReset": "Vászon törlése", "clearReset": "Vászon törlése",
@@ -135,6 +136,7 @@
"decryptFailed": "Nem sikerült visszafejteni a titkosított adatot.", "decryptFailed": "Nem sikerült visszafejteni a titkosított adatot.",
"uploadedSecurly": "A feltöltést végpontok közötti titkosítással biztosítottuk, ami azt jelenti, hogy egy harmadik fél nem tudja megnézni a tartalmát, beleértve az Excalidraw szervereit is.", "uploadedSecurly": "A feltöltést végpontok közötti titkosítással biztosítottuk, ami azt jelenti, hogy egy harmadik fél nem tudja megnézni a tartalmát, beleértve az Excalidraw szervereit is.",
"loadSceneOverridePrompt": "A betöltött külső rajz felül fogja írnia meglévőt. Szeretnéd folytatni?", "loadSceneOverridePrompt": "A betöltött külső rajz felül fogja írnia meglévőt. Szeretnéd folytatni?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Hibába ütközött a harmarmadik féltől származó könyvtár betöltése.", "errorLoadingLibrary": "Hibába ütközött a harmarmadik féltől származó könyvtár betöltése.",
"confirmAddLibrary": "Ez a művelet {{numShapes}} formát fog hozzáadni a könyvtáradhoz. Biztos vagy benne?", "confirmAddLibrary": "Ez a művelet {{numShapes}} formát fog hozzáadni a könyvtáradhoz. Biztos vagy benne?",
"imageDoesNotContainScene": "Képek importálása egyelőre nem támogatott.\n\nEgy jelenetet szeretnél betölteni? Úgy tűnik ez a kép fájl nem tartalmazza a szükséges adatokat. Exportáláskor ezt egy külön opcióval lehet beállítani.", "imageDoesNotContainScene": "Képek importálása egyelőre nem támogatott.\n\nEgy jelenetet szeretnél betölteni? Úgy tűnik ez a kép fájl nem tartalmazza a szükséges adatokat. Exportáláskor ezt egy külön opcióval lehet beállítani.",
@@ -233,10 +235,14 @@
"storage": "Tárhely", "storage": "Tárhely",
"title": "Statisztikák", "title": "Statisztikák",
"total": "Összesen", "total": "Összesen",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Szélesség" "width": "Szélesség"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+28 -22
View File
@@ -80,9 +80,9 @@
"gridMode": "Mode grid", "gridMode": "Mode grid",
"addToLibrary": "Tambahkan ke pustaka", "addToLibrary": "Tambahkan ke pustaka",
"removeFromLibrary": "Hapus dari pustaka", "removeFromLibrary": "Hapus dari pustaka",
"libraryLoadingMessage": "Memuat pustaka...", "libraryLoadingMessage": "Memuat pustaka",
"libraries": "Telusur pustaka", "libraries": "Telusur pustaka",
"loadingScene": "Memuat pemandangan...", "loadingScene": "Memuat pemandangan",
"align": "Perataan", "align": "Perataan",
"alignTop": "Rata atas", "alignTop": "Rata atas",
"alignBottom": "Rata bawah", "alignBottom": "Rata bawah",
@@ -91,7 +91,8 @@
"centerVertically": "Pusatkan secara vertikal", "centerVertically": "Pusatkan secara vertikal",
"centerHorizontally": "Pusatkan secara horizontal", "centerHorizontally": "Pusatkan secara horizontal",
"distributeHorizontally": "Distribusikan horizontal", "distributeHorizontally": "Distribusikan horizontal",
"distributeVertically": "Distribusikan vertikal" "distributeVertically": "Distribusikan vertikal",
"viewMode": "Mode tampilan"
}, },
"buttons": { "buttons": {
"clearReset": "Setel Ulang Kanvas", "clearReset": "Setel Ulang Kanvas",
@@ -135,6 +136,7 @@
"decryptFailed": "Tidak dapat mengdekripsi data.", "decryptFailed": "Tidak dapat mengdekripsi data.",
"uploadedSecurly": "Pengunggahan ini telah diamankan menggunakan enkripsi end-to-end, artinya server Excalidraw dan pihak ketiga tidak data membaca nya", "uploadedSecurly": "Pengunggahan ini telah diamankan menggunakan enkripsi end-to-end, artinya server Excalidraw dan pihak ketiga tidak data membaca nya",
"loadSceneOverridePrompt": "Memuat gambar external akan mengganti konten Anda yang ada. Apakah Anda ingin melanjutkan?", "loadSceneOverridePrompt": "Memuat gambar external akan mengganti konten Anda yang ada. Apakah Anda ingin melanjutkan?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Terdapat kesalahan dalam memuat pustaka pihak ketiga.", "errorLoadingLibrary": "Terdapat kesalahan dalam memuat pustaka pihak ketiga.",
"confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?", "confirmAddLibrary": "Ini akan menambahkan {{numShapes}} bentuk ke pustaka Anda. Anda yakin?",
"imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?", "imageDoesNotContainScene": "Mengimpor gambar tidak didukung saat ini.\n\nApakah Anda ingin impor pemandangan? Gambar ini tidak berisi data pemandangan. Sudah ka Anda aktifkan ini ketika ekspor?",
@@ -200,25 +202,25 @@
"title": "Kesalahan" "title": "Kesalahan"
}, },
"helpDialog": { "helpDialog": {
"blog": "", "blog": "Baca blog kami",
"click": "", "click": "klik",
"curvedArrow": "", "curvedArrow": "Panah lengkung",
"curvedLine": "", "curvedLine": "Garis lengkung",
"documentation": "", "documentation": "Dokumentasi",
"drag": "", "drag": "seret",
"editor": "", "editor": "Editor",
"github": "", "github": "Menemukan masalah? Kirimkan",
"howto": "", "howto": "Ikuti panduan kami",
"or": "", "or": "atau",
"preventBinding": "", "preventBinding": "Cegah pengikatan panah",
"shapes": "", "shapes": "Bentuk",
"shortcuts": "", "shortcuts": "Pintasan keyboard",
"textFinish": "", "textFinish": "Selesai mengedit (teks)",
"textNewLine": "", "textNewLine": "Tambahkan baris baru (teks)",
"title": "", "title": "Bantuan",
"view": "", "view": "Tampilan",
"zoomToFit": "", "zoomToFit": "Perbesar agar sesuai dengan semua elemen",
"zoomToSelection": "" "zoomToSelection": "Perbesar ke seleksi"
}, },
"encrypted": { "encrypted": {
"tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya." "tooltip": "Gambar anda terenkripsi end-to-end sehingga server Excalidraw tidak akan pernah dapat melihatnya."
@@ -233,10 +235,14 @@
"storage": "Penyimpanan", "storage": "Penyimpanan",
"title": "Statistik untuk nerd", "title": "Statistik untuk nerd",
"total": "Total", "total": "Total",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Lebar" "width": "Lebar"
}, },
"toast": { "toast": {
"copyStyles": "Gaya tersalin.", "copyStyles": "Gaya tersalin.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Tersalin ke clipboard sebagai PNG." "copyToClipboardAsPng": "Tersalin ke clipboard sebagai PNG."
} }
} }
+28 -22
View File
@@ -80,9 +80,9 @@
"gridMode": "Modalità griglia", "gridMode": "Modalità griglia",
"addToLibrary": "Aggiungi alla libreria", "addToLibrary": "Aggiungi alla libreria",
"removeFromLibrary": "Rimuovi dalla libreria", "removeFromLibrary": "Rimuovi dalla libreria",
"libraryLoadingMessage": "Caricamento della biblioteca...", "libraryLoadingMessage": "Caricamento libreria…",
"libraries": "Sfoglia librerie", "libraries": "Sfoglia librerie",
"loadingScene": "Caricamento della scena...", "loadingScene": "Caricamento della scena",
"align": "Allinea", "align": "Allinea",
"alignTop": "Allinea in alto", "alignTop": "Allinea in alto",
"alignBottom": "Allinea in basso", "alignBottom": "Allinea in basso",
@@ -91,7 +91,8 @@
"centerVertically": "Centra Verticalmente", "centerVertically": "Centra Verticalmente",
"centerHorizontally": "Centra orizzontalmente", "centerHorizontally": "Centra orizzontalmente",
"distributeHorizontally": "Distribuisci orizzontalmente", "distributeHorizontally": "Distribuisci orizzontalmente",
"distributeVertically": "Distribuisci verticalmente" "distributeVertically": "Distribuisci verticalmente",
"viewMode": "Modalità visualizzazione"
}, },
"buttons": { "buttons": {
"clearReset": "Svuota la tela", "clearReset": "Svuota la tela",
@@ -135,6 +136,7 @@
"decryptFailed": "Impossibile decriptare i dati.", "decryptFailed": "Impossibile decriptare i dati.",
"uploadedSecurly": "L'upload è stato protetto con la crittografia end-to-end, il che significa che il server Excalidraw e terze parti non possono leggere il contenuto.", "uploadedSecurly": "L'upload è stato protetto con la crittografia end-to-end, il che significa che il server Excalidraw e terze parti non possono leggere il contenuto.",
"loadSceneOverridePrompt": "Se carichi questo disegno esterno, sostituirà quello che hai. Vuoi continuare?", "loadSceneOverridePrompt": "Se carichi questo disegno esterno, sostituirà quello che hai. Vuoi continuare?",
"collabStopOverridePrompt": "Interrompere la sessione sovrascriverà il precedente disegno memorizzato localmente. Sei sicuro?\n\n(Se vuoi mantenere il tuo disegno locale, chiudi semplicemente la scheda del browser.)",
"errorLoadingLibrary": "Si è verificato un errore nel caricamento della libreria di terze parti.", "errorLoadingLibrary": "Si è verificato un errore nel caricamento della libreria di terze parti.",
"confirmAddLibrary": "Questo aggiungerà {{numShapes}} forma(e) alla tua libreria. Sei sicuro?", "confirmAddLibrary": "Questo aggiungerà {{numShapes}} forma(e) alla tua libreria. Sei sicuro?",
"imageDoesNotContainScene": "L'importazione di immagini al momento non è supportata.\n\nVuoi importare una scena? Questa immagine non sembra contenere alcun dato di scena. Hai abilitato questa opzione durante l'esportazione?", "imageDoesNotContainScene": "L'importazione di immagini al momento non è supportata.\n\nVuoi importare una scena? Questa immagine non sembra contenere alcun dato di scena. Hai abilitato questa opzione durante l'esportazione?",
@@ -200,25 +202,25 @@
"title": "Errore" "title": "Errore"
}, },
"helpDialog": { "helpDialog": {
"blog": "", "blog": "Leggi il nostro blog",
"click": "", "click": "click",
"curvedArrow": "", "curvedArrow": "Freccia curva",
"curvedLine": "", "curvedLine": "Linea curva",
"documentation": "", "documentation": "Documentazione",
"drag": "", "drag": "trascina",
"editor": "", "editor": "Editor",
"github": "", "github": "Trovato un problema? Segnalalo",
"howto": "", "howto": "Segui le nostre guide",
"or": "", "or": "oppure",
"preventBinding": "", "preventBinding": "Impedisci legame della freccia",
"shapes": "", "shapes": "Forme",
"shortcuts": "", "shortcuts": "Scorciatoie da tastiera",
"textFinish": "", "textFinish": "Termina la modifica (testo)",
"textNewLine": "", "textNewLine": "Aggiungi nuova riga (testo)",
"title": "", "title": "Guida",
"view": "", "view": "Vista",
"zoomToFit": "", "zoomToFit": "Adatta zoom per mostrare tutti gli elementi",
"zoomToSelection": "" "zoomToSelection": "Zoom alla selezione"
}, },
"encrypted": { "encrypted": {
"tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere." "tooltip": "I tuoi disegni sono crittografati end-to-end in modo che i server di Excalidraw non li possano mai vedere."
@@ -233,10 +235,14 @@
"storage": "Memoria", "storage": "Memoria",
"title": "Statistiche per nerd", "title": "Statistiche per nerd",
"total": "Totale", "total": "Totale",
"version": "Versione",
"versionCopy": "Clicca per copiare",
"versionNotAvailable": "Versione non disponibile",
"width": "Larghezza" "width": "Larghezza"
}, },
"toast": { "toast": {
"copyStyles": "Stili copiati.", "copyStyles": "Stili copiati.",
"copyToClipboard": "Copiato negli appunti.",
"copyToClipboardAsPng": "Copiato negli appunti come PNG." "copyToClipboardAsPng": "Copiato negli appunti come PNG."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "", "gridMode": "",
"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": "キャンバスのリセット",
@@ -135,6 +136,7 @@
"decryptFailed": "データを復号できませんでした。", "decryptFailed": "データを復号できませんでした。",
"uploadedSecurly": "データのアップロードはエンドツーエンド暗号化によって保護されています。Excalidrawサーバーと第三者はデータの内容を見ることができません。", "uploadedSecurly": "データのアップロードはエンドツーエンド暗号化によって保護されています。Excalidrawサーバーと第三者はデータの内容を見ることができません。",
"loadSceneOverridePrompt": "外部図面を読み込むと、既存のコンテンツが置き換わります。続行しますか?", "loadSceneOverridePrompt": "外部図面を読み込むと、既存のコンテンツが置き換わります。続行しますか?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "サードパーティライブラリの読み込み中にエラーが発生しました。", "errorLoadingLibrary": "サードパーティライブラリの読み込み中にエラーが発生しました。",
"confirmAddLibrary": "{{numShapes}} 個の図形をライブラリに追加します。よろしいですか?", "confirmAddLibrary": "{{numShapes}} 個の図形をライブラリに追加します。よろしいですか?",
"imageDoesNotContainScene": "", "imageDoesNotContainScene": "",
@@ -233,10 +235,14 @@
"storage": "", "storage": "",
"title": "", "title": "",
"total": "合計", "total": "合計",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "幅" "width": "幅"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+248
View File
@@ -0,0 +1,248 @@
{
"labels": {
"paste": "Senṭeḍ",
"pasteCharts": "Senṭeḍ udlifen",
"selectAll": "Fren akk",
"multiSelect": "Rnu aferdis ɣer tefrayt",
"moveCanvas": "Smutti taɣzut n usuneɣ",
"cut": "Gzem",
"copy": "Nɣel",
"copyAsPng": "Nɣel ɣer tecfawit am PNG",
"copyAsSvg": "Nɣel ɣer tecfawit am SVG",
"bringForward": "Awi ɣer sdat",
"sendToBack": "Awi s agilal",
"bringToFront": "Err ɣer deffir",
"sendBackward": "Awi ɣer deffir",
"delete": "Kkes",
"copyStyles": "Nɣel iɣunab",
"pasteStyles": "Senṭeḍ iɣunab",
"stroke": "Azizdew",
"background": "Agilal",
"fill": "Taččart",
"strokeWidth": "Tehri n yizirig",
"strokeStyle": "Aɣanib n tizirig",
"strokeStyle_solid": "Aččuran",
"strokeStyle_dashed": "S tjerriḍin",
"strokeStyle_dotted": "S tenqiḍin",
"sloppiness": "",
"opacity": "Tiḍullest",
"textAlign": "Areyyec n uḍris",
"edges": "Leryuf",
"sharp": "Yemsed",
"round": "Imdewer",
"arrowheads": "Ixfawen n tenccabt",
"arrowhead_none": "Ulac",
"arrowhead_arrow": "Taneccabt",
"arrowhead_bar": "Afeggag",
"arrowhead_dot": "Tanqiḍt",
"fontSize": "Tiddi n tsefsit",
"fontFamily": "Tawacult n tsefsiyin",
"onlySelected": "Tafrayt kan",
"withBackground": "S ugilal",
"exportEmbedScene": "Seddu asayes deg ufaylu yettwasifḍen",
"exportEmbedScene_details": "Asayes ad yettwasekles deg ufaylu n usifeḍ PNG/SVG akken akken ad yili wamek ara d-yettwarr seg-s usayes. Ayagi ad isimɣur tiddi n ufaylu n usifeḍ.",
"addWatermark": "Seddu \"Yettwaxdem s Excalidraw\"",
"handDrawn": "Asuneɣ s ufus",
"normal": "Amagnu",
"code": "Tangalt",
"small": "Meẓẓi",
"medium": "Alemmas",
"large": "Ameqran",
"veryLarge": "Meqqer aṭas",
"solid": "Aččuran",
"hachure": "Azerreg",
"crossHatch": "Azerreg anmidag",
"thin": "Arqaq",
"bold": "Azuran",
"left": "Azelmaḍ",
"center": "Talemmast",
"right": "Ayfus",
"extraBold": "Azuran aṭas",
"architect": "Amasdag",
"artist": "Anaẓur",
"cartoonist": "",
"fileTitle": "Azwel n ufaylu",
"colorPicker": "Amafran n yini",
"canvasBackground": "Agilal n teɣzut n usuneɣ",
"drawingCanvas": "Taɣzut n usuneɣ",
"layers": "Tissiyin",
"actions": "Tigawin",
"language": "Tutlayt",
"createRoom": "Bḍu tiɣimit n umɛawen s srid",
"duplicateSelection": "Sisleg",
"untitled": "War azwel",
"name": "Isem",
"yourName": "Isem-ik (im)",
"madeWithExcalidraw": "Yettwaxdem s Excalidraw",
"group": "Segrew tafrayt",
"ungroup": "Kkess asegrew i tefrayt",
"collaborators": "Imɛiwnen",
"gridMode": "Askar n uferrug",
"addToLibrary": "Rnu ɣer temkarḍit",
"removeFromLibrary": "Kkes si temkarḍit",
"libraryLoadingMessage": "Asali n temkarḍit…",
"libraries": "Snirem timkarḍiyin",
"loadingScene": "Asali n usayes…",
"align": "Reyyec",
"alignTop": "Areyyec uksawen",
"alignBottom": "Areyyec ukessar",
"alignLeft": "Reyyec s azelmaḍ",
"alignRight": "Areyyec s ayfus",
"centerVertically": "Di tlemmast s ibeddi",
"centerHorizontally": "Di tlemmast s uglawi",
"distributeHorizontally": "Freq s uglawi",
"distributeVertically": "Freq s yibeddi",
"viewMode": "Askar n tmuɣli"
},
"buttons": {
"clearReset": "Ales awennez n teɣzut n usuneɣ",
"export": "Sifeḍ",
"exportToPng": "Sifeḍ ɣer PNG",
"exportToSvg": "Sifeḍ ɣer SVG",
"copyToClipboard": "Nɣel ɣer tecfawit",
"copyPngToClipboard": "Nɣel PNG ɣer tecfawit",
"scale": "Taskala",
"save": "Sekles",
"saveAs": "Sekles am",
"load": "Sali-d",
"getShareableLink": "Awi-d aseɣwen n beṭṭu",
"close": "Mdel",
"selectLanguage": "Fren tutlayt",
"scrollBackToContent": "Uɣal s agbur",
"zoomIn": "Simɣur",
"zoomOut": "Simẓi",
"resetZoom": "Ales awennez n usemɣer",
"menu": "Umuɣ",
"done": "Ifukk",
"edit": "Ẓreg",
"undo": "Sefsex",
"redo": "Err-d",
"roomDialog": "Bdu amɛawen s srid",
"createNewRoom": "Snulfu-d taxxamt tamaynutt",
"fullScreen": "Agdil aččuran",
"darkMode": "Askar imsulles",
"lightMode": "Askar afaw",
"zenMode": "Askar Zen",
"exitZenMode": "Ffeɣ seg uskar Zen"
},
"alerts": {
"clearReset": "Ayagi ad isfeḍ akk taɣzut n usuneɣ. Tetḥeqqeḍ?",
"couldNotCreateShareableLink": "D awezɣi asnulfu n useɣwen n beṭṭu.",
"couldNotCreateShareableLinkTooBig": "D awezɣi asnulfu n useɣwen n beṭṭu. Asayes ɣezzif aṭas",
"couldNotLoadInvalidFile": "D awezɣi asali n ufaylu armeɣtu",
"importBackendFailed": "",
"cannotExportEmptyCanvas": "D awezɣi asifeḍ n teɣzut n usuneɣ tilemt.",
"couldNotCopyToClipboard": "D awezɣi anɣal ɣer tecfawit. Eɛreḍ ad tesqedceḍ iminig Chrome.",
"decryptFailed": "D awezɣi tukksa n uwgelhen i yisefka.",
"uploadedSecurly": "Asili yettwasɣelles s uwgelhen ixef s ixef, ayagi yebɣa ad d-yini belli aqeddac n Excalidraw akked medden ur zmiren ara ad ɣren agbur.",
"loadSceneOverridePrompt": "Asali n wunuɣ uffiɣ ad isemselsi agbur-inek (m) yellan. Tebɣiḍ ad tkemmeleḍ?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Teḍra-d tuccḍa deg usali n temkarḍit n wis kraḍ.",
"confirmAddLibrary": "Ayagi adirnu talɣa (win) {{numShapes}} ɣer temkarḍit-inek (m). Tetḥeqqeḍ?",
"imageDoesNotContainScene": "Taktert n tugniwin ur tettwadhel ara akka tura.\nTebɣiḍ ad tketreḍ asayes? Tugna-agi tettban-d ur tegbir ara isefka n usnas. Tesremdeḍ ayagi deg usifeḍ?",
"cannotRestoreFromImage": "Asayes ulamek ara d-yettwarr seg ufaylu-agi n tugna"
},
"toolBar": {
"selection": "Tafrayt",
"draw": "Unuɣ ilelli",
"rectangle": "Asrem",
"diamond": "Ameɣṛun",
"ellipse": "Taglayt",
"arrow": "Taneccabt",
"line": "Izirig",
"text": "Aḍris",
"library": "Tamkarḍit",
"lock": "Eǧǧ afecku n tefrayt yermed mbaɛd asuneɣ"
},
"headings": {
"canvasActions": "Tigawin n teɣzut n usuneɣ",
"selectedShapeActions": "Tigawin n talɣa yettwafernen",
"shapes": "Talɣiwin"
},
"hints": {
"linearElement": "Ssit akken ad tebduḍ aṭas n tenqiḍin, zuɣer i yiwen n yizirig",
"freeDraw": "Ssit yerna zuɣer, serreḥ ticki tfukeḍ",
"text": "Tixidest: tzemreḍ daɣen ad ternuḍ aḍris s usiti snat n tikkal anida tebɣiḍ s ufecku n tefrayt",
"linearElementMulti": "Ssit ɣef tenqiḍt taneggarut neɣ ssed taqeffalt Escape neɣ taqeffalt Kcem akken ad tfakkeḍ",
"lockAngle": "Tzemreḍ ad tḥettmeḍ tiɣmert s tuṭṭfa n tqeffalt SHIFT",
"resize": "Tzemreḍ ad tḥettemeḍ assaɣ s tuṭṭfa n tqeffalt SHIFT mi ara tettbeddileḍ tiddi,\nma teṭṭfeḍ ALT abeddel n tiddi ad yili si tlemmast",
"rotate": "Tzemreḍ ad tḥettemeḍ tiɣemmar s tuṭṭfa n SHIFT di tuzzya",
"lineEditor_info": "Ssit snat n tikkal neɣ ssed taqeffalt Kcem akken ad tẓergeḍ tinqiḍin",
"lineEditor_pointSelected": "Ssed taqeffalt kkes akken ad tekkseḍ tanqiḍt, CtrlOrCmd+D akken ad tsiselgeḍ, neɣ zuɣer akken ad tesmuttiḍ",
"lineEditor_nothingSelected": "Fren tanqiḍt ara tesmuttiḍ neɣ ara tekkseḍ, neɣ ṭṭef taqeffalt Alt akken ad ternuḍ tinqiḍin timaynutin"
},
"canvasError": {
"cannotShowPreview": "Ulamek abeqqeḍ n teskant",
"canvasTooBig": "Taɣzut n usuneɣ tezmer ad tili temeqqer aṭas.",
"canvasTooBigTip": "Tixidest: eɛreḍ ad tesqerbeḍ ciṭ iferdisen yembaɛaden."
},
"errorSplash": {
"headingMain_pre": "Teḍra-d tuccḍa. Eɛreḍ ",
"headingMain_button": "asali n usebter tikkelt-nniḍen.",
"clearCanvasMessage": "Ma yella tulsa n usali ur tefri ara ugur, eɛreḍ ",
"clearCanvasMessage_button": "asfaḍ n teɣzut n usuneɣ.",
"clearCanvasCaveat": " Ayagi ad d-iglu s usṛuḥu n umahil ",
"trackedToSentry_pre": "Tuccḍa akked umesmagi ",
"trackedToSentry_post": " tettwasekles deg unagraw-nneɣ.",
"openIssueMessage_pre": "",
"openIssueMessage_button": "afecku n weḍfar n yibugen.",
"openIssueMessage_post": " Ma ulac uɣilif seddu talɣut ukessar-agi s wenɣal akked usenṭeḍ di GitHub issue.",
"sceneContent": "Agbur n usayes:"
},
"roomDialog": {
"desc_intro": "Tzemreḍ ad d-teɛerḍeḍ medden ɣer usayes-inek (m) amiran akken ad ttekkin yid-k.",
"desc_privacy": "Ur tqelliq ara, tiɣimit tsseqdac awgelhen ixef s ixef, dɣa ayen ara tsunɣeḍ ad iqqim d amaẓlay. Ula d aqeddac-nneɣ ur yezmir ara ad iwali acu txeddemeḍ.",
"button_startSession": "Bdu tiɣimit",
"button_stopSession": "Ḥbes tiɣimit",
"desc_inProgressIntro": "Tiɣimit n umɛawen s srid tetteddu akka tura.",
"desc_shareLink": "Bḍu aseɣwen-agi akked medden ukud tebɣiḍ ad temɛawaneḍ:",
"desc_exitSession": "Aḥbas n tɣimit ad k (m) yesenser si texxamt, maca ad tizmireḍ ad tkemmeleḍ amahil s usayes, s wudem adigan. Ẓer belli ayagi ur yettḥaz ara imdanen-nniḍen, yerna ad izmiren ad kemmelen ad mɛawanen di tsuffeɣt-nnsen."
},
"errorDialog": {
"title": "Tuccḍa"
},
"helpDialog": {
"blog": "Ɣeṛ ablug-nneɣ",
"click": "ssit",
"curvedArrow": "Taneccabt izelgen",
"curvedLine": "Izirig izelgen",
"documentation": "Tasemlit",
"drag": "zuɣer",
"editor": "Amaẓrag",
"github": "Tufiḍ-d ugur? Azen-aɣ-d",
"howto": "Ḍfer imniren-nneɣ",
"or": "neɣ",
"preventBinding": "",
"shapes": "Talɣiwin",
"shortcuts": "Inegzumen n unasiw",
"textFinish": "Fak asiẓreg (aḍris)",
"textNewLine": "Rnu ajerriḍ amaynut (aḍris)",
"title": "Tallelt",
"view": "Tamuɣli",
"zoomToFit": "Simɣur akken ad twliḍ akk iferdisen",
"zoomToSelection": "Simɣur ɣer tefrayt"
},
"encrypted": {
"tooltip": "Unuɣen-inek (m) ttuwgelhnen seg yixef s ixef dɣa iqeddacen n Excalidraw werǧin ad ten-walin. "
},
"stats": {
"angle": "Tiɣmeṛt",
"element": "Aferdis",
"elements": "Iferdisen",
"height": "Tattayt",
"scene": "Asayes",
"selected": "Yettwafren",
"storage": "Aḥraz",
"title": "",
"total": "Aɣrud",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Tehri"
},
"toast": {
"copyStyles": "Iɣunab yettwaneɣlen.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Yettwanɣel ɣer tecfawit am PNG."
}
}
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "격자 방식", "gridMode": "격자 방식",
"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": "캔버스 초기화",
@@ -135,6 +136,7 @@
"decryptFailed": "데이터를 복호화하지 못했습니다.", "decryptFailed": "데이터를 복호화하지 못했습니다.",
"uploadedSecurly": "업로드는 종단 간 암호화로 보호되므로 Excalidraw 서버 및 타사가 콘텐츠를 읽을 수 없습니다.", "uploadedSecurly": "업로드는 종단 간 암호화로 보호되므로 Excalidraw 서버 및 타사가 콘텐츠를 읽을 수 없습니다.",
"loadSceneOverridePrompt": "외부 파일을 불러 오면 기존 콘텐츠가 대체됩니다. 계속 진행할까요?", "loadSceneOverridePrompt": "외부 파일을 불러 오면 기존 콘텐츠가 대체됩니다. 계속 진행할까요?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "외부 라이브러리를 불러오는 중에 문제가 발생했습니다.", "errorLoadingLibrary": "외부 라이브러리를 불러오는 중에 문제가 발생했습니다.",
"confirmAddLibrary": "{{numShapes}}개의 모양이 라이브러리에 추가됩니다. 계속하시겠어요?", "confirmAddLibrary": "{{numShapes}}개의 모양이 라이브러리에 추가됩니다. 계속하시겠어요?",
"imageDoesNotContainScene": "이미지에서 불러오기는 현재 지원되지 않습니다.\n\n화면을 불러오려고 하셨나요? 이미지에 화면 정보가 없는 것 같습니다. 내보낼 때 화면을 포함했나요?", "imageDoesNotContainScene": "이미지에서 불러오기는 현재 지원되지 않습니다.\n\n화면을 불러오려고 하셨나요? 이미지에 화면 정보가 없는 것 같습니다. 내보낼 때 화면을 포함했나요?",
@@ -233,10 +235,14 @@
"storage": "저장공간", "storage": "저장공간",
"title": "덕후들을 위한 통계", "title": "덕후들을 위한 통계",
"total": "합계", "total": "합계",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "너비" "width": "너비"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "", "gridMode": "",
"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": "ကားချပ်ရှင်းလင်း",
@@ -135,6 +136,7 @@
"decryptFailed": "အချက်အလက်ဖော်ယူ၍မရပါ။", "decryptFailed": "အချက်အလက်ဖော်ယူ၍မရပါ။",
"uploadedSecurly": "တင်သွင်းအချက်အလက်များအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်စနစ်အသုံးပြု၍လုံခြုံစွာထိန်းသိမ်းထားပါသဖြင့် Excalidraw ဆာဗာနှင့်ဆက်စပ်အဖွဲ့အစည်းများပင်လျှင်မဖတ်ရှုနိုင်ပါ။", "uploadedSecurly": "တင်သွင်းအချက်အလက်များအား နှစ်ဘက်စွန်းတိုင်လျှို့ဝှက်စနစ်အသုံးပြု၍လုံခြုံစွာထိန်းသိမ်းထားပါသဖြင့် Excalidraw ဆာဗာနှင့်ဆက်စပ်အဖွဲ့အစည်းများပင်လျှင်မဖတ်ရှုနိုင်ပါ။",
"loadSceneOverridePrompt": "လက်ရှိရေးဆွဲထားသမျှအား ပြင်ပမှတင်သွင်းသောပုံနှင့်အစားထိုးပါမည်။ ဆက်လက်ဆောင်ရွက်လိုပါသလား။", "loadSceneOverridePrompt": "လက်ရှိရေးဆွဲထားသမျှအား ပြင်ပမှတင်သွင်းသောပုံနှင့်အစားထိုးပါမည်။ ဆက်လက်ဆောင်ရွက်လိုပါသလား။",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "ပြင်ပမှမှတ်တမ်းအားတင်သွင်းရာတွင်အမှားအယွင်းရှိနေသည်။", "errorLoadingLibrary": "ပြင်ပမှမှတ်တမ်းအားတင်သွင်းရာတွင်အမှားအယွင်းရှိနေသည်။",
"confirmAddLibrary": "{{numShapes}} ခုသောပုံသဏ္ဌာန်အားမှတ်တမ်းတင်ပါမည်။ အတည်ပြုပါ။", "confirmAddLibrary": "{{numShapes}} ခုသောပုံသဏ္ဌာန်အားမှတ်တမ်းတင်ပါမည်။ အတည်ပြုပါ။",
"imageDoesNotContainScene": "", "imageDoesNotContainScene": "",
@@ -233,10 +235,14 @@
"storage": "သိုလှောင်ခန်း", "storage": "သိုလှောင်ခန်း",
"title": "အက္ခရာများအတွက်အချက်အလက်များ", "title": "အက္ခရာများအတွက်အချက်အလက်များ",
"total": "စုစုပေါင်း", "total": "စုစုပေါင်း",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "အကျယ်" "width": "အကျယ်"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Rutevisning", "gridMode": "Rutevisning",
"addToLibrary": "Legg til i bibliotek", "addToLibrary": "Legg til i bibliotek",
"removeFromLibrary": "Fjern fra bibliotek", "removeFromLibrary": "Fjern fra bibliotek",
"libraryLoadingMessage": "Laster bibliotek...", "libraryLoadingMessage": "Laster bibliotek",
"libraries": "Bla gjennom biblioteker", "libraries": "Bla gjennom biblioteker",
"loadingScene": "Laster inn scene...", "loadingScene": "Laster inn scene",
"align": "Juster", "align": "Juster",
"alignTop": "Juster øverst", "alignTop": "Juster øverst",
"alignBottom": "Juster nederst", "alignBottom": "Juster nederst",
@@ -91,7 +91,8 @@
"centerVertically": "Midtstill vertikalt", "centerVertically": "Midtstill vertikalt",
"centerHorizontally": "Midtstill horisontalt", "centerHorizontally": "Midtstill horisontalt",
"distributeHorizontally": "Distribuer horisontalt", "distributeHorizontally": "Distribuer horisontalt",
"distributeVertically": "Distribuer vertikalt" "distributeVertically": "Distribuer vertikalt",
"viewMode": "Visningsmodus"
}, },
"buttons": { "buttons": {
"clearReset": "Tøm lerretet og tilbakestill bakgrunnsfargen", "clearReset": "Tøm lerretet og tilbakestill bakgrunnsfargen",
@@ -135,6 +136,7 @@
"decryptFailed": "Kunne ikke dekryptere data.", "decryptFailed": "Kunne ikke dekryptere data.",
"uploadedSecurly": "Opplastingen er kryptert og kan ikke leses av Excalidraw-serveren eller tredjeparter.", "uploadedSecurly": "Opplastingen er kryptert og kan ikke leses av Excalidraw-serveren eller tredjeparter.",
"loadSceneOverridePrompt": "Å laste inn ekstern tegning vil erstatte det eksisterende innholdet. Ønsker du å fortsette?", "loadSceneOverridePrompt": "Å laste inn ekstern tegning vil erstatte det eksisterende innholdet. Ønsker du å fortsette?",
"collabStopOverridePrompt": "Hvis du slutter økten, overskrives din forrige, lokalt lagrede tegning. Er du sikker?\n\n(Hvis du ønsker å beholde din lokale tegning, bare lukk nettleserfanen i stedet.)",
"errorLoadingLibrary": "Det oppstod en feil under lasting av tredjepartsbiblioteket.", "errorLoadingLibrary": "Det oppstod en feil under lasting av tredjepartsbiblioteket.",
"confirmAddLibrary": "Dette vil legge til {{numShapes}} figur(er) i biblioteket ditt. Er du sikker?", "confirmAddLibrary": "Dette vil legge til {{numShapes}} figur(er) i biblioteket ditt. Er du sikker?",
"imageDoesNotContainScene": "Importering av bilder støttes ikke for øyeblikket.\n\nVil du importere en scene? Dette bildet ser ikke ut til å inneholde noen scene-data. Har du aktivert dette under eksporten?", "imageDoesNotContainScene": "Importering av bilder støttes ikke for øyeblikket.\n\nVil du importere en scene? Dette bildet ser ikke ut til å inneholde noen scene-data. Har du aktivert dette under eksporten?",
@@ -233,10 +235,14 @@
"storage": "Lagring", "storage": "Lagring",
"title": "Statistikk for nerder", "title": "Statistikk for nerder",
"total": "Totalt", "total": "Totalt",
"version": "Versjon",
"versionCopy": "Klikk for å kopiere",
"versionNotAvailable": "Versjon ikke tilgjengelig",
"width": "Bredde" "width": "Bredde"
}, },
"toast": { "toast": {
"copyStyles": "Kopierte stiler.", "copyStyles": "Kopierte stiler.",
"copyToClipboard": "Kopiert til utklippstavlen.",
"copyToClipboardAsPng": "Kopiert til utklippstavlen som PNG." "copyToClipboardAsPng": "Kopiert til utklippstavlen som PNG."
} }
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Rasterweergave", "gridMode": "Rasterweergave",
"addToLibrary": "Voeg toe aan bibliotheek", "addToLibrary": "Voeg toe aan bibliotheek",
"removeFromLibrary": "Verwijder uit bibliotheek", "removeFromLibrary": "Verwijder uit bibliotheek",
"libraryLoadingMessage": "Bibliotheek laden...", "libraryLoadingMessage": "Bibliotheek laden",
"libraries": "Blader door bibliotheken", "libraries": "Blader door bibliotheken",
"loadingScene": "Scène laden...", "loadingScene": "Scène laden",
"align": "Uitlijnen", "align": "Uitlijnen",
"alignTop": "Boven uitlijnen", "alignTop": "Boven uitlijnen",
"alignBottom": "Onder uitlijnen", "alignBottom": "Onder uitlijnen",
@@ -91,7 +91,8 @@
"centerVertically": "Verticaal Centreren", "centerVertically": "Verticaal Centreren",
"centerHorizontally": "Horizontaal Centreren", "centerHorizontally": "Horizontaal Centreren",
"distributeHorizontally": "Horizontaal verspreiden", "distributeHorizontally": "Horizontaal verspreiden",
"distributeVertically": "Verticaal distribueren" "distributeVertically": "Verticaal distribueren",
"viewMode": "Weergavemodus"
}, },
"buttons": { "buttons": {
"clearReset": "Canvas opnieuw instellen", "clearReset": "Canvas opnieuw instellen",
@@ -135,6 +136,7 @@
"decryptFailed": "Kan gegevens niet decoderen.", "decryptFailed": "Kan gegevens niet decoderen.",
"uploadedSecurly": "De upload is beveiligd met end-to-end encryptie, wat betekent dat de Excalidraw server en derden de inhoud niet kunnen lezen.", "uploadedSecurly": "De upload is beveiligd met end-to-end encryptie, wat betekent dat de Excalidraw server en derden de inhoud niet kunnen lezen.",
"loadSceneOverridePrompt": "Het laden van externe tekening zal uw bestaande inhoud vervangen. Wil je doorgaan?", "loadSceneOverridePrompt": "Het laden van externe tekening zal uw bestaande inhoud vervangen. Wil je doorgaan?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Bij het laden van de externe bibliotheek is een fout opgetreden.", "errorLoadingLibrary": "Bij het laden van de externe bibliotheek is een fout opgetreden.",
"confirmAddLibrary": "Hiermee worden {{numShapes}} vorm(n) aan uw bibliotheek toegevoegd. Ben je het zeker?", "confirmAddLibrary": "Hiermee worden {{numShapes}} vorm(n) aan uw bibliotheek toegevoegd. Ben je het zeker?",
"imageDoesNotContainScene": "Afbeeldingen importeren wordt op dit moment niet ondersteund.\n\nWil je een scène importeren? Deze afbeelding lijkt geen scène gegevens te bevatten. Heb je dit geactiveerd tijdens het exporteren?", "imageDoesNotContainScene": "Afbeeldingen importeren wordt op dit moment niet ondersteund.\n\nWil je een scène importeren? Deze afbeelding lijkt geen scène gegevens te bevatten. Heb je dit geactiveerd tijdens het exporteren?",
@@ -233,10 +235,14 @@
"storage": "Opslag", "storage": "Opslag",
"title": "Statistieken voor nerds", "title": "Statistieken voor nerds",
"total": "Totaal", "total": "Totaal",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Breedte" "width": "Breedte"
}, },
"toast": { "toast": {
"copyStyles": "Stijlen gekopieerd.", "copyStyles": "Stijlen gekopieerd.",
"copyToClipboard": "",
"copyToClipboardAsPng": "Gekopieerd naar klembord als PNG." "copyToClipboardAsPng": "Gekopieerd naar klembord als PNG."
} }
} }
+15 -9
View File
@@ -80,9 +80,9 @@
"gridMode": "Rutevisning", "gridMode": "Rutevisning",
"addToLibrary": "Legg til i bibliotek", "addToLibrary": "Legg til i bibliotek",
"removeFromLibrary": "Fjern frå bibliotek", "removeFromLibrary": "Fjern frå bibliotek",
"libraryLoadingMessage": "Laster bibliotek...", "libraryLoadingMessage": "Laster bibliotek",
"libraries": "Blad gjennom bibliotek", "libraries": "Blad gjennom bibliotek",
"loadingScene": "Laster scene...", "loadingScene": "Laster scene",
"align": "Juster", "align": "Juster",
"alignTop": "Juster til topp", "alignTop": "Juster til topp",
"alignBottom": "Juster til botn", "alignBottom": "Juster til botn",
@@ -91,7 +91,8 @@
"centerVertically": "Midtstill vertikalt", "centerVertically": "Midtstill vertikalt",
"centerHorizontally": "Midtstill horisontalt", "centerHorizontally": "Midtstill horisontalt",
"distributeHorizontally": "Sprei horisontalt", "distributeHorizontally": "Sprei horisontalt",
"distributeVertically": "Sprei vertikalt" "distributeVertically": "Sprei vertikalt",
"viewMode": ""
}, },
"buttons": { "buttons": {
"clearReset": "Tilbakestill lerretet", "clearReset": "Tilbakestill lerretet",
@@ -135,6 +136,7 @@
"decryptFailed": "Kunne ikkje dekryptere data.", "decryptFailed": "Kunne ikkje dekryptere data.",
"uploadedSecurly": "Opplastinga er kryptert og er ikkje mogleg å lese av Excalidraw-serveren eller tredjepartar.", "uploadedSecurly": "Opplastinga er kryptert og er ikkje mogleg å lese av Excalidraw-serveren eller tredjepartar.",
"loadSceneOverridePrompt": "Innlasting av ekstern teikning erstattar ditt eksisterande innhald. Ynskjer du å fortsette?", "loadSceneOverridePrompt": "Innlasting av ekstern teikning erstattar ditt eksisterande innhald. Ynskjer du å fortsette?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Det oppstod ein feil under lastinga av tredjepartsbibliotek.", "errorLoadingLibrary": "Det oppstod ein feil under lastinga av tredjepartsbibliotek.",
"confirmAddLibrary": "Dette vil legge til {{numShapes}} form(er) i biblioteket ditt. Er du sikker?", "confirmAddLibrary": "Dette vil legge til {{numShapes}} form(er) i biblioteket ditt. Er du sikker?",
"imageDoesNotContainScene": "Importering av bilder støttes ikkje for p. t.\n\nVil du importere ein scene? Dette bildet ser ikkje ut til å inneholde noen scene-data. Har du aktivert dette under eksporten?", "imageDoesNotContainScene": "Importering av bilder støttes ikkje for p. t.\n\nVil du importere ein scene? Dette bildet ser ikkje ut til å inneholde noen scene-data. Har du aktivert dette under eksporten?",
@@ -201,22 +203,22 @@
}, },
"helpDialog": { "helpDialog": {
"blog": "", "blog": "",
"click": "", "click": "klikk",
"curvedArrow": "", "curvedArrow": "",
"curvedLine": "", "curvedLine": "",
"documentation": "", "documentation": "",
"drag": "", "drag": "",
"editor": "", "editor": "Redigering",
"github": "", "github": "",
"howto": "", "howto": "",
"or": "", "or": "eller",
"preventBinding": "", "preventBinding": "",
"shapes": "", "shapes": "Formar",
"shortcuts": "", "shortcuts": "",
"textFinish": "", "textFinish": "",
"textNewLine": "", "textNewLine": "",
"title": "", "title": "Hjelp",
"view": "", "view": "Vising",
"zoomToFit": "", "zoomToFit": "",
"zoomToSelection": "" "zoomToSelection": ""
}, },
@@ -233,10 +235,14 @@
"storage": "Lagring", "storage": "Lagring",
"title": "Statistikk for nerdar", "title": "Statistikk for nerdar",
"total": "Totalt", "total": "Totalt",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Breidde" "width": "Breidde"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }
+28 -22
View File
@@ -80,9 +80,9 @@
"gridMode": "ਜਾਲੀਦਾਰ ਮੋਡ", "gridMode": "ਜਾਲੀਦਾਰ ਮੋਡ",
"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": "ਕੈਨਵਸ ਰੀਸੈੱਟ ਕਰੋ",
@@ -135,6 +136,7 @@
"decryptFailed": "ਡਾਟਾ ਡੀਕਰਿਪਟ ਨਹੀਂ ਕਰ ਸਕੇ।", "decryptFailed": "ਡਾਟਾ ਡੀਕਰਿਪਟ ਨਹੀਂ ਕਰ ਸਕੇ।",
"uploadedSecurly": "ਅੱਪਲੋਡ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਸ਼ਨ ਨਾਲ ਸੁਰੱਖਿਅਤ ਕੀਤੀ ਹੋਈ ਹੈ, ਜਿਸਦਾ ਮਤਲਬ ਇਹ ਹੈ ਕਿ Excalidraw ਸਰਵਰ ਅਤੇ ਤੀਜੀ ਧਿਰ ਦੇ ਬੰਦੇ ਸਮੱਗਰੀ ਨੂੰ ਪੜ੍ਹ ਨਹੀਂ ਸਕਦੇ।", "uploadedSecurly": "ਅੱਪਲੋਡ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕਰਿਪਸ਼ਨ ਨਾਲ ਸੁਰੱਖਿਅਤ ਕੀਤੀ ਹੋਈ ਹੈ, ਜਿਸਦਾ ਮਤਲਬ ਇਹ ਹੈ ਕਿ Excalidraw ਸਰਵਰ ਅਤੇ ਤੀਜੀ ਧਿਰ ਦੇ ਬੰਦੇ ਸਮੱਗਰੀ ਨੂੰ ਪੜ੍ਹ ਨਹੀਂ ਸਕਦੇ।",
"loadSceneOverridePrompt": "ਬਾਹਰੀ ਡਰਾਇੰਗ ਨੂੰ ਲੋਡ ਕਰਨਾ ਤੁਹਾਡੀ ਮੌਜੂਦਾ ਸਮੱਗਰੀ ਦੀ ਥਾਂ ਲੈ ਲਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਜਾਰੀ ਰੱਖਣਾ ਚਾਹੁੰਦੇ ਹੋ?", "loadSceneOverridePrompt": "ਬਾਹਰੀ ਡਰਾਇੰਗ ਨੂੰ ਲੋਡ ਕਰਨਾ ਤੁਹਾਡੀ ਮੌਜੂਦਾ ਸਮੱਗਰੀ ਦੀ ਥਾਂ ਲੈ ਲਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਜਾਰੀ ਰੱਖਣਾ ਚਾਹੁੰਦੇ ਹੋ?",
"collabStopOverridePrompt": "ਇਜਲਾਸ ਨੂੰ ਰੋਕਣਾ ਪਿਛਲੀ ਲੋਕਲ ਸਾਂਭੀ ਡਰਾਇੰਗ ਦੀ ਥਾਂ ਲੈ ਲਵੇਗਾ। ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?\n\n(ਜੇ ਤੁਸੀਂ ਆਪਣੀ ਲੋਕਲ ਡਰਾਇੰਗ ਨੂੰ ਬਰਕਰਾਰ ਰੱਖਣਾ ਚਾਹੁੰਦੇ ਹੋ ਤਾਂ ਇਹ ਕਰਨ ਦੀ ਬਜਾਏ ਬੱਸ ਆਪਣਾ ਟੈਬ ਬੰਦ ਕਰ ਦਿਉ।)",
"errorLoadingLibrary": "ਤੀਜੀ ਧਿਰ ਦੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਗਲਤੀ ਹੋਈ ਸੀ।", "errorLoadingLibrary": "ਤੀਜੀ ਧਿਰ ਦੀ ਲਾਇਬ੍ਰੇਰੀ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਗਲਤੀ ਹੋਈ ਸੀ।",
"confirmAddLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ {{numShapes}} ਆਕ੍ਰਿਤੀ(ਆਂ) ਨੂੰ ਜੋੜ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?", "confirmAddLibrary": "ਇਹ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ {{numShapes}} ਆਕ੍ਰਿਤੀ(ਆਂ) ਨੂੰ ਜੋੜ ਦੇਵੇਗਾ। ਕੀ ਤੁਸੀਂ ਪੱਕਾ ਇੰਝ ਕਰਨਾ ਚਾਹੁੰਦੇ ਹੋ?",
"imageDoesNotContainScene": "ਫਿਲਹਾਲ ਤਸਵੀਰਾਂ ਨੂੰ ਆਯਾਤ ਕਰਨ ਦਾ ਸਮਰਥਨ ਨਹੀਂ ਕਰਦਾ।\n\nਕੀ ਤੁਸੀਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਕਰਨਾ ਚਾਹੁੰਦੇ ਸੀ? ਇਸ ਤਸਵੀਰ ਵਿੱਚ ਦ੍ਰਿਸ਼ ਦਾ ਕੋਈ ਵੀ ਡਾਟਾ ਨਜ਼ਰ ਨਹੀਂ ਆ ਰਿਹਾ। ਕੀ ਨਿਰਯਾਤ ਦੌਰਾਨ ਤੁਸੀਂ ਇਹ ਸਮਰੱਥ ਕੀਤਾ ਸੀ?", "imageDoesNotContainScene": "ਫਿਲਹਾਲ ਤਸਵੀਰਾਂ ਨੂੰ ਆਯਾਤ ਕਰਨ ਦਾ ਸਮਰਥਨ ਨਹੀਂ ਕਰਦਾ।\n\nਕੀ ਤੁਸੀਂ ਦ੍ਰਿਸ਼ ਨੂੰ ਆਯਾਤ ਕਰਨਾ ਚਾਹੁੰਦੇ ਸੀ? ਇਸ ਤਸਵੀਰ ਵਿੱਚ ਦ੍ਰਿਸ਼ ਦਾ ਕੋਈ ਵੀ ਡਾਟਾ ਨਜ਼ਰ ਨਹੀਂ ਆ ਰਿਹਾ। ਕੀ ਨਿਰਯਾਤ ਦੌਰਾਨ ਤੁਸੀਂ ਇਹ ਸਮਰੱਥ ਕੀਤਾ ਸੀ?",
@@ -200,25 +202,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 +235,14 @@
"storage": "ਸਟੋਰੇਜ", "storage": "ਸਟੋਰੇਜ",
"title": "ਪੜਾਕੂਆਂ ਲਈ ਅੰਕੜੇ", "title": "ਪੜਾਕੂਆਂ ਲਈ ਅੰਕੜੇ",
"total": "ਕੁੱਲ", "total": "ਕੁੱਲ",
"version": "ਸੰਸਕਰਨ",
"versionCopy": "ਕਾਪੀ ਕਰਨ ਲਈ ਕਲਿੱਕ ਕਰੋ",
"versionNotAvailable": "ਸੰਸਕਰਨ ਉਪਲਬਧ ਨਹੀਂ ਹੈ",
"width": "ਚੌੜਾਈ" "width": "ਚੌੜਾਈ"
}, },
"toast": { "toast": {
"copyStyles": "ਕਾਪੀ ਕੀਤੇ ਸਟਾਇਲ।", "copyStyles": "ਕਾਪੀ ਕੀਤੇ ਸਟਾਇਲ।",
"copyToClipboard": "ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕੀਤਾ।",
"copyToClipboardAsPng": "ਕਲਿੱਪਬੋਰਡ 'ਤੇ PNG ਵਜੋਂ ਕਾਪੀ ਕੀਤਾ।" "copyToClipboardAsPng": "ਕਲਿੱਪਬੋਰਡ 'ਤੇ PNG ਵਜੋਂ ਕਾਪੀ ਕੀਤਾ।"
} }
} }
+27 -26
View File
@@ -1,35 +1,36 @@
{ {
"ar-SA": 90, "ar-SA": 87,
"bg-BG": 90, "bg-BG": 91,
"ca-ES": 90, "ca-ES": 87,
"de-DE": 100, "de-DE": 98,
"el-GR": 100, "el-GR": 99,
"en": 100, "en": 100,
"es-ES": 100, "es-ES": 98,
"fa-IR": 90, "fa-IR": 95,
"fi-FI": 100, "fi-FI": 98,
"fr-FR": 100, "fr-FR": 100,
"he-IL": 90, "he-IL": 87,
"hi-IN": 90, "hi-IN": 98,
"hu-HU": 90, "hu-HU": 87,
"id-ID": 91, "id-ID": 97,
"it-IT": 91, "it-IT": 100,
"ja-JP": 81, "ja-JP": 79,
"ko-KR": 90, "kab-KAB": 94,
"my-MM": 83, "ko-KR": 87,
"my-MM": 81,
"nb-NO": 100, "nb-NO": 100,
"nl-NL": 100, "nl-NL": 97,
"nn-NO": 90, "nn-NO": 90,
"pa-IN": 91, "pa-IN": 100,
"pl-PL": 90, "pl-PL": 88,
"pt-BR": 100, "pt-BR": 100,
"pt-PT": 100, "pt-PT": 97,
"ro-RO": 100, "ro-RO": 100,
"ru-RU": 91, "ru-RU": 97,
"sk-SK": 91, "sk-SK": 100,
"sv-SE": 100, "sv-SE": 98,
"tr-TR": 90, "tr-TR": 87,
"uk-UA": 100, "uk-UA": 97,
"zh-CN": 90, "zh-CN": 98,
"zh-TW": 100 "zh-TW": 100
} }
+9 -3
View File
@@ -80,9 +80,9 @@
"gridMode": "Tryb siatki", "gridMode": "Tryb siatki",
"addToLibrary": "Dodaj do biblioteki", "addToLibrary": "Dodaj do biblioteki",
"removeFromLibrary": "Usuń z biblioteki", "removeFromLibrary": "Usuń z biblioteki",
"libraryLoadingMessage": "Wczytywanie biblioteki...", "libraryLoadingMessage": "Ładowanie biblioteki",
"libraries": "Przeglądaj biblioteki", "libraries": "Przeglądaj biblioteki",
"loadingScene": "Ładowanie sceny...", "loadingScene": "Ładowanie sceny",
"align": "Wyrównaj", "align": "Wyrównaj",
"alignTop": "Wyrównaj do góry", "alignTop": "Wyrównaj do góry",
"alignBottom": "Wyrównaj do dołu", "alignBottom": "Wyrównaj do dołu",
@@ -91,7 +91,8 @@
"centerVertically": "Wyśrodkuj w pionie", "centerVertically": "Wyśrodkuj w pionie",
"centerHorizontally": "Wyśrodkuj w poziomie", "centerHorizontally": "Wyśrodkuj w poziomie",
"distributeHorizontally": "Rozłóż poziomo", "distributeHorizontally": "Rozłóż poziomo",
"distributeVertically": "Rozłóż pionowo" "distributeVertically": "Rozłóż pionowo",
"viewMode": "Tryb widoku"
}, },
"buttons": { "buttons": {
"clearReset": "Wyczyść dokument i zresetuj kolor dokumentu", "clearReset": "Wyczyść dokument i zresetuj kolor dokumentu",
@@ -135,6 +136,7 @@
"decryptFailed": "Nie udało się odszyfrować danych.", "decryptFailed": "Nie udało się odszyfrować danych.",
"uploadedSecurly": "By zapewnić Ci prywatność, udostępnianie projektu jest zabezpieczone szyfrowaniem end-to-end, co oznacza, że poza tobą i osobą z którą podzielisz się linkiem, nikt nie ma dostępu do tego co udostępniasz.", "uploadedSecurly": "By zapewnić Ci prywatność, udostępnianie projektu jest zabezpieczone szyfrowaniem end-to-end, co oznacza, że poza tobą i osobą z którą podzielisz się linkiem, nikt nie ma dostępu do tego co udostępniasz.",
"loadSceneOverridePrompt": "Wczytanie zewnętrznego rysunku zastąpi istniejącą zawartość. Czy chcesz kontynuować?", "loadSceneOverridePrompt": "Wczytanie zewnętrznego rysunku zastąpi istniejącą zawartość. Czy chcesz kontynuować?",
"collabStopOverridePrompt": "",
"errorLoadingLibrary": "Wystąpił błąd podczas ładowania zewnętrznej biblioteki.", "errorLoadingLibrary": "Wystąpił błąd podczas ładowania zewnętrznej biblioteki.",
"confirmAddLibrary": "To doda {{numShapes}} kształtów do twojej biblioteki. Jesteś pewien?", "confirmAddLibrary": "To doda {{numShapes}} kształtów do twojej biblioteki. Jesteś pewien?",
"imageDoesNotContainScene": "Importowanie zdjęć nie jest obecnie obsługiwane.\n\nCzy chciałeś zaimportować scenę? Ten obraz nie zawiera żadnych danych sceny. Czy włączyłeś to podczas eksportowania?", "imageDoesNotContainScene": "Importowanie zdjęć nie jest obecnie obsługiwane.\n\nCzy chciałeś zaimportować scenę? Ten obraz nie zawiera żadnych danych sceny. Czy włączyłeś to podczas eksportowania?",
@@ -233,10 +235,14 @@
"storage": "Pamięć", "storage": "Pamięć",
"title": "Statystyki dla nerdów", "title": "Statystyki dla nerdów",
"total": "Łącznie", "total": "Łącznie",
"version": "",
"versionCopy": "",
"versionNotAvailable": "",
"width": "Szerokość" "width": "Szerokość"
}, },
"toast": { "toast": {
"copyStyles": "", "copyStyles": "",
"copyToClipboard": "",
"copyToClipboardAsPng": "" "copyToClipboardAsPng": ""
} }
} }

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