diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index 3fcdde198..8ae0ea649 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -6,7 +6,7 @@ on: - master jobs: - build-docker: + build: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 92ab34f41..86936cbb2 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -13,10 +13,10 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Setup Node.js 12.x + - name: Setup Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.x - name: Install dependencies run: | diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml index 19496c5bf..1e2ed41b6 100644 --- a/.github/workflows/cancel.yml +++ b/.github/workflows/cancel.yml @@ -1,9 +1,11 @@ -name: Cancel -on: [push] +name: Cancel previous runs + +on: push + jobs: cancel: - name: "Cancel Previous Runs" runs-on: ubuntu-latest + timeout-minutes: 3 steps: - uses: styfle/cancel-workflow-action@0.6.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1abc8bfd7..a11350a4e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,10 +1,6 @@ name: Lint -on: - push: - branches: - - master - pull_request: +on: push jobs: lint: @@ -13,10 +9,10 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Setup Node.js 12.x + - name: Setup Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.x - name: Install and lint run: | @@ -24,5 +20,3 @@ jobs: npm run test:other npm run test:code npm run test:typecheck - env: - CI: true diff --git a/.github/workflows/locales-coverage.yml b/.github/workflows/locales-coverage.yml index d391639e1..c7b6fc799 100644 --- a/.github/workflows/locales-coverage.yml +++ b/.github/workflows/locales-coverage.yml @@ -14,18 +14,18 @@ jobs: with: token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} - - name: Setup Node.js 12.x + - name: Setup Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.x - name: Create report file run: | npm run locales-coverage FILE_CHANGED=$(git diff src/locales/percentages.json) if [ ! -z "${FILE_CHANGED}" ]; then - git config --global user.name 'Kostas Bariotis' - git config --global user.email 'konmpar@gmail.com' + git config --global user.name 'Excalidraw Bot' + git config --global user.email 'bot@excalidraw.com' git add src/locales/percentages.json git commit -am "Auto commit: Calculate translation coverage" git push @@ -43,5 +43,5 @@ jobs: uses: kt3k/update-pr-description@v1.0.1 with: pr_body: ${{ steps.getCommentBody.outputs.body }} - pr_title: "chore: New Crowdin updates" + pr_title: "chore: Update translations from Crowdin" github_token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index be84d2294..78a2674df 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -10,6 +10,7 @@ on: jobs: main: runs-on: ubuntu-latest + steps: - uses: amannn/action-semantic-pull-request@v3.0.0 env: diff --git a/.github/workflows/sentry-production.yml b/.github/workflows/sentry-production.yml index 8b9797289..7408949db 100644 --- a/.github/workflows/sentry-production.yml +++ b/.github/workflows/sentry-production.yml @@ -8,13 +8,14 @@ on: jobs: release: runs-on: ubuntu-latest + steps: - uses: actions/checkout@v1.0.0 - - name: Setup Node.js 12.x + - name: Setup Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.x - name: Install and build run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd99330d8..57434ceed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,6 @@ name: Tests -on: - push: - branches: - - master - pull_request: +on: push jobs: test: @@ -13,14 +9,12 @@ jobs: steps: - uses: actions/checkout@v1 - - name: Setup Node.js 12.x + - name: Setup Node.js 14.x uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 14.x - name: Install and test run: | npm ci npm run test:app - env: - CI: true diff --git a/README.md b/README.md index 148812e77..267db969c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@
- Excalidraw logo: Sketch handrawn like diagrams. + Excalidraw logo: Sketch handrawn like diagrams. -

Virtual whiteboard for sketching hand-drawn like diagrams.

+

Virtual whiteboard for sketching hand-drawn like diagrams.
Collaborative and end to end encrypted.

Follow Excalidraw on Twitter @@ -10,9 +10,6 @@ - - -

@@ -20,13 +17,51 @@ Go to [excalidraw.com](https://excalidraw.com) to start sketching. -Read our [blog](https://blog.excalidraw.com) and follow the [guides](https://howto.excalidraw.com) to learn more about Excalidraw and how to use it effectively. +Read the latest news and updates on our [blog](https://blog.excalidraw.com). A good start is to see all the updates of [One Year of Excalidraw](https://blog.excalidraw.com/one-year-of-excalidraw/). + +## Documentation + +### Shortcuts + +You can almost do anything with shortcuts. Click on the help icon on the bottom right corner to see them all. + +### Curved lines and arrows + +Choose line or arrow and click click click instead of drag. + +### Charts + +You can easily create charts by copy pasting data from Excel or just plain comma separated text. + +### Translating + +To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first. + +Translations will be available on the app if they exceed a certain threshold of completion (currently 85%). + +### Create a collaboration session manually + +In order to create a session manually you just need to generate a link of this form: + +``` +https://excalidraw.com/#room=[0-9a-f]{20},[a-zA-Z0-9_-]{22} +``` + +#### Example + +``` +https://excalidraw.com/#room=91bd46ae3aa84dff9d20,pfLqgEoY1c2ioq8LmGwsFA +``` + +The first set of digits is the room. This is visible from the server that’s going to dispatch messages to everyone that knows this number. + +The second set of digits is the encryption key. The Excalidraw server doesn’t know about it. This is what all the participants use to encrypt/decrypt the messages. ## Shape libraries Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com). -## Run the code +## Developement ### Code Sandbox @@ -63,7 +98,7 @@ You can use docker-compose to work on excalidraw locally if you don't want to se docker-compose up --build -d ``` -## Self hosting +### Self hosting We publish a Docker image with the Excalidraw client at [excalidraw/excalidraw](https://hub.docker.com/r/excalidraw/excalidraw). You can use it to self host your own client under your own domain, on Kubernetes, AWS ECS, etc. @@ -82,45 +117,11 @@ We are working towards providing a full-fledged solution for self hosting your o Pull requests are welcome. For major changes, please [open an issue](https://github.com/excalidraw/excalidraw/issues/new) first to discuss what you would like to change. -## Translating +## Notable used tools -To translate Excalidraw into other languages, please visit [our Crowdin page](https://crowdin.com/project/excalidraw). To add a new language, [open an issue](https://github.com/excalidraw/excalidraw/issues/new) so we can get things set up on our end first. - -Translations will be available on the app if they exceed a certain threshold of completion (currently 85%). - -## Excalidraw is built using these awesome tools - -- [React](https://reactjs.org) +- [Create React App](https://github.com/facebook/create-react-app) - [Rough.js](https://roughjs.com) - [TypeScript](https://www.typescriptlang.org) - [Vercel](https://vercel.com) And the main source of inspiration for starting the project is the awesome [Zwibbler](https://zwibbler.com/demo/) app. - -## Testimonials - - - - - - - -## Contributors - -### Code Contributors - -This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. - -### Financial Contributors - -Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/excalidraw/contribute)] - -#### Individuals - - - -#### Organizations - -Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/excalidraw/contribute)] - - diff --git a/package-lock.json b/package-lock.json index bee71f5ff..2c486d4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,9 +4,9 @@ "lockfileVersion": 1, "dependencies": { "@apidevtools/json-schema-ref-parser": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz", - "integrity": "sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.7.tgz", + "integrity": "sha512-QdwOGF1+eeyFh+17v2Tz626WX0nucd1iKOm6JUTUvCZdbolblCOOQCxGrQPY0f7jEhn36PiAWqZnsC2r5vmUWg==", "dev": true, "requires": { "@jsdevtools/ono": "^7.1.3", @@ -1308,9 +1308,9 @@ "integrity": "sha512-Jj2xW+8+8XPfWGkv9HPv/uR+Qrmq37NPYT352wf7MvE9LrstpLVmFg3LqG6MCRr5miLAom5sen2gZ+iOhVDeRA==" }, "@firebase/app": { - "version": "0.6.13", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.6.13.tgz", - "integrity": "sha512-xGrJETzvCb89VYbGSHFHCW7O/y067HRxT7MGehUE1xMxdPVBDNayHnxEuKwzfGvXAjVmajXBKFlKxaCWpgSjCQ==", + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.6.14.tgz", + "integrity": "sha512-ZQKuiJ+fzr4tULgWoXbW+AZVTGsejOkSrlQ+zx78WiGKIubpFJLklnP3S0oYr/1nHzr4vaKuM4G8IL1Wv/+MpQ==", "requires": { "@firebase/app-types": "0.6.1", "@firebase/component": "0.1.21", @@ -1334,9 +1334,9 @@ "integrity": "sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg==" }, "@firebase/auth": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.16.1.tgz", - "integrity": "sha512-7juD7D/kaxNti/xa5G+ZGJJs+bdJUWOW0MlNBtXwiG+TjMh69EDmwJnQmmc9h/32QVvXt1qo1OGWOoMMpF/2Gg==", + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.16.2.tgz", + "integrity": "sha512-68TlDL0yh3kF8PiCzI8m8RWd/bf/xCLUsdz1NZ2Dwea0sp6e2WAhu0sem1GfhwuEwL+Ns4jCdX7qbe/OQlkVEA==", "requires": { "@firebase/auth-types": "0.10.1" } @@ -1368,9 +1368,9 @@ } }, "@firebase/database": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-0.8.2.tgz", - "integrity": "sha512-E86yrom0Ii+61UScG44y1q3H3NuozzGGTGbYmiyTe1qK8Qvzuiu7yyfdDnqFW2fkeKvTRLoDeCpgZy27FgEndQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-0.9.1.tgz", + "integrity": "sha512-JdxgNvniSZiAx+lrdAQxkCZOTv+UfdmhRm9JA4RTs4XOpvwzmRtJTAIGBn+9CWXUAkWkjt5CYHLmYysD7NGj6g==", "requires": { "@firebase/auth-interop-types": "0.1.5", "@firebase/component": "0.1.21", @@ -1405,9 +1405,9 @@ } }, "@firebase/firestore": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-2.1.2.tgz", - "integrity": "sha512-8yUdBLLr6UhE+IjPR+fxLBD0bDnEqF9GalohfURZeLQPaL3b+LtqqGCLvvXC4MKT0lJAHOV8J9LA6rHj8vI0/Q==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-2.1.4.tgz", + "integrity": "sha512-chSOvJyVoS7HmH7YOyqQP66wMwmsYNo2nPbFkrmQM/fRGXntNxXD1Greu1uts2hNyNeDLNrFHW5y7PlE3LAbwQ==", "requires": { "@firebase/component": "0.1.21", "@firebase/firestore-types": "2.1.0", @@ -1649,17 +1649,17 @@ "dev": true }, "@google-cloud/pubsub": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@google-cloud/pubsub/-/pubsub-2.7.0.tgz", - "integrity": "sha512-wc/XOo5Ibo3GWmuaLu80EBIhXSdu2vf99HUqBbdsSSkmRNIka2HqoIhLlOFnnncQn0lZnGL7wtKGIDLoH9LiBg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@google-cloud/pubsub/-/pubsub-2.8.0.tgz", + "integrity": "sha512-AoSKAbpHCoLq6jO9vMX+K6hJhkayafan24Rs2RKHU8Y0qF6IGSm1+ly0OG12TgziHWg818/6dljWWKgwDcp8KA==", "dev": true, "requires": { "@google-cloud/paginator": "^3.0.0", "@google-cloud/precise-date": "^2.0.0", "@google-cloud/projectify": "^2.0.0", "@google-cloud/promisify": "^2.0.0", - "@opentelemetry/api": "^0.11.0", - "@opentelemetry/tracing": "^0.11.0", + "@opentelemetry/api": "^0.12.0", + "@opentelemetry/tracing": "^0.12.0", "@types/duplexify": "^3.6.0", "@types/long": "^4.0.0", "arrify": "^2.0.0", @@ -2460,28 +2460,28 @@ } }, "@opentelemetry/api": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-0.11.0.tgz", - "integrity": "sha512-K+1ADLMxduhsXoZ0GRfi9Pw162FvzBQLDQlHru1lg86rpIU+4XqdJkSGo6y3Kg+GmOWq1HNHOA/ydw/rzHQkRg==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-0.12.0.tgz", + "integrity": "sha512-Dn4vU5GlaBrIWzLpsM6xbJwKHdlpwBQ4Bd+cL9ofJP3hKT8jBXpBpribmyaqAzrajzzl2Yt8uTa9rFVLfjDAvw==", "dev": true, "requires": { - "@opentelemetry/context-base": "^0.11.0" + "@opentelemetry/context-base": "^0.12.0" } }, "@opentelemetry/context-base": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-base/-/context-base-0.11.0.tgz", - "integrity": "sha512-ESRk+572bftles7CVlugAj5Azrz61VO0MO0TS2pE9MLVL/zGmWuUBQryART6/nsrFqo+v9HPt37GPNcECTZR1w==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-base/-/context-base-0.12.0.tgz", + "integrity": "sha512-UXwSsXo3F3yZ1dIBOG9ID8v2r9e+bqLWoizCtTb8rXtwF+N5TM7hzzvQz72o3nBU+zrI/D5e+OqAYK8ZgDd3DA==", "dev": true }, "@opentelemetry/core": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-0.11.0.tgz", - "integrity": "sha512-ZEKjBXeDGBqzouz0uJmrbEKNExEsQOhsZ3tJDCLcz5dUNoVw642oIn2LYWdQK2YdIfZbEmltiF65/csGsaBtFA==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-0.12.0.tgz", + "integrity": "sha512-oLZIkmTNWTJXzo1eA4dGu/S7wOVtylsgnEsCmhSJGhrJVDXm1eW/aGuNs3DVBeuxp0ZvQLAul3/PThsC3YrnzA==", "dev": true, "requires": { - "@opentelemetry/api": "^0.11.0", - "@opentelemetry/context-base": "^0.11.0", + "@opentelemetry/api": "^0.12.0", + "@opentelemetry/context-base": "^0.12.0", "semver": "^7.1.3" }, "dependencies": { @@ -2506,32 +2506,32 @@ } }, "@opentelemetry/resources": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-0.11.0.tgz", - "integrity": "sha512-o7DwV1TcezqBtS5YW2AWBcn01nVpPptIbTr966PLlVBcS//w8LkjeOShiSZxQ0lmV4b2en0FiSouSDoXk/5qIQ==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-0.12.0.tgz", + "integrity": "sha512-8cYvIKB68cyupc7D6SWzkLtt13mbjgxMahL4JKCM6hWPyiGSJlPFEAey4XFXI5LLpPZRYTPHLVoLqI/xwCFZZA==", "dev": true, "requires": { - "@opentelemetry/api": "^0.11.0", - "@opentelemetry/core": "^0.11.0" + "@opentelemetry/api": "^0.12.0", + "@opentelemetry/core": "^0.12.0" } }, "@opentelemetry/semantic-conventions": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-0.11.0.tgz", - "integrity": "sha512-xsthnI/J+Cx0YVDGgUzvrH0ZTtfNtl866M454NarYwDrc0JvC24sYw+XS5PJyk2KDzAHtb0vlrumUc1OAut/Fw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-0.12.0.tgz", + "integrity": "sha512-BuCcDW0uLNYYTns0/LwXkJ8lp8aDm7kpS+WunEmPAPRSCe6ciOYRvzn5reqJfX93rf+6A3U2SgrBnCTH+0qoQQ==", "dev": true }, "@opentelemetry/tracing": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/tracing/-/tracing-0.11.0.tgz", - "integrity": "sha512-QweFmxzl32BcyzwdWCNjVXZT1WeENNS/RWETq/ohqu+fAsTcMyGcr6cOq/yDdFmtBy+bm5WVVdeByEjNS+c4/w==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/tracing/-/tracing-0.12.0.tgz", + "integrity": "sha512-2TUGhTGkhgnxTciHCNAILPSeyXageJewRqfP9wOrx65sKd/jgvNYoY8nYf4EVWVMirDOxKDsmYgUkjdQrwb2dg==", "dev": true, "requires": { - "@opentelemetry/api": "^0.11.0", - "@opentelemetry/context-base": "^0.11.0", - "@opentelemetry/core": "^0.11.0", - "@opentelemetry/resources": "^0.11.0", - "@opentelemetry/semantic-conventions": "^0.11.0" + "@opentelemetry/api": "^0.12.0", + "@opentelemetry/context-base": "^0.12.0", + "@opentelemetry/core": "^0.12.0", + "@opentelemetry/resources": "^0.12.0", + "@opentelemetry/semantic-conventions": "^0.12.0" } }, "@pmmmwh/react-refresh-webpack-plugin": { @@ -2663,70 +2663,86 @@ } }, "@sentry/browser": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.29.2.tgz", - "integrity": "sha512-uxZ7y7rp85tJll+RZtXRhXPbnFnOaxZqJEv05vJlXBtBNLQtlczV5iCtU9mZRLVHDtmZ5VVKUV8IKXntEqqDpQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.0.3.tgz", + "integrity": "sha512-Ukxh83Twql4UmUgds9wPWllE62NG71cYvm5AM6daTojvM8wFR2jh7G6GiA0WYfgMb2fw6SlbevB2xb6RDG5DzQ==", "requires": { - "@sentry/core": "5.29.2", - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", + "@sentry/core": "6.0.3", + "@sentry/types": "6.0.3", + "@sentry/utils": "6.0.3", "tslib": "^1.9.3" } }, "@sentry/core": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.29.2.tgz", - "integrity": "sha512-7WYkoxB5IdlNEbwOwqSU64erUKH4laavPsM0/yQ+jojM76ErxlgEF0u//p5WaLPRzh3iDSt6BH+9TL45oNZeZw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.0.3.tgz", + "integrity": "sha512-UykB/4/98y2DkNvwTiL2ofFPuK3KDHc7rIRNsdj6dg6D+Cf7FRexgmWUUkZrpC/y+QBj0TPqkcFDcZAuQDa3Ag==", "requires": { - "@sentry/hub": "5.29.2", - "@sentry/minimal": "5.29.2", - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", + "@sentry/hub": "6.0.3", + "@sentry/minimal": "6.0.3", + "@sentry/types": "6.0.3", + "@sentry/utils": "6.0.3", "tslib": "^1.9.3" } }, "@sentry/hub": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.29.2.tgz", - "integrity": "sha512-LaAIo2hwUk9ykeh9RF0cwLy6IRw+DjEee8l1HfEaDFUM6TPGlNNGObMJNXb9/95jzWp7jWwOpQjoIE3jepdQJQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.0.3.tgz", + "integrity": "sha512-BfV32tE09rjTWM9W0kk8gzxUC2k1h57Z5dNWJ35na79+LguNNtCcI6fHlFQ3PkJca6ITYof9FI8iQHUfsHFZnw==", "requires": { - "@sentry/types": "5.29.2", - "@sentry/utils": "5.29.2", + "@sentry/types": "6.0.3", + "@sentry/utils": "6.0.3", "tslib": "^1.9.3" } }, "@sentry/integrations": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-5.29.2.tgz", - "integrity": "sha512-bH50B0xubbHrJFq8xZRxOc5BgXe1PXKfC0OqQkhhSd+Bu2WDLCHcn0CEzV+8thZTYkipAoFAFJNdEWcsM2Wcew==", + "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": "5.29.2", - "@sentry/utils": "5.29.2", - "localforage": "1.8.1", + "@sentry/types": "6.0.3", + "@sentry/utils": "6.0.3", + "localforage": "^1.8.1", "tslib": "^1.9.3" + }, + "dependencies": { + "@sentry/types": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.0.3.tgz", + "integrity": "sha512-266aBQbk9AGedhG2dzXshWbn23LYLElXqlI74DLku48UrU2v7TGKdyik/8/nfOfquCoRSp0GFGYHbItwU124XQ==" + }, + "@sentry/utils": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.0.3.tgz", + "integrity": "sha512-lvuBFvZHYs1zYwI8dkC8Z8ryb0aYnwPFUl1rbZiMwJpYI2Dgl1jpqqZWv9luux2rSRYOMid74uGedV708rvEgA==", + "requires": { + "@sentry/types": "6.0.3", + "tslib": "^1.9.3" + } + } } }, "@sentry/minimal": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.29.2.tgz", - "integrity": "sha512-0aINSm8fGA1KyM7PavOBe1GDZDxrvnKt+oFnU0L+bTcw8Lr+of+v6Kwd97rkLRNOLw621xP076dL/7LSIzMuhw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.0.3.tgz", + "integrity": "sha512-YsW+nw0SMyyb7UQdjZeKlZjxbGsJFpXNLh9iIp6fHKnoLTTv17YPm2ej9sOikDsQuVotaPg/xn/Qt5wySGHIxw==", "requires": { - "@sentry/hub": "5.29.2", - "@sentry/types": "5.29.2", + "@sentry/hub": "6.0.3", + "@sentry/types": "6.0.3", "tslib": "^1.9.3" } }, "@sentry/types": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.29.2.tgz", - "integrity": "sha512-dM9wgt8wy4WRty75QkqQgrw9FV9F+BOMfmc0iaX13Qos7i6Qs2Q0dxtJ83SoR4YGtW8URaHzlDtWlGs5egBiMA==" + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.0.3.tgz", + "integrity": "sha512-266aBQbk9AGedhG2dzXshWbn23LYLElXqlI74DLku48UrU2v7TGKdyik/8/nfOfquCoRSp0GFGYHbItwU124XQ==" }, "@sentry/utils": { - "version": "5.29.2", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.29.2.tgz", - "integrity": "sha512-nEwQIDjtFkeE4k6yIk4Ka5XjGRklNLThWLs2xfXlL7uwrYOH2B9UBBOOIRUraBm/g/Xrra3xsam/kRxuiwtXZQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.0.3.tgz", + "integrity": "sha512-lvuBFvZHYs1zYwI8dkC8Z8ryb0aYnwPFUl1rbZiMwJpYI2Dgl1jpqqZWv9luux2rSRYOMid74uGedV708rvEgA==", "requires": { - "@sentry/types": "5.29.2", + "@sentry/types": "6.0.3", "tslib": "^1.9.3" } }, @@ -2992,9 +3008,9 @@ } }, "@testing-library/jest-dom": { - "version": "5.11.8", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.8.tgz", - "integrity": "sha512-ScyKrWQM5xNcr79PkSewnA79CLaoxVskE+f7knTOhDD9ftZSA1Jw8mj+pneqhEu3x37ncNfW84NUr7lqK+mXjA==", + "version": "5.11.9", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.11.9.tgz", + "integrity": "sha512-Mn2gnA9d1wStlAIT2NU8J15LNob0YFBVjs2aEQ3j8rsfRQo+lAs7/ui1i2TGaJjapLmuNPLTsrm+nPjmZDwpcQ==", "requires": { "@babel/runtime": "^7.9.2", "@types/testing-library__jest-dom": "^5.9.1", @@ -3298,14 +3314,6 @@ "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==" }, - "@types/nanoid": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/nanoid/-/nanoid-2.1.0.tgz", - "integrity": "sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==", - "requires": { - "@types/node": "*" - } - }, "@types/node": { "version": "13.5.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.5.1.tgz", @@ -3368,9 +3376,9 @@ } }, "@types/socket.io-client": { - "version": "1.4.34", - "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.34.tgz", - "integrity": "sha512-Lzia5OTQFJZJ5R4HsEEldywiiqT9+W2rDbyHJiiTGqOcju89sCsQ8aUXDljY6Ls33wKZZGC0bfMhr/VpOyjtXg==" + "version": "1.4.35", + "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.35.tgz", + "integrity": "sha512-MI8YmxFS+jMkIziycT5ickBWK1sZwDwy16mgH/j99Mcom6zRG/NimNGQ3vJV0uX5G6g/hEw0FG3w3b3sT5OUGw==" }, "@types/source-list-map": { "version": "0.1.2", @@ -5108,10 +5116,10 @@ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=" }, - "browser-nativefs": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/browser-nativefs/-/browser-nativefs-0.12.0.tgz", - "integrity": "sha512-ZCHJcQI6bBm9YjB+6wMT1nWg+/mnWnz7r3gJ8sx7RjgLtWROFq+BuD12cAncD6y45MIbUqFM8eMKXoHXOxSFxA==" + "browser-fs-access": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.13.0.tgz", + "integrity": "sha512-qP8zFVhRQThxYgBXdlFHbzIrWb1us0G5kL2ZL0vW4BO5llKE4qBAcQsQrw4KN+6vjw8sKeWaGWJtzijfRT4N0Q==" }, "browser-process-hrtime": { "version": "1.0.0", @@ -7940,9 +7948,9 @@ } }, "eslint-config-prettier": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-7.1.0.tgz", - "integrity": "sha512-9sm5/PxaFG7qNJvJzTROMM1Bk1ozXVTKI0buKOyb0Bsr1hrwi0H/TzxF/COtf1uxikIK8SwhX7K6zg78jAzbeA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz", + "integrity": "sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==", "dev": true }, "eslint-config-react-app": { @@ -8505,9 +8513,9 @@ } }, "qs": { - "version": "6.9.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", - "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==", + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz", + "integrity": "sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ==", "dev": true }, "semver": { @@ -9020,16 +9028,16 @@ } }, "firebase": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-8.2.2.tgz", - "integrity": "sha512-a07aW2TTAA9S7p4mx5pu8hvtVokJEjAQlAocHKOWwmRJRIduE9Vvr/3i50FtujT5gGNr0Qm+EyWyB+/7TJiwnw==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-8.2.5.tgz", + "integrity": "sha512-x9KUJR8PvqLUNzNKWHjAnO7rJVgK546G0F+vjlJTNl+J/8oFTdWh8X4PvYda0z0XM68A2Y9xPGf3blz5qHCn0A==", "requires": { "@firebase/analytics": "0.6.2", - "@firebase/app": "0.6.13", + "@firebase/app": "0.6.14", "@firebase/app-types": "0.6.1", - "@firebase/auth": "0.16.1", - "@firebase/database": "0.8.2", - "@firebase/firestore": "2.1.2", + "@firebase/auth": "0.16.2", + "@firebase/database": "0.9.1", + "@firebase/firestore": "2.1.4", "@firebase/functions": "0.6.1", "@firebase/installations": "0.4.19", "@firebase/messaging": "0.7.3", @@ -9041,9 +9049,9 @@ } }, "firebase-tools": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/firebase-tools/-/firebase-tools-9.1.2.tgz", - "integrity": "sha512-YUiqMuQ+nbdCNpahSO0eyKxxVfT0nDdijkUEUplTGArkDwqdOKPIxVqHj1edq7GEPXTRWlk7zibnbOnCCHaedw==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/firebase-tools/-/firebase-tools-9.2.2.tgz", + "integrity": "sha512-AFjf7S9NjEM+u8ZByJEKASxRG1g+LLg/A0CrzA3V91P92MN+8cyrCigEs7mCdtFknLaShrCgzROyo/OEwd4xdA==", "dev": true, "requires": { "@google-cloud/pubsub": "^2.7.0", @@ -10146,9 +10154,9 @@ }, "dependencies": { "@types/node": { - "version": "13.13.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.39.tgz", - "integrity": "sha512-wct+WgRTTkBm2R3vbrFOqyZM5w0g+D8KnhstG9463CJBVC3UVZHMToge7iMBR1vDl/I+NWFHUeK9X+JcF0rWKw==", + "version": "13.13.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.40.tgz", + "integrity": "sha512-eKaRo87lu1yAXrzEJl0zcJxfUMDT5/mZalFyOkT44rnQps41eS2pfWzbaulSPpQLFNy29bFqn+Y5lOTL8ATlEQ==", "dev": true }, "duplexify": { @@ -10784,9 +10792,9 @@ "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==" }, "husky": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.7.tgz", - "integrity": "sha512-0fQlcCDq/xypoyYSJvEuzbDPHFf8ZF9IXKJxlrnvxABTSzK1VPT2RKYQKrcgJ+YD39swgoB6sbzywUqFxUiqjw==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/husky/-/husky-4.3.8.tgz", + "integrity": "sha512-LCqqsB0PzJQ/AlCgfrfzRe3e3+NvmefAdKQhRYpxS4u6clblBoDdzzvHi8fmxKRzvMxPY/1WZWzomPZww0Anow==", "dev": true, "requires": { "chalk": "^4.0.0", @@ -11515,9 +11523,9 @@ }, "dependencies": { "ip-regex": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.2.0.tgz", - "integrity": "sha512-n5cDDeTWWRwK1EBoWwRti+8nP4NbytBBY0pldmnIkq6Z55KNFmWofh4rl9dPZpj+U/nVq7gweR3ylrvMt4YZ5A==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", "dev": true } } @@ -14254,9 +14262,9 @@ } }, "localforage": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.8.1.tgz", - "integrity": "sha512-azSSJJfc7h4bVpi0PGi+SmLQKJl2/8NErI+LhJsrORNikMZnhaQ7rv9fHj+ofwgSHrKRlsDCL/639a6nECIKuQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.9.0.tgz", + "integrity": "sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==", "requires": { "lie": "3.1.1" } @@ -15263,9 +15271,9 @@ "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" }, "nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" + "version": "3.1.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", + "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==" }, "nanomatch": { "version": "1.2.13", @@ -17653,9 +17661,9 @@ } }, "proxy-agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-4.0.0.tgz", - "integrity": "sha512-8P0Y2SkwvKjiGU1IkEfYuTteioMIDFxPL4/j49zzt5Mz3pG1KO+mIrDG1qH0PQUHTTczjwGcYl+EzfXiFj5vUQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-4.0.1.tgz", + "integrity": "sha512-ODnQnW2jc/FUVwHHuaZEfN5otg/fMbvMxz9nMSUQfJ9JU7q2SZvSULSsjLloVgJOiv9yhc8GlNMKc4GkFmcVEA==", "dev": true, "requires": { "agent-base": "^6.0.0", diff --git a/package.json b/package.json index 4c5771e7d..3af13c169 100644 --- a/package.json +++ b/package.json @@ -19,21 +19,20 @@ ] }, "dependencies": { - "@sentry/browser": "5.29.2", - "@sentry/integrations": "5.29.2", - "@testing-library/jest-dom": "5.11.8", + "@sentry/browser": "6.0.3", + "@sentry/integrations": "6.0.3", + "@testing-library/jest-dom": "5.11.9", "@testing-library/react": "11.2.3", "@types/jest": "26.0.20", - "@types/nanoid": "2.1.0", "@types/react": "17.0.0", "@types/react-dom": "17.0.0", - "@types/socket.io-client": "1.4.34", - "browser-nativefs": "0.12.0", + "@types/socket.io-client": "1.4.35", + "browser-fs-access": "0.13.0", "clsx": "1.1.1", - "firebase": "8.2.2", + "firebase": "8.2.5", "i18next-browser-languagedetector": "6.0.1", "lodash.throttle": "4.1.1", - "nanoid": "2.1.11", + "nanoid": "3.1.20", "node-sass": "4.14.1", "open-color": "1.8.0", "pako": "1.0.11", @@ -52,10 +51,10 @@ "devDependencies": { "@types/lodash.throttle": "4.1.6", "@types/pako": "1.0.1", - "eslint-config-prettier": "7.1.0", + "eslint-config-prettier": "7.2.0", "eslint-plugin-prettier": "3.3.1", - "firebase-tools": "9.1.2", - "husky": "4.3.7", + "firebase-tools": "9.2.2", + "husky": "4.3.8", "jest-canvas-mock": "2.3.0", "lint-staged": "10.5.3", "pepjs": "0.5.3", @@ -73,7 +72,7 @@ }, "jest": { "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 }, @@ -82,7 +81,7 @@ "scripts": { "build-node": "node ./scripts/build-node.js", "build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build", - "build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build", + "build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build", "build:version": "node ./scripts/build-version.js", "build": "npm run build:app && npm run build:version", "eject": "react-scripts eject", diff --git a/public/og-image-sm.png b/public/og-image-sm.png new file mode 100644 index 000000000..5e88dba05 Binary files /dev/null and b/public/og-image-sm.png differ diff --git a/scripts/locales-coverage-description.js b/scripts/locales-coverage-description.js index df5794409..7eb5fe457 100644 --- a/scripts/locales-coverage-description.js +++ b/scripts/locales-coverage-description.js @@ -18,6 +18,7 @@ const crowdinMap = { "id-ID": "en-id", "it-IT": "en-it", "ja-JP": "en-ja", + "kab-KAB": "en-kab", "ko-KR": "en-ko", "my-MM": "en-my", "nb-NO": "en-nb", @@ -40,7 +41,7 @@ const crowdinMap = { const flags = { "ar-SA": "🇸🇦", "bg-BG": "🇧🇬", - "ca-ES": "🇪🇸", + "ca-ES": "🏳", "de-DE": "🇩🇪", "el-GR": "🇬🇷", "es-ES": "🇪🇸", @@ -53,6 +54,7 @@ const flags = { "id-ID": "🇮🇩", "it-IT": "🇮🇹", "ja-JP": "🇯🇵", + "kab-KAB": "🏳", "ko-KR": "🇰🇷", "my-MM": "🇲🇲", "nb-NO": "🇳🇴", @@ -88,6 +90,7 @@ const languages = { "id-ID": "Bahasa Indonesia", "it-IT": "Italiano", "ja-JP": "日本語", + "kab-KAB": "Taqbaylit", "ko-KR": "한국어", "my-MM": "Burmese", "nb-NO": "Norsk bokmål", diff --git a/src/actions/actionAddToLibrary.ts b/src/actions/actionAddToLibrary.ts index a0abbf5ca..8fb7eac93 100644 --- a/src/actions/actionAddToLibrary.ts +++ b/src/actions/actionAddToLibrary.ts @@ -17,6 +17,5 @@ export const actionAddToLibrary = register({ }); return false; }, - contextMenuOrder: 6, contextItemLabel: "labels.addToLibrary", }); diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 872874a5b..866f1ce29 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -3,7 +3,7 @@ import { getDefaultAppState } from "../appState"; import { ColorPicker } from "../components/ColorPicker"; import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { GRID_SIZE } from "../constants"; +import { GRID_SIZE, ZOOM_STEP } from "../constants"; import { getCommonBounds, getNonDeletedElements } from "../element"; import { newElementWith } from "../element/mutateElement"; import { ExcalidrawElement } from "../element/types"; @@ -76,8 +76,6 @@ export const actionClearCanvas = register({ ), }); -const ZOOM_STEP = 0.1; - export const actionZoomIn = register({ name: "zoomIn", perform: (_elements, appState) => { diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx new file mode 100644 index 000000000..70e4af17d --- /dev/null +++ b/src/actions/actionClipboard.tsx @@ -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, +}); diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 88e014503..dd2ad42cd 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -136,7 +136,6 @@ export const actionDeleteSelected = register({ }; }, contextItemLabel: "labels.delete", - contextMenuOrder: 999999, keyTest: (event) => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE, PanelComponent: ({ elements, appState, updateData }) => ( enableActionGroup(elements, appState), @@ -174,7 +173,6 @@ export const actionUngroup = register({ }, keyTest: (event) => event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G, - contextMenuOrder: 5, contextItemLabel: "labels.ungroup", contextItemPredicate: (elements, appState) => getSelectedGroupIds(appState).length > 0, diff --git a/src/actions/actionHistory.tsx b/src/actions/actionHistory.tsx index 9e3f62321..c0971c2ef 100644 --- a/src/actions/actionHistory.tsx +++ b/src/actions/actionHistory.tsx @@ -6,7 +6,7 @@ import { t } from "../i18n"; import { SceneHistory, HistoryEntry } from "../history"; import { ExcalidrawElement } from "../element/types"; import { AppState } from "../types"; -import { KEYS } from "../keys"; +import { isWindows, KEYS } from "../keys"; import { getElementMap } from "../element"; import { newElementWith } from "../element/mutateElement"; import { fixBindingsAfterDeletion } from "../element/binding"; @@ -59,16 +59,16 @@ const writeData = ( return { commitToHistory }; }; -const testUndo = (shift: boolean) => (event: KeyboardEvent) => - event[KEYS.CTRL_OR_CMD] && /z/i.test(event.key) && event.shiftKey === shift; - type ActionCreator = (history: SceneHistory) => Action; export const createUndoAction: ActionCreator = (history) => ({ name: "undo", perform: (elements, appState) => writeData(elements, appState, () => history.undoOnce()), - keyTest: testUndo(false), + keyTest: (event) => + event[KEYS.CTRL_OR_CMD] && + event.key.toLowerCase() === KEYS.Z && + !event.shiftKey, PanelComponent: ({ updateData }) => ( ({ name: "redo", perform: (elements, appState) => 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 }) => ( ( - + ), keyTest: (event) => event.key === KEYS.QUESTION_MARK, }); diff --git a/src/actions/actionStyles.ts b/src/actions/actionStyles.ts index 991f62b95..4a8b54afc 100644 --- a/src/actions/actionStyles.ts +++ b/src/actions/actionStyles.ts @@ -4,6 +4,7 @@ import { redrawTextBoundingBox, } from "../element"; import { CODES, KEYS } from "../keys"; +import { t } from "../i18n"; import { register } from "./register"; import { mutateElement, newElementWith } from "../element/mutateElement"; import { @@ -23,13 +24,16 @@ export const actionCopyStyles = register({ copiedStyles = JSON.stringify(element); } return { + appState: { + ...appState, + toastMessage: t("toast.copyStyles"), + }, commitToHistory: false, }; }, contextItemLabel: "labels.copyStyles", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C, - contextMenuOrder: 0, }); export const actionPasteStyles = register({ @@ -69,5 +73,4 @@ export const actionPasteStyles = register({ contextItemLabel: "labels.pasteStyles", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V, - contextMenuOrder: 1, }); diff --git a/src/actions/actionToggleGridMode.tsx b/src/actions/actionToggleGridMode.tsx new file mode 100644 index 000000000..152a9da9a --- /dev/null +++ b/src/actions/actionToggleGridMode.tsx @@ -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, +}); diff --git a/src/actions/actionToggleStats.tsx b/src/actions/actionToggleStats.tsx new file mode 100644 index 000000000..3c03b80c6 --- /dev/null +++ b/src/actions/actionToggleStats.tsx @@ -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", +}); diff --git a/src/actions/actionToggleViewMode.tsx b/src/actions/actionToggleViewMode.tsx new file mode 100644 index 000000000..0808a5d53 --- /dev/null +++ b/src/actions/actionToggleViewMode.tsx @@ -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, +}); diff --git a/src/actions/actionToggleZenMode.tsx b/src/actions/actionToggleZenMode.tsx new file mode 100644 index 000000000..5ff494ea2 --- /dev/null +++ b/src/actions/actionToggleZenMode.tsx @@ -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, +}); diff --git a/src/actions/index.ts b/src/actions/index.ts index c5c444482..b335e44e6 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -65,3 +65,15 @@ export { distributeHorizontally, distributeVertically, } from "./actionDistribute"; + +export { + actionCopy, + actionCut, + actionCopyAsPng, + actionCopyAsSvg, +} from "./actionClipboard"; + +export { actionToggleGridMode } from "./actionToggleGridMode"; +export { actionToggleZenMode } from "./actionToggleZenMode"; + +export { actionToggleStats } from "./actionToggleStats"; diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 236a14414..71eccf14f 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -3,14 +3,15 @@ import { Action, ActionsManagerInterface, UpdaterFn, - ActionFilterFn, ActionName, ActionResult, } from "./types"; import { ExcalidrawElement } from "../element/types"; -import { AppState } from "../types"; -import { t } from "../i18n"; -import { ShortcutName } from "./shortcuts"; +import { AppState, ExcalidrawProps } from "../types"; + +// This is the 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 { actions = {} as ActionsManagerInterface["actions"]; @@ -18,13 +19,14 @@ export class ActionManager implements ActionsManagerInterface { updater: (actionResult: ActionResult | Promise) => void; getAppState: () => Readonly; - getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; + app: App; constructor( updater: UpdaterFn, getAppState: () => AppState, getElementsIncludingDeleted: () => readonly ExcalidrawElement[], + app: App, ) { this.updater = (actionResult) => { if (actionResult && "then" in actionResult) { @@ -37,6 +39,7 @@ export class ActionManager implements ActionsManagerInterface { }; this.getAppState = getAppState; this.getElementsIncludingDeleted = getElementsIncludingDeleted; + this.app = app; } registerAction(action: Action) { @@ -63,6 +66,12 @@ export class ActionManager implements ActionsManagerInterface { if (data.length === 0) { return false; } + const { viewModeEnabled } = this.getAppState(); + if (viewModeEnabled) { + if (data[0].name !== "viewMode") { + return false; + } + } event.preventDefault(); this.updater( @@ -70,6 +79,7 @@ export class ActionManager implements ActionsManagerInterface { this.getElementsIncludingDeleted(), this.getAppState(), null, + this.app, ), ); return true; @@ -81,43 +91,11 @@ export class ActionManager implements ActionsManagerInterface { this.getElementsIncludingDeleted(), this.getAppState(), null, + this.app, ), ); } - getContextMenuItems(actionFilter: ActionFilterFn = (action) => action) { - return Object.values(this.actions) - .filter(actionFilter) - .filter((action) => "contextItemLabel" in action) - .filter((action) => - action.contextItemPredicate - ? action.contextItemPredicate( - this.getElementsIncludingDeleted(), - this.getAppState(), - ) - : true, - ) - .sort( - (a, b) => - (a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) - - (b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999), - ) - .map((action) => ({ - // take last bit of the label "labels." - shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName, - label: action.contextItemLabel ? t(action.contextItemLabel) : "", - action: () => { - this.updater( - action.perform( - this.getElementsIncludingDeleted(), - this.getAppState(), - null, - ), - ); - }, - })); - } - // Id is an attribute that we can use to pass in data like keys. // This is needed for dynamically generated action components // like the user list. We can use this key to extract more @@ -132,6 +110,7 @@ export class ActionManager implements ActionsManagerInterface { this.getElementsIncludingDeleted(), this.getAppState(), formState, + this.app, ), ); }; diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index e2fcf595a..4c9bc60c4 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -9,7 +9,7 @@ export type ShortcutName = | "copyStyles" | "pasteStyles" | "selectAll" - | "delete" + | "deleteSelectedElements" | "duplicateSelection" | "sendBackward" | "bringForward" @@ -22,7 +22,8 @@ export type ShortcutName = | "gridMode" | "zenMode" | "stats" - | "addToLibrary"; + | "addToLibrary" + | "viewMode"; const shortcutMap: Record = { cut: [getShortcutKey("CtrlOrCmd+X")], @@ -31,10 +32,10 @@ const shortcutMap: Record = { copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")], pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")], selectAll: [getShortcutKey("CtrlOrCmd+A")], - delete: [getShortcutKey("Del")], + deleteSelectedElements: [getShortcutKey("Del")], duplicateSelection: [ getShortcutKey("CtrlOrCmd+D"), - getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`), + getShortcutKey(`Alt+${t("helpDialog.drag")}`), ], sendBackward: [getShortcutKey("CtrlOrCmd+[")], bringForward: [getShortcutKey("CtrlOrCmd+]")], @@ -56,6 +57,7 @@ const shortcutMap: Record = { zenMode: [getShortcutKey("Alt+Z")], stats: [], addToLibrary: [], + viewMode: [getShortcutKey("Alt+R")], }; export const getShortcutFromShortcutName = (name: ShortcutName) => { diff --git a/src/actions/types.ts b/src/actions/types.ts index ca30f5678..9d90efd88 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -16,12 +16,18 @@ type ActionFn = ( elements: readonly ExcalidrawElement[], appState: Readonly, formData: any, + app: { canvas: HTMLCanvasElement | null }, ) => ActionResult | Promise; export type UpdaterFn = (res: ActionResult) => void; export type ActionFilterFn = (action: Action) => void; export type ActionName = + | "copy" + | "cut" + | "paste" + | "copyAsPng" + | "copyAsSvg" | "sendBackward" | "bringForward" | "sendToBack" @@ -29,6 +35,9 @@ export type ActionName = | "copyStyles" | "selectAll" | "pasteStyles" + | "gridMode" + | "zenMode" + | "stats" | "changeStrokeColor" | "changeBackgroundColor" | "changeFillStyle" @@ -75,7 +84,8 @@ export type ActionName = | "alignVerticallyCentered" | "alignHorizontallyCentered" | "distributeHorizontally" - | "distributeVertically"; + | "distributeVertically" + | "viewMode"; export interface Action { name: ActionName; @@ -93,19 +103,16 @@ export interface Action { elements: readonly ExcalidrawElement[], ) => boolean; contextItemLabel?: string; - contextMenuOrder?: number; contextItemPredicate?: ( elements: readonly ExcalidrawElement[], appState: AppState, ) => boolean; + checked?: (appState: Readonly) => boolean; } export interface ActionsManagerInterface { actions: Record; registerAction: (action: Action) => void; handleKeyDown: (event: KeyboardEvent) => boolean; - getContextMenuItems: ( - actionFilter: ActionFilterFn, - ) => { label: string; action: () => void }[]; renderAction: (name: ActionName) => React.ReactElement | null; } diff --git a/src/analytics.ts b/src/analytics.ts index 30a3887e3..a48a0a1f4 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,5 +1,6 @@ export const trackEvent = - process.env.REACT_APP_GOOGLE_ANALYTICS_ID && + typeof process !== "undefined" && + process.env?.REACT_APP_GOOGLE_ANALYTICS_ID && typeof window !== "undefined" && window.gtag ? (category: string, name: string, label?: string, value?: number) => { @@ -9,7 +10,7 @@ export const trackEvent = value, }); } - : typeof process !== "undefined" && process?.env?.JEST_WORKER_ID + : typeof process !== "undefined" && process.env?.JEST_WORKER_ID ? (category: string, name: string, label?: string, value?: number) => {} : (category: string, name: string, label?: string, value?: number) => { // Uncomment the next line to track locally diff --git a/src/appState.ts b/src/appState.ts index 676bb21e1..77662730f 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -6,7 +6,7 @@ import { GRID_SIZE, } from "./constants"; import { t } from "./i18n"; -import { AppState, FlooredNumber, NormalizedZoomValue } from "./types"; +import { AppState, NormalizedZoomValue } from "./types"; import { getDateTime } from "./utils"; export const getDefaultAppState = (): Omit< @@ -57,22 +57,24 @@ export const getDefaultAppState = (): Omit< previousSelectedElementIds: {}, resizingElement: null, scrolledOutside: false, - scrollX: 0 as FlooredNumber, - scrollY: 0 as FlooredNumber, + scrollX: 0, + scrollY: 0, selectedElementIds: {}, selectedGroupIds: {}, selectionElement: null, shouldAddWatermark: false, shouldCacheIgnoreZoom: false, showGrid: false, - showShortcutsDialog: false, + showHelpDialog: false, showStats: false, startBoundElement: null, suggestedBindings: [], + toastMessage: null, viewBackgroundColor: oc.white, width: window.innerWidth, zenModeEnabled: false, zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } }, + viewModeEnabled: false, }; }; @@ -144,14 +146,16 @@ const APP_STATE_STORAGE_CONF = (< selectionElement: { browser: false, export: false }, shouldAddWatermark: { browser: true, export: false }, shouldCacheIgnoreZoom: { browser: true, export: false }, - showShortcutsDialog: { browser: false, export: false }, + showHelpDialog: { browser: false, export: false }, showStats: { browser: true, export: false }, startBoundElement: { browser: false, export: false }, suggestedBindings: { browser: false, export: false }, + toastMessage: { browser: false, export: false }, viewBackgroundColor: { browser: true, export: true }, width: { browser: false, export: false }, zenModeEnabled: { browser: true, export: false }, zoom: { browser: true, export: false }, + viewModeEnabled: { browser: false, export: false }, }); const _clearAppStateForStorage = ( diff --git a/src/charts.ts b/src/charts.ts index 3b2bbb38b..f36a076e4 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -1,4 +1,3 @@ -import { trackEvent } from "./analytics"; import colors from "./colors"; import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, ENV } from "./constants"; import { newElement, newLinearElement, newTextElement } from "./element"; @@ -473,7 +472,6 @@ export const renderSpreadsheet = ( x: number, y: number, ): ChartElements => { - trackEvent("magic", "chart", chartType, spreadsheet.values.length); if (chartType === "line") { return chartTypeLine(spreadsheet, x, y); } diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 177fd61c2..2798caccd 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -163,9 +163,9 @@ export const ShapesSwitcher = ({ {SHAPES.map(({ value, icon, key }, index) => { const label = t(`toolBar.${value}`); const letter = typeof key === "string" ? key : key[0]; - const shortcut = `${capitalizeString(letter)} ${t( - "shortcutsDialog.or", - )} ${index + 1}`; + const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${ + index + 1 + }`; return ( ["setScrollToCenter"]; getSceneElements: InstanceType["getSceneElements"]; + getAppState: () => InstanceType["state"]; readyPromise: ResolvablePromise; ready: true; }; @@ -272,6 +298,7 @@ class App extends React.Component { offsetLeft, offsetTop, excalidrawRef, + viewModeEnabled = false, } = props; this.state = { ...defaultAppState, @@ -279,6 +306,7 @@ class App extends React.Component { width, height, ...this.getCanvasOffsets({ offsetLeft, offsetTop }), + viewModeEnabled, }; if (excalidrawRef) { const readyPromise = @@ -296,6 +324,7 @@ class App extends React.Component { }, setScrollToCenter: this.setScrollToCenter, getSceneElements: this.getSceneElements, + getAppState: () => this.state, } as const; if (typeof excalidrawRef === "function") { excalidrawRef(api); @@ -310,6 +339,7 @@ class App extends React.Component { this.syncActionResult, () => this.state, () => this.scene.getElementsIncludingDeleted(), + this, ); this.actionManager.registerAll(actions); @@ -317,6 +347,62 @@ class App extends React.Component { 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 ( + + {t("labels.drawingCanvas")} + + ); + } + return ( + + {t("labels.drawingCanvas")} + + ); + } + public render() { const { zenModeEnabled, @@ -324,20 +410,19 @@ class App extends React.Component { height: canvasDOMHeight, offsetTop, offsetLeft, + viewModeEnabled, } = this.state; const { onCollabButtonClick, onExportToBackend, renderFooter } = this.props; - const canvasScale = window.devicePixelRatio; - - const canvasWidth = canvasDOMWidth * canvasScale; - const canvasHeight = canvasDOMHeight * canvasScale; const DEFAULT_PASTE_X = canvasDOMWidth / 2; const DEFAULT_PASTE_Y = canvasDOMHeight / 2; return (
{ isCollaborating={this.props.isCollaborating || false} onExportToBackend={onExportToBackend} renderCustomFooter={renderFooter} + viewModeEnabled={viewModeEnabled} /> {this.state.showStats && ( { onClose={this.toggleStats} /> )} -
- - {t("labels.drawingCanvas")} - -
+ {this.state.toastMessage !== null && ( + + )} +
{this.renderCanvas()}
); } @@ -437,6 +508,13 @@ class App extends React.Component { if (actionResult.commitToHistory) { history.resumeRecording(); } + + let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; + + if (typeof this.props.viewModeEnabled !== "undefined") { + viewModeEnabled = this.props.viewModeEnabled; + } + this.setState( (state) => ({ ...actionResult.appState, @@ -446,6 +524,7 @@ class App extends React.Component { height: state.height, offsetTop: state.offsetTop, offsetLeft: state.offsetLeft, + viewModeEnabled, }), () => { if (actionResult.syncHistory) { @@ -628,7 +707,6 @@ class App extends React.Component { } this.scene.addCallback(this.onSceneUpdated); - this.addEventListeners(); // optim to avoid extra render on init @@ -695,25 +773,16 @@ class App extends React.Component { } private addEventListeners() { + this.removeEventListeners(); document.addEventListener(EVENT.COPY, this.onCopy); - document.addEventListener(EVENT.PASTE, this.pasteFromClipboard); - document.addEventListener(EVENT.CUT, this.onCut); - document.addEventListener(EVENT.KEYDOWN, this.onKeyDown, false); document.addEventListener(EVENT.KEYUP, this.onKeyUp, { passive: true }); document.addEventListener( EVENT.MOUSE_MOVE, this.updateCurrentCursorPosition, ); - window.addEventListener(EVENT.RESIZE, this.onResize, false); - window.addEventListener(EVENT.UNLOAD, this.onUnload, false); - window.addEventListener(EVENT.BLUR, this.onBlur, false); - window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false); - window.addEventListener(EVENT.DROP, this.disableEvent, false); - // rerender text elements on font load to fix #637 && #1553 document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded); - // Safari-only desktop pinch zoom document.addEventListener( EVENT.GESTURE_START, @@ -730,6 +799,18 @@ class App extends React.Component { this.onGestureEnd as any, 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) { @@ -752,6 +833,17 @@ class App extends React.Component { }); } + if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) { + this.setState( + { viewModeEnabled: !!this.props.viewModeEnabled }, + this.addEventListeners, + ); + } + + if (prevState.viewModeEnabled !== this.state.viewModeEnabled) { + this.addEventListeners(); + } + document .querySelector(".excalidraw") ?.classList.toggle("Appearance_dark", this.state.appearance === "dark"); @@ -899,43 +991,6 @@ class App extends React.Component { copyToClipboard(this.scene.getElements(), this.state); }; - private copyToClipboardAsPng = async () => { - const elements = this.scene.getElements(); - - const selectedElements = getSelectedElements(elements, this.state); - try { - await exportCanvas( - "clipboard", - selectedElements.length ? selectedElements : elements, - this.state, - this.canvas!, - this.state, - ); - } catch (error) { - console.error(error); - this.setState({ errorMessage: error.message }); - } - }; - - private copyToClipboardAsSvg = async () => { - const selectedElements = getSelectedElements( - this.scene.getElements(), - this.state, - ); - try { - await exportCanvas( - "clipboard-svg", - selectedElements.length ? selectedElements : this.scene.getElements(), - this.state, - this.canvas!, - this.state, - ); - } catch (error) { - console.error(error); - this.setState({ errorMessage: error.message }); - } - }; - private static resetTapTwice() { didTapTwice = false; } @@ -1143,9 +1198,7 @@ class App extends React.Component { }; toggleZenMode = () => { - this.setState({ - zenModeEnabled: !this.state.zenModeEnabled, - }); + this.actionManager.executeAction(actionToggleZenMode); }; toggleGridMode = () => { @@ -1158,9 +1211,7 @@ class App extends React.Component { if (!this.state.showStats) { trackEvent("dialog", "stats"); } - this.setState({ - showStats: !this.state.showStats, - }); + this.actionManager.executeAction(actionToggleStats); }; setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => { @@ -1173,6 +1224,10 @@ class App extends React.Component { }); }; + clearToast = () => { + this.setState({ toastMessage: null }); + }; + public updateScene = withBatchedUpdates((sceneData: SceneData) => { if (sceneData.commitToHistory) { history.resumeRecording(); @@ -1242,31 +1297,22 @@ class App extends React.Component { if (event.key === KEYS.QUESTION_MARK) { this.setState({ - showShortcutsDialog: true, + showHelpDialog: true, }); } - if (!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z) { - this.toggleZenMode(); - } - - if (event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE) { - this.toggleGridMode(); - } - if (event[KEYS.CTRL_OR_CMD]) { - this.setState({ isBindingEnabled: false }); - } - - if (event.code === CODES.C && event.altKey && event.shiftKey) { - this.copyToClipboardAsPng(); - event.preventDefault(); - return; - } - if (this.actionManager.handleKeyDown(event)) { return; } + if (this.state.viewModeEnabled) { + return; + } + + if (event[KEYS.CTRL_OR_CMD]) { + this.setState({ isBindingEnabled: false }); + } + if (event.code === CODES.NINE) { this.setState({ isLibraryOpen: !this.state.isLibraryOpen }); } @@ -1771,8 +1817,8 @@ class App extends React.Component { const scaleFactor = distance / gesture.initialDistance; this.setState(({ zoom, scrollX, scrollY, offsetLeft, offsetTop }) => ({ - scrollX: normalizeScroll(scrollX + deltaX / zoom.value), - scrollY: normalizeScroll(scrollY + deltaY / zoom.value), + scrollX: scrollX + deltaX / zoom.value, + scrollY: scrollY + deltaY / zoom.value, zoom: getNewZoom( getNormalizedZoom(initialScale * scaleFactor), zoom, @@ -2074,14 +2120,16 @@ class App extends React.Component { lastPointerUp = onPointerUp; - window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); - window.addEventListener(EVENT.POINTER_UP, onPointerUp); - window.addEventListener(EVENT.KEYDOWN, onKeyDown); - window.addEventListener(EVENT.KEYUP, onKeyUp); - pointerDownState.eventListeners.onMove = onPointerMove; - pointerDownState.eventListeners.onUp = onPointerUp; - pointerDownState.eventListeners.onKeyUp = onKeyUp; - pointerDownState.eventListeners.onKeyDown = onKeyDown; + if (!this.state.viewModeEnabled) { + window.addEventListener(EVENT.POINTER_MOVE, onPointerMove); + window.addEventListener(EVENT.POINTER_UP, onPointerUp); + window.addEventListener(EVENT.KEYDOWN, onKeyDown); + window.addEventListener(EVENT.KEYUP, onKeyUp); + pointerDownState.eventListeners.onMove = onPointerMove; + pointerDownState.eventListeners.onUp = onPointerUp; + pointerDownState.eventListeners.onKeyUp = onKeyUp; + pointerDownState.eventListeners.onKeyDown = onKeyDown; + } }; private maybeOpenContextMenuAfterPointerDownOnTouchDevices = ( @@ -2131,7 +2179,8 @@ class App extends React.Component { !( gesture.pointers.size === 0 && (event.button === POINTER_BUTTON.WHEEL || - (event.button === POINTER_BUTTON.MAIN && isHoldingSpace)) + (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || + this.state.viewModeEnabled) ) ) { return false; @@ -2184,12 +2233,8 @@ class App extends React.Component { } this.setState({ - scrollX: normalizeScroll( - this.state.scrollX - deltaX / this.state.zoom.value, - ), - scrollY: normalizeScroll( - this.state.scrollY - deltaY / this.state.zoom.value, - ), + scrollX: this.state.scrollX - deltaX / this.state.zoom.value, + scrollY: this.state.scrollY - deltaY / this.state.zoom.value, }); }); const teardown = withBatchedUpdates( @@ -3013,9 +3058,7 @@ class App extends React.Component { const x = event.clientX; const dx = x - pointerDownState.lastCoords.x; this.setState({ - scrollX: normalizeScroll( - this.state.scrollX - dx / this.state.zoom.value, - ), + scrollX: this.state.scrollX - dx / this.state.zoom.value, }); pointerDownState.lastCoords.x = x; return true; @@ -3025,9 +3068,7 @@ class App extends React.Component { const y = event.clientY; const dy = y - pointerDownState.lastCoords.y; this.setState({ - scrollY: normalizeScroll( - this.state.scrollY - dy / this.state.zoom.value, - ), + scrollY: this.state.scrollY - dy / this.state.zoom.value, }); pointerDownState.lastCoords.y = y; return true; @@ -3593,9 +3634,6 @@ class App extends React.Component { transformElements( pointerDownState, transformHandleType, - (newTransformHandle) => { - pointerDownState.resize.handleType = newTransformHandle; - }, selectedElements, pointerDownState.resize.arrowDirection, getRotateWithDiscreteAngleKey(event), @@ -3625,52 +3663,87 @@ class App extends React.Component { this.state, ); + const maybeGroupAction = actionGroup.contextItemPredicate!( + this.actionManager.getElementsIncludingDeleted(), + this.actionManager.getAppState(), + ); + + const maybeUngroupAction = actionUngroup.contextItemPredicate!( + this.actionManager.getElementsIncludingDeleted(), + this.actionManager.getAppState(), + ); + + const separator = "separator"; + + const _isMobile = isMobile(); + const elements = this.scene.getElements(); const element = this.getElementAtPosition(x, y); + const options: ContextMenuOption[] = []; + if (probablySupportsClipboardBlob && elements.length > 0) { + options.push(actionCopyAsPng); + } + + if (probablySupportsClipboardWriteText && elements.length > 0) { + options.push(actionCopyAsSvg); + } if (!element) { + const viewModeOptions: 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({ options: [ - navigator.clipboard && { - shortcutName: "paste", - label: t("labels.paste"), - action: () => this.pasteFromClipboard(null), - }, + _isMobile && + navigator.clipboard && { + name: "paste", + perform: (elements, appStates) => { + this.pasteFromClipboard(null); + return { + commitToHistory: false, + }; + }, + contextItemLabel: "labels.paste", + }, + _isMobile && navigator.clipboard && separator, probablySupportsClipboardBlob && - elements.length > 0 && { - shortcutName: "copyAsPng", - label: t("labels.copyAsPng"), - action: this.copyToClipboardAsPng, - }, + elements.length > 0 && + actionCopyAsPng, probablySupportsClipboardWriteText && - elements.length > 0 && { - shortcutName: "copyAsSvg", - label: t("labels.copyAsSvg"), - action: this.copyToClipboardAsSvg, - }, - ...this.actionManager.getContextMenuItems((action) => - CANVAS_ONLY_ACTIONS.includes(action.name), - ), - { - checked: this.state.showGrid, - shortcutName: "gridMode", - label: t("labels.gridMode"), - 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, - }, + elements.length > 0 && + actionCopyAsSvg, + ((probablySupportsClipboardBlob && elements.length > 0) || + (probablySupportsClipboardWriteText && elements.length > 0)) && + separator, + actionSelectAll, + separator, + actionToggleGridMode, + actionToggleZenMode, + typeof this.props.viewModeEnabled === "undefined" && + actionToggleViewMode, + actionToggleStats, ], top: clientY, left: clientX, + actionManager: this.actionManager, + appState: this.state, }); return; } @@ -3679,39 +3752,55 @@ class App extends React.Component { this.setState({ selectedElementIds: { [element.id]: true } }); } + if (this.state.viewModeEnabled) { + ContextMenu.push({ + options: [navigator.clipboard && actionCopy, ...options], + top: clientY, + left: clientX, + actionManager: this.actionManager, + appState: this.state, + }); + return; + } + ContextMenu.push({ options: [ - { - shortcutName: "cut", - label: t("labels.cut"), - action: this.cutAll, - }, - navigator.clipboard && { - shortcutName: "copy", - label: t("labels.copy"), - action: this.copyAll, - }, - navigator.clipboard && { - shortcutName: "paste", - label: t("labels.paste"), - action: () => this.pasteFromClipboard(null), - }, - probablySupportsClipboardBlob && { - shortcutName: "copyAsPng", - label: t("labels.copyAsPng"), - action: this.copyToClipboardAsPng, - }, - probablySupportsClipboardWriteText && { - shortcutName: "copyAsSvg", - label: t("labels.copyAsSvg"), - action: this.copyToClipboardAsSvg, - }, - ...this.actionManager.getContextMenuItems( - (action) => !CANVAS_ONLY_ACTIONS.includes(action.name), - ), + _isMobile && actionCut, + _isMobile && navigator.clipboard && actionCopy, + _isMobile && + navigator.clipboard && { + name: "paste", + perform: (elements, appStates) => { + this.pasteFromClipboard(null); + return { + commitToHistory: false, + }; + }, + contextItemLabel: "labels.paste", + }, + _isMobile && separator, + ...options, + separator, + actionCopyStyles, + actionPasteStyles, + separator, + maybeGroupAction && actionGroup, + maybeUngroupAction && actionUngroup, + (maybeGroupAction || maybeUngroupAction) && separator, + actionAddToLibrary, + separator, + actionSendBackward, + actionBringForward, + actionSendToBack, + actionBringToFront, + separator, + actionDuplicateSelection, + actionDeleteSelected, ], top: clientY, left: clientX, + actionManager: this.actionManager, + appState: this.state, }); }; @@ -3742,9 +3831,15 @@ class App extends React.Component { }, 1000); } + let newZoom = this.state.zoom.value - delta / 100; + // increase zoom steps the more zoomed-in we are (applies to >100% only) + newZoom += Math.log10(Math.max(1, this.state.zoom.value)) * -sign; + // round to nearest step + newZoom = Math.round(newZoom * ZOOM_STEP * 100) / (ZOOM_STEP * 100); + this.setState(({ zoom, offsetLeft, offsetTop }) => ({ zoom: getNewZoom( - getNormalizedZoom(zoom.value - delta / 100), + getNormalizedZoom(newZoom), zoom, { left: offsetLeft, top: offsetTop }, { @@ -3767,14 +3862,14 @@ class App extends React.Component { if (event.shiftKey) { this.setState(({ zoom, scrollX }) => ({ // on Mac, shift+wheel tends to result in deltaX - scrollX: normalizeScroll(scrollX - (deltaY || deltaX) / zoom.value), + scrollX: scrollX - (deltaY || deltaX) / zoom.value, })); return; } this.setState(({ zoom, scrollX, scrollY }) => ({ - scrollX: normalizeScroll(scrollX - deltaX / zoom.value), - scrollY: normalizeScroll(scrollY - deltaY / zoom.value), + scrollX: scrollX - deltaX / zoom.value, + scrollY: scrollY - deltaY / zoom.value, })); }); @@ -3834,7 +3929,9 @@ class App extends React.Component { }; private resetShouldCacheIgnoreZoomDebounced = debounce(() => { - this.setState({ shouldCacheIgnoreZoom: false }); + if (!this.unmounted) { + this.setState({ shouldCacheIgnoreZoom: false }); + } }, 300); private getCanvasOffsets(offsets?: { diff --git a/src/components/Avatar.scss b/src/components/Avatar.scss index 61f084b72..d077d916b 100644 --- a/src/components/Avatar.scss +++ b/src/components/Avatar.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .Avatar { diff --git a/src/components/CollabButton.scss b/src/components/CollabButton.scss index fd51cc055..5d9a86de3 100644 --- a/src/components/CollabButton.scss +++ b/src/components/CollabButton.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .CollabButton.is-collaborating { diff --git a/src/components/ColorPicker.scss b/src/components/ColorPicker.scss index 23a6aac82..cb29e66d1 100644 --- a/src/components/ColorPicker.scss +++ b/src/components/ColorPicker.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .color-picker { diff --git a/src/components/ContextMenu.scss b/src/components/ContextMenu.scss index fe8059e09..f4ec1142e 100644 --- a/src/components/ContextMenu.scss +++ b/src/components/ContextMenu.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .context-menu { @@ -9,9 +9,10 @@ list-style: none; user-select: none; margin: -0.25rem 0 0 0.125rem; - padding: 0.25rem 0; + padding: 0.5rem 0; background-color: var(--popup-secondary-background-color); border: 1px solid var(--button-gray-3); + cursor: default; } .context-menu button { @@ -54,6 +55,7 @@ .context-menu-option__shortcut { justify-self: end; opacity: 0.6; + font-family: inherit; font-size: 0.7rem; } } @@ -87,4 +89,9 @@ } } } + + .context-menu-option-separator { + border: none; + border-top: 1px solid $oc-gray-5; + } } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 27d14d880..2a2c9b96e 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -2,28 +2,36 @@ import React from "react"; import { render, unmountComponentAtNode } from "react-dom"; import clsx from "clsx"; import { Popover } from "./Popover"; +import { t } from "../i18n"; import "./ContextMenu.scss"; import { getShortcutFromShortcutName, ShortcutName, } from "../actions/shortcuts"; +import { Action } from "../actions/types"; +import { ActionManager } from "../actions/manager"; +import { AppState } from "../types"; -type ContextMenuOption = { - checked?: boolean; - shortcutName: ShortcutName; - label: string; - action(): void; -}; +export type ContextMenuOption = "separator" | Action; -type Props = { +type ContextMenuProps = { options: ContextMenuOption[]; onCloseRequest?(): void; top: number; left: number; + actionManager: ActionManager; + appState: Readonly; }; -const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => { +const ContextMenu = ({ + options, + onCloseRequest, + top, + left, + actionManager, + appState, +}: ContextMenuProps) => { const isDarkTheme = !!document .querySelector(".excalidraw") ?.classList.contains("Appearance_dark"); @@ -43,23 +51,34 @@ const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => { className="context-menu" onContextMenu={(event) => event.preventDefault()} > - {options.map(({ action, checked, shortcutName, label }, idx) => ( -
  • - -
  • - ))} + {options.map((option, idx) => { + if (option === "separator") { + return
    ; + } + + const actionName = option.name; + const label = option.contextItemLabel + ? t(option.contextItemLabel) + : ""; + return ( +
  • + +
  • + ); + })} @@ -78,8 +97,10 @@ const getContextMenuNode = (): HTMLDivElement => { type ContextMenuParams = { options: (ContextMenuOption | false | null | undefined)[]; - top: number; - left: number; + top: ContextMenuProps["top"]; + left: ContextMenuProps["left"]; + actionManager: ContextMenuProps["actionManager"]; + appState: Readonly; }; const handleClose = () => { @@ -101,6 +122,8 @@ export default { left={params.left} options={options} onCloseRequest={handleClose} + actionManager={params.actionManager} + appState={params.appState} />, getContextMenuNode(), ); diff --git a/src/components/Dialog.scss b/src/components/Dialog.scss index 3586c1cd2..37d19219b 100644 --- a/src/components/Dialog.scss +++ b/src/components/Dialog.scss @@ -1,6 +1,11 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { + .Dialog { + user-select: text; + cursor: auto; + } + .Dialog__title { display: grid; align-items: center; @@ -10,6 +15,7 @@ padding: calc(var(--space-factor) * 2); text-align: center; font-variant: small-caps; + font-size: 1.2em; } .Dialog__titleContent { diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index aa2ef7c01..507dc1772 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -1,5 +1,6 @@ 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 useIsMobile from "../is-mobile"; import { KEYS } from "../keys"; @@ -8,14 +9,6 @@ import { back, close } from "./icons"; import { Island } from "./Island"; import { Modal } from "./Modal"; -const useRefState = () => { - const [refValue, setRefValue] = useState(null); - const refCallback = useCallback((value: T) => { - setRefValue(value); - }, []); - return [refValue, refCallback] as const; -}; - export const Dialog = (props: { children: React.ReactNode; className?: string; @@ -24,7 +17,7 @@ export const Dialog = (props: { title: React.ReactNode; autofocus?: boolean; }) => { - const [islandNode, setIslandNode] = useRefState(); + const [islandNode, setIslandNode] = useCallbackRefState(); useEffect(() => { if (!islandNode) { @@ -80,7 +73,7 @@ export const Dialog = (props: { onCloseRequest={props.onCloseRequest} > -

    +

    {props.title} -

    +
    {props.children}
    diff --git a/src/components/ExportDialog.scss b/src/components/ExportDialog.scss index 3086d72b1..c47cdf400 100644 --- a/src/components/ExportDialog.scss +++ b/src/components/ExportDialog.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .ExportDialog__preview { diff --git a/src/components/ShortcutsDialog.scss b/src/components/HelpDialog.scss similarity index 55% rename from src/components/ShortcutsDialog.scss rename to src/components/HelpDialog.scss index 2feb38fbf..6b5701b84 100644 --- a/src/components/ShortcutsDialog.scss +++ b/src/components/HelpDialog.scss @@ -1,23 +1,28 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { - .ShortcutsDialog-island { + .HelpDialog h3 { + border-bottom: 1px solid var(--button-gray-2); + padding-bottom: 4px; + } + + .HelpDialog--island { border: 1px solid var(--button-gray-2); margin-bottom: 16px; } - .ShortcutsDialog-island-title { + .HelpDialog--island-title { margin: 0; padding: 4px; background-color: var(--button-gray-1); text-align: center; } - .ShorcutsDialog-shortcut { + .HelpDialog--shortcut { border-top: 1px solid var(--button-gray-2); } - .ShorcutsDialog-key { + .HelpDialog--key { word-break: keep-all; border: 1px solid var(--button-gray-2); padding: 2px 8px; @@ -29,14 +34,23 @@ box-sizing: border-box; display: flex; align-items: center; + font-family: inherit; } - .ShortcutsDialog-footer { + .HelpDialog--header { display: flex; flex-direction: row; justify-content: space-evenly; - border-top: 1px solid var(--button-gray-2); - margin-top: 8px; - padding-top: 16px; + margin-bottom: 32px; + padding-bottom: 16px; + } + + .HelpDialog--btn { + border: 1px solid var(--link-color); + padding: 8px 32px; + border-radius: 4px; + } + .HelpDialog--btn:hover { + text-decoration: none; } } diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx new file mode 100644 index 000000000..a66708977 --- /dev/null +++ b/src/components/HelpDialog.tsx @@ -0,0 +1,359 @@ +import React from "react"; +import { t } from "../i18n"; +import { isDarwin, isWindows } from "../keys"; +import { Dialog } from "./Dialog"; +import { getShortcutKey } from "../utils"; +import "./HelpDialog.scss"; + +const Header = () => ( + +); + +const Section = (props: { title: string; children: React.ReactNode }) => ( + <> +

    {props.title}

    + {props.children} + +); + +const Columns = (props: { children: React.ReactNode }) => ( +
    + {props.children} +
    +); + +const Column = (props: { children: React.ReactNode }) => ( +
    {props.children}
    +); + +const ShortcutIsland = (props: { + caption: string; + children: React.ReactNode; +}) => ( +
    +

    {props.caption}

    + {props.children} +
    +); + +const Shortcut = (props: { + label: string; + shortcuts: string[]; + isOr: boolean; +}) => { + return ( +
    +
    +
    + {props.label} +
    +
    + {props.shortcuts.map((shortcut, index) => ( + + {shortcut} + {props.isOr && + index !== props.shortcuts.length - 1 && + t("helpDialog.or")} + + ))} +
    +
    +
    + ); +}; + +Shortcut.defaultProps = { + isOr: true, +}; + +const ShortcutKey = (props: { children: React.ReactNode }) => ( + +); + +export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { + const handleClose = React.useCallback(() => { + if (onClose) { + onClose(); + } + }, [onClose]); + + return ( + <> + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + ); +}; diff --git a/src/components/HelpIcon.tsx b/src/components/HelpIcon.tsx index d114117be..8ec09d4e1 100644 --- a/src/components/HelpIcon.tsx +++ b/src/components/HelpIcon.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { questionCircle } from "../components/icons"; type HelpIconProps = { title?: string; @@ -7,19 +8,8 @@ type HelpIconProps = { onClick?(): void; }; -const ICON = ( - - - -); - export const HelpIcon = (props: HelpIconProps) => ( ); diff --git a/src/components/HintViewer.scss b/src/components/HintViewer.scss index 87b502b64..7f87354cb 100644 --- a/src/components/HintViewer.scss +++ b/src/components/HintViewer.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; // this is loosely based on the longest hint text $wide-viewport-width: 1000px; diff --git a/src/components/IconPicker.scss b/src/components/IconPicker.scss index ced5c5c23..284c36526 100644 --- a/src/components/IconPicker.scss +++ b/src/components/IconPicker.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .picker-container { diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index fbd5fdee0..ef245e8ca 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -36,7 +36,7 @@ import { LockIcon } from "./LockIcon"; import { MobileMenu } from "./MobileMenu"; import { PasteChartDialog } from "./PasteChartDialog"; import { Section } from "./Section"; -import { ShortcutsDialog } from "./ShortcutsDialog"; +import { HelpDialog } from "./HelpDialog"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; import { Tooltip } from "./Tooltip"; @@ -61,6 +61,7 @@ interface LayerUIProps { canvas: HTMLCanvasElement | null, ) => void; renderCustomFooter?: (isMobile: boolean) => JSX.Element; + viewModeEnabled: boolean; } const useOnClickOutside = ( @@ -299,6 +300,7 @@ const LayerUI = ({ isCollaborating, onExportToBackend, renderCustomFooter, + viewModeEnabled, }: LayerUIProps) => { const isMobile = useIsMobile(); @@ -358,6 +360,28 @@ const LayerUI = ({ ); }; + const renderViewModeCanvasActions = () => { + return ( +
    + {/* the zIndex ensures this menu has higher stacking order, + see https://github.com/excalidraw/excalidraw/pull/1445 */} + + + + {actionManager.renderAction("saveScene")} + {actionManager.renderAction("saveAsScene")} + {renderExportDialog()} + + + +
    + ); + }; const renderCanvasActions = () => (
    - {renderCanvasActions()} + {viewModeEnabled + ? renderViewModeCanvasActions() + : renderCanvasActions()} {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} -
    - {(heading) => ( - - - - - {heading} - - - - - - - {libraryMenu} - - )} -
    + {!viewModeEnabled && ( +
    + {(heading) => ( + + + + + {heading} + + + + + + + {libraryMenu} + + )} +
    + )} { + return ( + + ); + }; const renderFooter = () => (
    setAppState({ errorMessage: null })} /> )} - {appState.showShortcutsDialog && ( - setAppState({ showShortcutsDialog: false })} - /> + {appState.showHelpDialog && ( + setAppState({ showHelpDialog: false })} /> )} {appState.pasteDialog.shown && ( ) : ( -
    +
    {dialogs} {renderFixedSideContainer()} {renderBottomAppMenu()} - { - - } + {renderGitHubCorner()} {renderFooter()}
    ); diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 1500de55f..6c94dbd58 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -29,6 +29,7 @@ type MobileMenuProps = { canvas: HTMLCanvasElement | null; isCollaborating: boolean; renderCustomFooter?: (isMobile: boolean) => JSX.Element; + viewModeEnabled: boolean; }; export const MobileMenu = ({ @@ -43,121 +44,164 @@ export const MobileMenu = ({ canvas, isCollaborating, renderCustomFooter, -}: MobileMenuProps) => ( - <> - -
    - {(heading) => ( - - - - {heading} - - - - - - - {libraryMenu} - - )} -
    - -
    -
    - - {appState.openMenu === "canvas" ? ( -
    -
    - - {actionManager.renderAction("loadScene")} - {actionManager.renderAction("saveScene")} - {actionManager.renderAction("saveAsScene")} - {exportButton} - {actionManager.renderAction("clearCanvas")} - {onCollabButtonClick && ( - - )} - { + const renderFixedSideContainer = () => { + return ( + +
    + {(heading) => ( + + + + {heading} + + + + + - {renderCustomFooter?.(true)} -
    - {t("labels.collaborators")} - - {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]) => ( - - {actionManager.renderAction( - "goToCollaborator", - clientId, - )} - - ))} - -
    -
    -
    -
    - ) : appState.openMenu === "shape" && - showSelectedShapeActions(appState, elements) ? ( -
    - -
    - ) : null} -
    -
    - {actionManager.renderAction("toggleCanvasMenu")} - {actionManager.renderAction("toggleEditMenu")} - {actionManager.renderAction("undo")} - {actionManager.renderAction("redo")} - {actionManager.renderAction( - appState.multiElement ? "finalize" : "duplicateSelection", - )} - {actionManager.renderAction("deleteSelectedElements")} -
    - {appState.scrolledOutside && !appState.openMenu && ( - + + {libraryMenu} + )} -
    -
    -
    - -); +
    + + + ); + }; + + const renderAppToolbar = () => { + if (viewModeEnabled) { + return ( +
    + {actionManager.renderAction("toggleCanvasMenu")} +
    + ); + } + return ( +
    + {actionManager.renderAction("toggleCanvasMenu")} + {actionManager.renderAction("toggleEditMenu")} + {actionManager.renderAction("undo")} + {actionManager.renderAction("redo")} + {actionManager.renderAction( + appState.multiElement ? "finalize" : "duplicateSelection", + )} + {actionManager.renderAction("deleteSelectedElements")} +
    + ); + }; + + 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 && ( + + )} + { + + } + + ); + }; + return ( + <> + {!viewModeEnabled && renderFixedSideContainer()} +
    + + {appState.openMenu === "canvas" ? ( +
    +
    + + {renderCanvasActions()} + {renderCustomFooter?.(true)} +
    + {t("labels.collaborators")} + + {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]) => ( + + {actionManager.renderAction( + "goToCollaborator", + clientId, + )} + + ))} + +
    +
    +
    +
    + ) : appState.openMenu === "shape" && + !viewModeEnabled && + showSelectedShapeActions(appState, elements) ? ( +
    + +
    + ) : null} +
    + {renderAppToolbar()} + {appState.scrolledOutside && !appState.openMenu && ( + + )} +
    +
    +
    + + ); +}; diff --git a/src/components/Modal.scss b/src/components/Modal.scss index 2b34500da..2666f3514 100644 --- a/src/components/Modal.scss +++ b/src/components/Modal.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .Modal { diff --git a/src/components/PasteChartDialog.scss b/src/components/PasteChartDialog.scss index 9d45fb2df..dc76306a8 100644 --- a/src/components/PasteChartDialog.scss +++ b/src/components/PasteChartDialog.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .PasteChartDialog { diff --git a/src/components/PasteChartDialog.tsx b/src/components/PasteChartDialog.tsx index 22381d8a4..43607b3ca 100644 --- a/src/components/PasteChartDialog.tsx +++ b/src/components/PasteChartDialog.tsx @@ -1,5 +1,6 @@ import oc from "open-color"; import React, { useLayoutEffect, useRef, useState } from "react"; +import { trackEvent } from "../analytics"; import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts"; import { ChartType } from "../element/types"; import { t } from "../i18n"; @@ -86,6 +87,7 @@ export const PasteChartDialog = ({ const handleChartClick = (chartType: ChartType, elements: ChartElements) => { onInsertChart(elements); + trackEvent("magic", "chart", chartType); setAppState({ currentChartType: chartType, pasteDialog: { diff --git a/src/components/ShortcutsDialog.tsx b/src/components/ShortcutsDialog.tsx deleted file mode 100644 index ff9327d90..000000000 --- a/src/components/ShortcutsDialog.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import React from "react"; -import { t } from "../i18n"; -import { isDarwin } from "../keys"; -import { Dialog } from "./Dialog"; -import { getShortcutKey } from "../utils"; -import "./ShortcutsDialog.scss"; - -const Columns = (props: { children: React.ReactNode }) => ( -
    - {props.children} -
    -); - -const Column = (props: { children: React.ReactNode }) => ( -
    {props.children}
    -); - -const ShortcutIsland = (props: { - caption: string; - children: React.ReactNode; -}) => ( -
    -

    {props.caption}

    - {props.children} -
    -); - -const Shortcut = (props: { - label: string; - shortcuts: string[]; - isOr: boolean; -}) => { - return ( -
    -
    -
    - {props.label} -
    -
    - {props.shortcuts.map((shortcut, index) => ( - - {shortcut} - {props.isOr && - index !== props.shortcuts.length - 1 && - t("shortcutsDialog.or")} - - ))} -
    -
    -
    - ); -}; - -Shortcut.defaultProps = { - isOr: true, -}; - -const ShortcutKey = (props: { children: React.ReactNode }) => ( - -); - -const Footer = () => ( - -); - -export const ShortcutsDialog = ({ onClose }: { onClose?: () => void }) => { - const handleClose = React.useCallback(() => { - if (onClose) { - onClose(); - } - }, [onClose]); - - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - ); -}; diff --git a/src/components/Stats.scss b/src/components/Stats.scss index a6849f3bc..84864f933 100644 --- a/src/components/Stats.scss +++ b/src/components/Stats.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .Stats { position: fixed; diff --git a/src/components/TextInput.scss b/src/components/TextInput.scss index 3ff96c87c..930372ff0 100644 --- a/src/components/TextInput.scss +++ b/src/components/TextInput.scss @@ -1,4 +1,4 @@ -@import "../css/_variables.scss"; +@import "../css/variables.module"; .excalidraw { .TextInput { diff --git a/src/components/Toast.scss b/src/components/Toast.scss new file mode 100644 index 000000000..70cc80180 --- /dev/null +++ b/src/components/Toast.scss @@ -0,0 +1,32 @@ +@import "../css/variables.module"; + +.excalidraw { + .Toast { + animation: fade-in 0.5s; + background-color: var(--button-gray-1); + border-radius: 4px; + bottom: 10px; + box-sizing: border-box; + cursor: default; + left: 50%; + margin-left: -150px; + padding: 4px 0; + position: fixed; + text-align: center; + width: 300px; + z-index: 999999; + } + + .Toast__message { + color: var(--popup-text-color); + } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 000000000..d7ae91407 --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,34 @@ +import React, { useCallback, useEffect, useRef } from "react"; +import { TOAST_TIMEOUT } from "../constants"; +import "./Toast.scss"; + +export const Toast = ({ + message, + clearToast, +}: { + message: string; + clearToast: () => void; +}) => { + const timerRef = useRef(0); + + const scheduleTimeout = useCallback( + () => + (timerRef.current = window.setTimeout(() => clearToast(), TOAST_TIMEOUT)), + [clearToast], + ); + + useEffect(() => { + scheduleTimeout(); + return () => clearTimeout(timerRef.current); + }, [scheduleTimeout, message]); + + return ( +
    clearTimeout(timerRef?.current)} + onMouseLeave={scheduleTimeout} + > +

    {message}

    +
    + ); +}; diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss index 91e8e1bc5..df07ca025 100644 --- a/src/components/ToolIcon.scss +++ b/src/components/ToolIcon.scss @@ -1,5 +1,5 @@ @import "open-color/open-color.scss"; -@import "../css/variables"; +@import "../css/variables.module"; .excalidraw { .ToolIcon { diff --git a/src/components/Tooltip.scss b/src/components/Tooltip.scss index 9fe048f6a..75b79bf56 100644 --- a/src/components/Tooltip.scss +++ b/src/components/Tooltip.scss @@ -1,4 +1,4 @@ -@import "../css/_variables"; +@import "../css/variables.module"; .excalidraw { .Tooltip { position: relative; @@ -48,15 +48,7 @@ } } - // the following 3 rules ensure that the tooltip doesn't show (nor affect - // the cursor) when you drag over when you draw on canvas, but at the same - // time it still works when clicking on the link/shield - - body:active & .Tooltip:not(:hover) { - pointer-events: none; - } - - body:not(:active) & .Tooltip:hover .Tooltip__label { + .Tooltip:hover .Tooltip__label { visibility: visible; } diff --git a/src/constants.ts b/src/constants.ts index 4f22b35ec..b037eb235 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -89,3 +89,7 @@ export const STORAGE_KEYS = { export const TAP_TWICE_TIMEOUT = 300; export const TOUCH_CTX_MENU_TIMEOUT = 500; export const TITLE_TIMEOUT = 10000; +export const TOAST_TIMEOUT = 5000; +export const VERSION_TIMEOUT = 30000; + +export const ZOOM_STEP = 0.1; diff --git a/src/createInverseContext.tsx b/src/createInverseContext.tsx new file mode 100644 index 000000000..ac6cc223e --- /dev/null +++ b/src/createInverseContext.tsx @@ -0,0 +1,42 @@ +import React from "react"; + +export const createInverseContext = ( + initialValue: T, +) => { + const Context = React.createContext(initialValue) as React.Context & { + _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 ( + + {this.props.children} + + ); + } + } + + class InverseProvider extends React.Component<{ value: T }> { + componentDidMount() { + Context._updateProviderValue?.(this.props.value); + } + componentDidUpdate() { + Context._updateProviderValue?.(this.props.value); + } + render() { + return {() => this.props.children}; + } + } + + return { + Context, + Consumer: InverseConsumer, + Provider: InverseProvider, + }; +}; diff --git a/src/css/styles.scss b/src/css/styles.scss index c2fcd527b..573dba8f5 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -1,4 +1,4 @@ -@import "./_variables"; +@import "./variables.module"; @import "./theme"; .excalidraw { @@ -13,7 +13,7 @@ a { font-weight: 500; text-decoration: none; - color: $oc-blue-7; /* OC Blue 7 */ + color: var(--link-color); &:hover { text-decoration: underline; @@ -282,7 +282,7 @@ pointer-events: none !important; } - .App-menu_top > * { + .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * { pointer-events: all; } @@ -323,7 +323,7 @@ } } - .App-menu_bottom > * { + .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_bottom > * { pointer-events: all; } @@ -431,6 +431,7 @@ cursor: pointer; fill: $oc-gray-6; bottom: 14px; + width: 1.5rem; :root[dir="ltr"] & { right: 14px; @@ -491,6 +492,13 @@ pointer-events: none !important; } + &.excalidraw--view-mode { + .App-menu { + display: flex; + justify-content: space-between; + } + } + @media print { .App-bottom-bar, .FixedSideContainer, diff --git a/src/css/theme.scss b/src/css/theme.scss index 4de90d83d..ca88d8404 100644 --- a/src/css/theme.scss +++ b/src/css/theme.scss @@ -32,6 +32,7 @@ --popup-text-color: #{$oc-black}; --popup-text-inverted-color: #{$oc-white}; --dialog-border: #{$oc-gray-6}; + --link-color: #{$oc-blue-7}; } .excalidraw { diff --git a/src/css/_variables.scss b/src/css/variables.module.scss similarity index 73% rename from src/css/_variables.scss rename to src/css/variables.module.scss index 4e4ac861d..5b9ee7a8c 100644 --- a/src/css/_variables.scss +++ b/src/css/variables.module.scss @@ -2,3 +2,7 @@ // keep up to date with is-mobile.tsx $is-mobile-query: "(max-width: 600px), (max-height: 500px) and (max-width: 1000px)"; + +:export { + isMobileQuery: unquote($is-mobile-query); +} diff --git a/src/data/index.ts b/src/data/index.ts index 41de22a44..7dae58be7 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,4 +1,4 @@ -import { fileSave } from "browser-nativefs"; +import { fileSave } from "browser-fs-access"; import { copyCanvasToClipboardAsPng, copyTextToSystemClipboard, @@ -36,7 +36,7 @@ export const exportCanvas = async ( }, ) => { if (elements.length === 0) { - return window.alert(t("alerts.cannotExportEmptyCanvas")); + throw new Error(t("alerts.cannotExportEmptyCanvas")); } if (type === "svg" || type === "clipboard-svg") { const tempSvg = exportToSvg(elements, { diff --git a/src/data/json.ts b/src/data/json.ts index d4d3205a1..65688c32e 100644 --- a/src/data/json.ts +++ b/src/data/json.ts @@ -1,4 +1,4 @@ -import { fileOpen, fileSave } from "browser-nativefs"; +import { fileOpen, fileSave } from "browser-fs-access"; import { cleanAppStateForExport } from "../appState"; import { MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; diff --git a/src/element/index.ts b/src/element/index.ts index e49bc633e..63fdcfff4 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -34,7 +34,6 @@ export { export { resizeTest, getCursorForResizingElement, - normalizeTransformHandleType, getElementWithTransformHandleType, getTransformHandleTypeFromCoords, } from "./resizeTest"; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 199e3a482..96c216b06 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -4,7 +4,6 @@ import { rescalePoints } from "../points"; import { rotate, adjustXYWithRotation, - getFlipAdjustment, centerPoint, rotatePoint, } from "../math"; @@ -13,21 +12,16 @@ import { ExcalidrawTextElement, NonDeletedExcalidrawElement, NonDeleted, - ExcalidrawGenericElement, - ExcalidrawElement, } from "./types"; import { getElementAbsoluteCoords, getCommonBounds, getResizedElementAbsoluteCoords, } from "./bounds"; -import { isGenericElement, isLinearElement, isTextElement } from "./typeChecks"; +import { isLinearElement, isTextElement } from "./typeChecks"; import { mutateElement } from "./mutateElement"; import { getPerfectElementSize } from "./sizeHelpers"; -import { - getCursorForResizingElement, - normalizeTransformHandleType, -} from "./resizeTest"; +import { getCursorForResizingElement } from "./resizeTest"; import { measureText, getFontString } from "../utils"; import { updateBoundElements } from "./binding"; import { @@ -49,7 +43,6 @@ const normalizeAngle = (angle: number): number => { export const transformElements = ( pointerDownState: PointerDownState, transformHandleType: MaybeTransformHandleType, - setTransformHandle: (nextTransformHandle: MaybeTransformHandleType) => void, selectedElements: readonly NonDeletedExcalidrawElement[], resizeArrowDirection: "origin" | "end", isRotateWithDiscreteAngle: boolean, @@ -101,36 +94,15 @@ export const transformElements = ( ); updateBoundElements(element); } else if (transformHandleType) { - if (isGenericElement(element)) { - resizeSingleGenericElement( - pointerDownState.originalElements.get(element.id) as typeof element, - shouldKeepSidesRatio, - element, - transformHandleType, - isResizeCenterPoint, - pointerX, - pointerY, - ); - } else { - const keepSquareAspectRatio = shouldKeepSidesRatio; - resizeSingleNonGenericElement( - element, - transformHandleType, - isResizeCenterPoint, - keepSquareAspectRatio, - pointerX, - pointerY, - ); - setTransformHandle( - normalizeTransformHandleType(element, transformHandleType), - ); - if (element.width < 0) { - mutateElement(element, { width: -element.width }); - } - if (element.height < 0) { - mutateElement(element, { height: -element.height }); - } - } + resizeSingleElement( + pointerDownState.originalElements.get(element.id) as typeof element, + shouldKeepSidesRatio, + element, + transformHandleType, + isResizeCenterPoint, + pointerX, + pointerY, + ); } // update cursor @@ -414,8 +386,8 @@ const resizeSingleTextElement = ( } }; -const resizeSingleGenericElement = ( - stateAtResizeStart: NonDeleted, +const resizeSingleElement = ( + stateAtResizeStart: NonDeletedExcalidrawElement, shouldKeepSidesRatio: boolean, element: NonDeletedExcalidrawElement, transformHandleDirection: TransformHandleDirection, @@ -423,251 +395,184 @@ const resizeSingleGenericElement = ( pointerX: number, pointerY: number, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(stateAtResizeStart); + // Gets bounds corners + const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords( + stateAtResizeStart, + stateAtResizeStart.width, + stateAtResizeStart.height, + ); const startTopLeft: Point = [x1, y1]; const startBottomRight: Point = [x2, y2]; const startCenter: Point = centerPoint(startTopLeft, startBottomRight); // Calculate new dimensions based on cursor position - let newWidth = stateAtResizeStart.width; - let newHeight = stateAtResizeStart.height; const rotatedPointer = rotatePoint( [pointerX, pointerY], startCenter, -stateAtResizeStart.angle, ); + + //Get bounds corners rendered on screen + const [esx1, esy1, esx2, esy2] = getResizedElementAbsoluteCoords( + element, + element.width, + element.height, + ); + const boundsCurrentWidth = esx2 - esx1; + const boundsCurrentHeight = esy2 - esy1; + + // It's important we set the initial scale value based on the width and height at resize start, + // otherwise previous dimensions affected by modifiers will be taken into account. + const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0]; + const atStartBoundsHeight = startBottomRight[1] - startTopLeft[1]; + let scaleX = atStartBoundsWidth / boundsCurrentWidth; + let scaleY = atStartBoundsHeight / boundsCurrentHeight; + if (transformHandleDirection.includes("e")) { - newWidth = rotatedPointer[0] - startTopLeft[0]; + scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth; } if (transformHandleDirection.includes("s")) { - newHeight = rotatedPointer[1] - startTopLeft[1]; + scaleY = (rotatedPointer[1] - startTopLeft[1]) / boundsCurrentHeight; } if (transformHandleDirection.includes("w")) { - newWidth = startBottomRight[0] - rotatedPointer[0]; + scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth; } if (transformHandleDirection.includes("n")) { - newHeight = startBottomRight[1] - rotatedPointer[1]; + scaleY = (startBottomRight[1] - rotatedPointer[1]) / boundsCurrentHeight; } + // Linear elements dimensions differ from bounds dimensions + const eleInitialWidth = stateAtResizeStart.width; + const eleInitialHeight = stateAtResizeStart.height; + // We have to use dimensions of element on screen, otherwise the scaling of the + // dimensions won't match the cursor for linear elements. + let eleNewWidth = element.width * scaleX; + let eleNewHeight = element.height * scaleY; // adjust dimensions for resizing from center if (isResizeFromCenter) { - newWidth = 2 * newWidth - stateAtResizeStart.width; - newHeight = 2 * newHeight - stateAtResizeStart.height; + eleNewWidth = 2 * eleNewWidth - eleInitialWidth; + eleNewHeight = 2 * eleNewHeight - eleInitialHeight; } // adjust dimensions to keep sides ratio if (shouldKeepSidesRatio) { - const widthRatio = Math.abs(newWidth) / stateAtResizeStart.width; - const heightRatio = Math.abs(newHeight) / stateAtResizeStart.height; + const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth; + const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight; if (transformHandleDirection.length === 1) { - newHeight *= widthRatio; - newWidth *= heightRatio; + eleNewHeight *= widthRatio; + eleNewWidth *= heightRatio; } if (transformHandleDirection.length === 2) { const ratio = Math.max(widthRatio, heightRatio); - newWidth = stateAtResizeStart.width * ratio * Math.sign(newWidth); - newHeight = stateAtResizeStart.height * ratio * Math.sign(newHeight); + eleNewWidth = eleInitialWidth * ratio * Math.sign(eleNewWidth); + eleNewHeight = eleInitialHeight * ratio * Math.sign(eleNewHeight); } } + const [ + newBoundsX1, + newBoundsY1, + newBoundsX2, + newBoundsY2, + ] = getResizedElementAbsoluteCoords( + stateAtResizeStart, + eleNewWidth, + eleNewHeight, + ); + const newBoundsWidth = newBoundsX2 - newBoundsX1; + const newBoundsHeight = newBoundsY2 - newBoundsY1; + // Calculate new topLeft based on fixed corner during resize - let newTopLeft = startTopLeft as [number, number]; + let newTopLeft = [...startTopLeft] as [number, number]; if (["n", "w", "nw"].includes(transformHandleDirection)) { newTopLeft = [ - startBottomRight[0] - Math.abs(newWidth), - startBottomRight[1] - Math.abs(newHeight), + startBottomRight[0] - Math.abs(newBoundsWidth), + startBottomRight[1] - Math.abs(newBoundsHeight), ]; } if (transformHandleDirection === "ne") { - const bottomLeft = [ - stateAtResizeStart.x, - stateAtResizeStart.y + stateAtResizeStart.height, - ]; - newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newHeight)]; + const bottomLeft = [startTopLeft[0], startBottomRight[1]]; + newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)]; } if (transformHandleDirection === "sw") { - const topRight = [ - stateAtResizeStart.x + stateAtResizeStart.width, - stateAtResizeStart.y, - ]; - newTopLeft = [topRight[0] - Math.abs(newWidth), topRight[1]]; + const topRight = [startBottomRight[0], startTopLeft[1]]; + newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]]; } // Keeps opposite handle fixed during resize if (shouldKeepSidesRatio) { if (["s", "n"].includes(transformHandleDirection)) { - newTopLeft[0] = startCenter[0] - newWidth / 2; + newTopLeft[0] = startCenter[0] - newBoundsWidth / 2; } if (["e", "w"].includes(transformHandleDirection)) { - newTopLeft[1] = startCenter[1] - newHeight / 2; + newTopLeft[1] = startCenter[1] - newBoundsHeight / 2; } } // Flip horizontally - if (newWidth < 0) { + if (eleNewWidth < 0) { if (transformHandleDirection.includes("e")) { - newTopLeft[0] -= Math.abs(newWidth); + newTopLeft[0] -= Math.abs(newBoundsWidth); } if (transformHandleDirection.includes("w")) { - newTopLeft[0] += Math.abs(newWidth); + newTopLeft[0] += Math.abs(newBoundsWidth); } } // Flip vertically - if (newHeight < 0) { + if (eleNewHeight < 0) { if (transformHandleDirection.includes("s")) { - newTopLeft[1] -= Math.abs(newHeight); + newTopLeft[1] -= Math.abs(newBoundsHeight); } if (transformHandleDirection.includes("n")) { - newTopLeft[1] += Math.abs(newHeight); + newTopLeft[1] += Math.abs(newBoundsHeight); } } if (isResizeFromCenter) { - newTopLeft[0] = startCenter[0] - Math.abs(newWidth) / 2; - newTopLeft[1] = startCenter[1] - Math.abs(newHeight) / 2; + newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2; + newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2; } // adjust topLeft to new rotation point const angle = stateAtResizeStart.angle; const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle); const newCenter: Point = [ - newTopLeft[0] + Math.abs(newWidth) / 2, - newTopLeft[1] + Math.abs(newHeight) / 2, + newTopLeft[0] + Math.abs(newBoundsWidth) / 2, + newTopLeft[1] + Math.abs(newBoundsHeight) / 2, ]; const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle); newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle); + // Readjust points for linear elements + const rescaledPoints = rescalePointsInElement( + stateAtResizeStart, + eleNewWidth, + eleNewHeight, + ); + // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner + // So we need to readjust (x,y) to be where the first point should be + const newOrigin = [...newTopLeft]; + newOrigin[0] += stateAtResizeStart.x - newBoundsX1; + newOrigin[1] += stateAtResizeStart.y - newBoundsY1; + const resizedElement = { - width: Math.abs(newWidth), - height: Math.abs(newHeight), - x: newTopLeft[0], - y: newTopLeft[1], + width: Math.abs(eleNewWidth), + height: Math.abs(eleNewHeight), + x: newOrigin[0], + y: newOrigin[1], + ...rescaledPoints, }; - updateBoundElements(element, { - newSize: { width: resizedElement.width, height: resizedElement.height }, - }); - mutateElement(element, resizedElement); -}; - -const resizeSingleNonGenericElement = ( - element: NonDeleted>, - transformHandleType: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se", - isResizeFromCenter: boolean, - keepSquareAspectRatio: boolean, - pointerX: number, - pointerY: number, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - const cx = (x1 + x2) / 2; - const cy = (y1 + y2) / 2; - - // rotation pointer with reverse angle - const [rotatedX, rotatedY] = rotate( - pointerX, - pointerY, - cx, - cy, - -element.angle, - ); - - let scaleX = 1; - let scaleY = 1; - if ( - transformHandleType === "e" || - transformHandleType === "ne" || - transformHandleType === "se" - ) { - scaleX = (rotatedX - x1) / (x2 - x1); - } - if ( - transformHandleType === "s" || - transformHandleType === "sw" || - transformHandleType === "se" - ) { - scaleY = (rotatedY - y1) / (y2 - y1); - } - if ( - transformHandleType === "w" || - transformHandleType === "nw" || - transformHandleType === "sw" - ) { - scaleX = (x2 - rotatedX) / (x2 - x1); - } - if ( - transformHandleType === "n" || - transformHandleType === "nw" || - transformHandleType === "ne" - ) { - scaleY = (y2 - rotatedY) / (y2 - y1); - } - let nextWidth = element.width * scaleX; - let nextHeight = element.height * scaleY; - if (keepSquareAspectRatio) { - nextWidth = nextHeight = Math.max(nextWidth, nextHeight); - } - - const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( - element, - nextWidth, - nextHeight, - ); - const deltaX1 = (x1 - nextX1) / 2; - const deltaY1 = (y1 - nextY1) / 2; - const deltaX2 = (x2 - nextX2) / 2; - const deltaY2 = (y2 - nextY2) / 2; - - const rescaledPoints = rescalePointsInElement(element, nextWidth, nextHeight); - - updateBoundElements(element, { - newSize: { width: nextWidth, height: nextHeight }, - }); - const [finalX1, finalY1, finalX2, finalY2] = getResizedElementAbsoluteCoords( - { - ...element, - ...rescaledPoints, - }, - Math.abs(nextWidth), - Math.abs(nextHeight), - ); - const [flipDiffX, flipDiffY] = getFlipAdjustment( - transformHandleType, - nextWidth, - nextHeight, - nextX1, - nextY1, - nextX2, - nextY2, - finalX1, - finalY1, - finalX2, - finalY2, - isLinearElement(element), - element.angle, - ); - const [nextElementX, nextElementY] = adjustXYWithRotation( - getSidesForTransformHandle(transformHandleType, isResizeFromCenter), - element.x - flipDiffX, - element.y - flipDiffY, - element.angle, - deltaX1, - deltaY1, - deltaX2, - deltaY2, - ); if ( - nextWidth !== 0 && - nextHeight !== 0 && - Number.isFinite(nextElementX) && - Number.isFinite(nextElementY) + resizedElement.width !== 0 && + resizedElement.height !== 0 && + Number.isFinite(resizedElement.x) && + Number.isFinite(resizedElement.y) ) { - mutateElement(element, { - width: nextWidth, - height: nextHeight, - x: nextElementX, - y: nextElementY, - ...rescaledPoints, + updateBoundElements(element, { + newSize: { width: resizedElement.width, height: resizedElement.height }, }); + mutateElement(element, resizedElement); } }; diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 4471244dd..3a794e2c5 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -173,57 +173,3 @@ export const getCursorForResizingElement = (resizingElement: { return cursor ? `${cursor}-resize` : ""; }; - -export const normalizeTransformHandleType = ( - element: ExcalidrawElement, - transformHandleType: TransformHandleType, -): TransformHandleType => { - if (element.width >= 0 && element.height >= 0) { - return transformHandleType; - } - - if (element.width < 0 && element.height < 0) { - switch (transformHandleType) { - case "nw": - return "se"; - case "ne": - return "sw"; - case "se": - return "nw"; - case "sw": - return "ne"; - } - } else if (element.width < 0) { - switch (transformHandleType) { - case "nw": - return "ne"; - case "ne": - return "nw"; - case "se": - return "sw"; - case "sw": - return "se"; - case "e": - return "w"; - case "w": - return "e"; - } - } else { - switch (transformHandleType) { - case "nw": - return "sw"; - case "ne": - return "se"; - case "se": - return "ne"; - case "sw": - return "nw"; - case "n": - return "s"; - case "s": - return "n"; - } - } - - return transformHandleType; -}; diff --git a/src/element/showSelectedShapeActions.ts b/src/element/showSelectedShapeActions.ts index d0a7439c9..545289812 100644 --- a/src/element/showSelectedShapeActions.ts +++ b/src/element/showSelectedShapeActions.ts @@ -7,7 +7,8 @@ export const showSelectedShapeActions = ( elements: readonly NonDeletedExcalidrawElement[], ) => Boolean( - appState.editingElement || - getSelectedElements(elements, appState).length || - appState.elementType !== "selection", + !appState.viewModeEnabled && + (appState.editingElement || + getSelectedElements(elements, appState).length || + appState.elementType !== "selection"), ); diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx index d27adb35d..c308a70d6 100644 --- a/src/excalidraw-app/collab/CollabWrapper.tsx +++ b/src/excalidraw-app/collab/CollabWrapper.tsx @@ -6,10 +6,11 @@ import { APP_NAME, ENV, EVENT } from "../../constants"; import { ImportedDataState } from "../../data/types"; import { ExcalidrawElement } from "../../element/types"; import { + getElementMap, getSceneVersion, getSyncableElements, } from "../../packages/excalidraw/index"; -import { AppState, Collaborator, Gesture } from "../../types"; +import { Collaborator, Gesture } from "../../types"; import { resolvablePromise, withBatchedUpdates } from "../../utils"; import { INITIAL_SCENE_UPDATE_TIMEOUT, @@ -31,6 +32,7 @@ import { } from "../data/localStorage"; import Portal from "./Portal"; import RoomDialog from "./RoomDialog"; +import { createInverseContext } from "../../createInverseContext"; interface CollabState { isCollaborating: boolean; @@ -56,17 +58,21 @@ type ReconciledElements = readonly ExcalidrawElement[] & { }; interface Props { - children: (collab: CollabAPI) => React.ReactNode; - // NOTE not type-safe because the refObject may in fact not be initialized - // with ExcalidrawImperativeAPI yet - excalidrawRef: React.MutableRefObject; + excalidrawAPI: ExcalidrawImperativeAPI; } +const { + Context: CollabContext, + Consumer: CollabContextConsumer, + Provider: CollabContextProvider, +} = createInverseContext<{ api: CollabAPI | null }>({ api: null }); + +export { CollabContext, CollabContextConsumer }; + class CollabWrapper extends PureComponent { portal: Portal; + excalidrawAPI: Props["excalidrawAPI"]; private socketInitializationTimer?: NodeJS.Timeout; - private excalidrawRef: Props["excalidrawRef"]; - excalidrawAppState?: AppState; private lastBroadcastedOrReceivedSceneVersion: number = -1; private collaborators = new Map(); @@ -80,7 +86,7 @@ class CollabWrapper extends PureComponent { activeRoomLink: "", }; this.portal = new Portal(this); - this.excalidrawRef = props.excalidrawRef; + this.excalidrawAPI = props.excalidrawAPI; } componentDidMount() { @@ -142,7 +148,7 @@ class CollabWrapper extends PureComponent { saveCollabRoomToFirebase = async ( syncableElements: ExcalidrawElement[] = getSyncableElements( - this.excalidrawRef.current!.getSceneElementsIncludingDeleted(), + this.excalidrawAPI.getSceneElementsIncludingDeleted(), ), ) => { try { @@ -154,13 +160,13 @@ class CollabWrapper extends PureComponent { openPortal = async () => { window.history.pushState({}, APP_NAME, await generateCollaborationLink()); - const elements = this.excalidrawRef.current!.getSceneElements(); + 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.excalidrawRef.current!.history.clear(); - this.excalidrawRef.current!.updateScene({ + this.excalidrawAPI.history.clear(); + this.excalidrawAPI.updateScene({ elements, commitToHistory: true, }); @@ -175,7 +181,7 @@ class CollabWrapper extends PureComponent { private destroySocketClient = () => { this.collaborators = new Map(); - this.excalidrawRef.current!.updateScene({ + this.excalidrawAPI.updateScene({ collaborators: this.collaborators, }); this.setState({ @@ -265,7 +271,7 @@ class CollabWrapper extends PureComponent { user.selectedElementIds = selectedElementIds; user.username = username; collaborators.set(socketId, user); - this.excalidrawRef.current!.updateScene({ + this.excalidrawAPI.updateScene({ collaborators, }); break; @@ -300,7 +306,55 @@ class CollabWrapper extends PureComponent { private reconcileElements = ( elements: readonly ExcalidrawElement[], ): 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) + // add local elements that weren't deleted or on remote + .concat(...Object.values(localElementMap)); // Avoid broadcasting to the rest of the collaborators the scene // we just received! @@ -319,10 +373,10 @@ class CollabWrapper extends PureComponent { }: { init?: boolean; initFromSnapshot?: boolean } = {}, ) => { if (init || initFromSnapshot) { - this.excalidrawRef.current!.setScrollToCenter(elements); + this.excalidrawAPI.setScrollToCenter(elements); } - this.excalidrawRef.current!.updateScene({ + this.excalidrawAPI.updateScene({ elements, commitToHistory: !!init, }); @@ -331,7 +385,7 @@ class CollabWrapper extends PureComponent { // 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, // right now we think this is the right tradeoff. - this.excalidrawRef.current!.history.clear(); + this.excalidrawAPI.history.clear(); }; setCollaborators(sockets: string[]) { @@ -347,7 +401,7 @@ class CollabWrapper extends PureComponent { } } this.collaborators = collaborators; - this.excalidrawRef.current!.updateScene({ collaborators }); + this.excalidrawAPI.updateScene({ collaborators }); }); } @@ -360,7 +414,7 @@ class CollabWrapper extends PureComponent { }; public getSceneElementsIncludingDeleted = () => { - return this.excalidrawRef.current!.getSceneElementsIncludingDeleted(); + return this.excalidrawAPI.getSceneElementsIncludingDeleted(); }; onPointerUpdate = (payload: { @@ -373,11 +427,7 @@ class CollabWrapper extends PureComponent { this.portal.broadcastMouseLocation(payload); }; - broadcastElements = ( - elements: readonly ExcalidrawElement[], - state: AppState, - ) => { - this.excalidrawAppState = state; + broadcastElements = (elements: readonly ExcalidrawElement[]) => { if ( getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() @@ -396,7 +446,7 @@ class CollabWrapper extends PureComponent { this.portal.broadcastScene( SCENE.UPDATE, getSyncableElements( - this.excalidrawRef.current!.getSceneElementsIncludingDeleted(), + this.excalidrawAPI.getSceneElementsIncludingDeleted(), ), true, ); @@ -425,8 +475,23 @@ class CollabWrapper extends PureComponent { }); }; + /** PRIVATE. Use `this.getContextValue()` instead. */ + private contextValue: CollabAPI | null = null; + + /** Getter of context value. Returned object is stable. */ + getContextValue = (): CollabAPI => { + this.contextValue = this.contextValue || ({} as CollabAPI); + + this.contextValue.isCollaborating = this.state.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() { - const { children } = this.props; const { modalIsShown, username, errorMessage, activeRoomLink } = this.state; return ( @@ -450,14 +515,11 @@ class CollabWrapper extends PureComponent { onClose={() => this.setState({ errorMessage: "" })} /> )} - {children({ - isCollaborating: this.state.isCollaborating, - username: this.state.username, - onPointerUpdate: this.onPointerUpdate, - initializeSocketClient: this.initializeSocketClient, - onCollabButtonClick: this.onCollabButtonClick, - broadcastElements: this.broadcastElements, - })} + ); } diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx index 2b66b0bd0..92086a27b 100644 --- a/src/excalidraw-app/collab/Portal.tsx +++ b/src/excalidraw-app/collab/Portal.tsx @@ -6,23 +6,20 @@ import { import CollabWrapper from "./CollabWrapper"; -import { - getElementMap, - getSyncableElements, -} from "../../packages/excalidraw/index"; +import { getSyncableElements } from "../../packages/excalidraw/index"; import { ExcalidrawElement } from "../../element/types"; import { BROADCAST, SCENE } from "../app_constants"; class Portal { - app: CollabWrapper; + collab: CollabWrapper; socket: SocketIOClient.Socket | null = null; socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized roomId: string | null = null; roomKey: string | null = null; broadcastedElementVersions: Map = new Map(); - constructor(app: CollabWrapper) { - this.app = app; + constructor(collab: CollabWrapper) { + this.collab = collab; } open(socket: SocketIOClient.Socket, id: string, key: string) { @@ -30,7 +27,7 @@ class Portal { this.roomId = id; this.roomKey = key; - // Initialize socket listeners (moving from App) + // Initialize socket listeners this.socket.on("init-room", () => { if (this.socket) { this.socket.emit("join-room", this.roomId); @@ -39,12 +36,12 @@ class Portal { this.socket.on("new-user", async (_socketId: string) => { this.broadcastScene( SCENE.INIT, - getSyncableElements(this.app.getSceneElementsIncludingDeleted()), + getSyncableElements(this.collab.getSceneElementsIncludingDeleted()), /* syncAll */ true, ); }); this.socket.on("room-user-change", (clients: string[]) => { - this.app.setCollaborators(clients); + this.collab.setCollaborators(clients); }); } @@ -125,10 +122,10 @@ class Portal { data as SocketUpdateData, ); - if (syncAll && this.app.state.isCollaborating) { + if (syncAll && this.collab.state.isCollaborating) { await Promise.all([ broadcastPromise, - this.app.saveCollabRoomToFirebase(syncableElements), + this.collab.saveCollabRoomToFirebase(syncableElements), ]); } else { await broadcastPromise; @@ -146,9 +143,9 @@ class Portal { socketId: this.socket.id, pointer: payload.pointer, button: payload.button || "up", - selectedElementIds: - this.app.excalidrawAppState?.selectedElementIds || {}, - username: this.app.state.username, + selectedElementIds: this.collab.excalidrawAPI.getAppState() + .selectedElementIds, + username: this.collab.state.username, }, }; return this._broadcastSocketData( @@ -157,62 +154,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) - // add local elements that weren't deleted or on remote - .concat(...Object.values(localElementMap)) - ); - }; } export default Portal; diff --git a/src/excalidraw-app/collab/RoomDialog.scss b/src/excalidraw-app/collab/RoomDialog.scss index de784d93d..5a045136a 100644 --- a/src/excalidraw-app/collab/RoomDialog.scss +++ b/src/excalidraw-app/collab/RoomDialog.scss @@ -1,4 +1,4 @@ -@import "../../css/_variables"; +@import "../../css/variables.module"; .excalidraw { .RoomDialog-linkContainer { diff --git a/src/excalidraw-app/components/LanguageList.tsx b/src/excalidraw-app/components/LanguageList.tsx index 4b707907e..18f38a320 100644 --- a/src/excalidraw-app/components/LanguageList.tsx +++ b/src/excalidraw-app/components/LanguageList.tsx @@ -25,7 +25,6 @@ export const LanguageList = ({ - {languages.map((lang) => (