Compare commits

..

1 Commits

Author SHA1 Message Date
kbariotis
0cb67a0bc9 upload images and store them as base64 2020-05-13 21:42:56 +01:00
294 changed files with 25940 additions and 72760 deletions

View File

@@ -2,9 +2,6 @@
!public/
!src/
!.npmrc
!.eslintrc.json
!.prettierrc
!package-lock.json
!package.json
!tsconfig.json
!.env

5
.env
View File

@@ -1,5 +0,0 @@
REACT_APP_BACKEND_V1_GET_URL=https://json.excalidraw.com/api/v1/
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
REACT_APP_SOCKET_SERVER_URL=https://excalidraw-socket.herokuapp.com
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'

View File

@@ -1 +0,0 @@
REACT_APP_INCLUDE_GTAG=true

View File

@@ -3,4 +3,3 @@ build/
package-lock.json
.vscode/
firebase/
dist/

View File

@@ -3,8 +3,6 @@
"plugins": ["prettier"],
"rules": {
"curly": "warn",
"dot-notation": "warn",
"import/no-anonymous-default-export": "off",
"no-console": [
"warn",
{
@@ -12,22 +10,7 @@
}
],
"no-else-return": "warn",
"no-lonely-if": "warn",
"no-restricted-syntax": [
"warn",
{
"message": "Use 't(...)' instead of literal text in JSX",
"selector": "JSXText[value=/\\w/]"
}
],
"no-unneeded-ternary": "warn",
"no-unused-expressions": "warn",
"no-unused-vars": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"object-shorthand": "warn",
"one-var": ["warn", "never"],
"prefer-arrow-callback": "warn",
"prefer-const": [
"warn",
{
@@ -35,6 +18,13 @@
}
],
"prefer-template": "warn",
"prettier/prettier": "warn"
"prettier/prettier": "warn",
"no-restricted-syntax": [
"warn",
{
"selector": "JSXText[value=/\\w/]",
"message": "Use 't(...)' instead of literal text in JSX"
}
]
}
}

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
* text=auto eol=lf

View File

@@ -1,15 +0,0 @@
name: Build Docker image
on:
push:
branches:
- master
pull_request:
jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- run: docker build -t excalidraw .

View File

@@ -1,33 +0,0 @@
name: Build packages
on:
push:
branches:
- master
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Install dependencies
run: |
npm ci
npm ci --prefix src/packages/excalidraw
npm ci --prefix src/packages/utils
- name: Build @excalidraw/excalidraw
run: |
npm run pack --prefix src/packages/excalidraw
- name: Build @excalidraw/utils
run: |
npm run pack --prefix src/packages/utils

View File

@@ -1,26 +0,0 @@
name: Changelog in sync for packages
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.x
- name: Install and run changelog check
run: |
npm ci
npm run changelog:check
env:
CI: true

View File

@@ -1,33 +0,0 @@
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
- cron: "18 7 * * 0"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ["typescript"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -1,32 +0,0 @@
name: Build locales coverage
on:
push:
branches:
- "l10n_master"
jobs:
locales:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 12.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 add src/locales/percentages.json
git commit -am "Auto commit: Calculate translation coverage"
git push
fi

View File

@@ -1,20 +0,0 @@
name: Publish Docker
on:
push:
branches:
- master
jobs:
publish-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: docker/build-push-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: excalidraw/excalidraw
tag_with_ref: true
tag_with_sha: true

19
.gitignore vendored
View File

@@ -1,18 +1,10 @@
.DS_Store
.env.development.local
.env.local
.env.production.local
.env.test.local
.envrc
.eslintcache
.idea
.vercel
.vscode
*.log
*.tgz
.DS_Store
.envrc
.now
.vscode
build
dist
firebase
firebase/
logs
node_modules
npm-debug.log*
@@ -20,3 +12,4 @@ static
yarn-debug.log*
yarn-error.log*
yarn.lock
.idea

View File

@@ -1,7 +1,7 @@
const { CLIEngine } = require("eslint");
// see https://github.com/okonet/lint-staged#how-can-i-ignore-files-from-eslintignore-
// for explanation
// for explanation
const cli = new CLIEngine({});
module.exports = {

View File

View File

@@ -1,3 +0,0 @@
## 2020-10-13
- Added ability to embed scene source into exported PNG/SVG files so you can import the scene from them (open via `Load` button or drag & drop). #2219

View File

@@ -1,18 +1,16 @@
FROM node:14-alpine AS build
WORKDIR /opt/node_app
ENV NODE_ENV=production
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm i --no-optional
ARG REACT_APP_INCLUDE_GTAG=false
ARG NODE_ENV=production
RUN npm install
COPY . .
RUN npm run build:app:docker
RUN npm run build:app
FROM nginx:1.17-alpine
COPY --from=build /opt/node_app/build /usr/share/nginx/html
COPY --from=build /usr/src/app/build /usr/share/nginx/html
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1

View File

@@ -2,30 +2,23 @@
<a href="https://excalidraw.com">
<img src="./public/og-image.png" alt="Excalidraw logo: Sketch handrawn like diagrams." />
</a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.</h3>
<h3>Excalidraw is a whiteboard tool that lets you easily sketch diagrams with a hand-drawn feel.</h3>
<p>
<a href="https://twitter.com/Excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+excalidraw&style=social&logo=twitter">
</a>
<a target="_blank" href="https://crowdin.com/project/excalidraw">
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/excalidraw">
<img src="https://badges.crowdin.net/excalidraw/localized.svg">
</a>
<a target="_blank" href="https://hub.docker.com/r/excalidraw/excalidraw">
<img src="https://img.shields.io/docker/pulls/excalidraw/excalidraw">
</a>
</p>
</div>
## Try it now
Go to [excalidraw.com](https://excalidraw.com) to start sketching.
Go to 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.
## Shape libraries
Find a growing list of libraries containing assets for your drawings at [libraries.excalidraw.com](https://libraries.excalidraw.com).
## Run the code
### Code Sandbox
@@ -55,28 +48,24 @@ git clone https://github.com/excalidraw/excalidraw.git
| `npm run test:update` | Update test snapshots |
| `npm run test:code` | Test for formatting with Prettier |
#### Docker Compose
### Docker Installation
You can use docker-compose to work on excalidraw locally if you don't want to setup a Node.js env.
A production-ready version for deploying to e.g. Kubernetes or OpenShift can be built using Docker.
#### Docker Compose
```sh
docker-compose up --build -d
```
## 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.
#### Native Docker
```sh
docker build -t excalidraw/excalidraw .
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latest
```
The Docker image is free of analytics and other tracking libraries.
**At the moment, self-hosting your own instance doesn't support sharing or collaboration features.**
We are working towards providing a full-fledged solution for self hosting your own Excalidraw.
After building the image and running the container, open <http://localhost:5000> to see the application.
## Contributing
@@ -86,13 +75,11 @@ Pull requests are welcome. For major changes, please [open an issue](https://git
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)
- [Rough.js](https://roughjs.com)
- [TypeScript](https://www.typescriptlang.org)
- [TypeScript](https://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.

View File

@@ -1,64 +0,0 @@
| Excalidraw | Category | Name | Label | Value |
| ----------------------- | -------- | ---------------------------------- | ------------------------------- | --------- |
| Shape / Selection | shape | selection, rectangle, diamond, etc | `toolbar` or `shortcut` |
| Text on double click | shape | text | `double-click` |
| Lock selection | shape | lock | `on` or `off` |
| Clear canvas | action | clear canvas |
| Zoom in | action | zoom | in | `zoom` |
| Zoom out | action | zoom | out | `zoom` |
| Zoom fit | action | zoom | fit | `zoom` |
| Zoom reset | action | zoom | reset | `zoom` |
| Scroll back to content | action | scroll to content |
| Load file | io | load | `MIME type` |
| Import from URL | io | import |
| Save | io | save |
| Save as | io | save as |
| Export to backend | io | export | backend |
| Export as SVG | io | export | `svg` or `clipboard-svg` |
| Export to PNG | io | export | `png` or `clipboard-png` |
| Canvas color | change | canvas color | `color` |
| Background color | change | background color | `color` |
| Stroke color | change | stroke color | `color` |
| Stroke width | change | stroke | width | `width` |
| Stroke style | change | style | `solid` or `dashed` or `dotted` |
| Stroke sloppiness | change | stroke | sloppiness | `value` |
| Fill | change | fill | `value` |
| Edge | change | edge | `value` |
| Opacity | change | opacity | value | `opacity` |
| Project name | change | title |
| Theme | change | theme | `light` or `dark` |
| Change language | change | language | `language` |
| Send to back | layer | move | `back` |
| Send backward | layer | move | `down` |
| Bring to front | layer | move | `front` |
| Bring forward | layer | move | `up` |
| Align left | align | align | `left` |
| Align right | align | align | `right` |
| Align top | align | align | `top` |
| Align bottom | align | align | `bottom` |
| Center horizontally | align | horizontally | `center` |
| Center vertically | align | vertically | `center` |
| Distribute horizontally | align | distribute | `horizontally` |
| Distribute vertically | align | distribute | `vertically` |
| Start session | share | session start |
| Join session | share | session join |
| Start end | share | session end |
| Copy room link | share | copy link |
| Go to collaborator | share | go to collaborator |
| Change name | share | name |
| Add to library | library | add |
| Remove from library | library | remove |
| Load library | library | load |
| Save library | library | save |
| Import library | library | import |
| Shortcuts dialog | dialog | shortcuts |
| Collaboration dialog | dialog | collaboration |
| Export dialog | dialog | export |
| Library dialog | dialog | library |
| E2EE shield | exit | e2ee shield |
| GitHub corner | exit | github |
| Excalidraw blog | exit | blog |
| Excalidraw guides | exit | guides |
| File issues | exit | issues |
| First load | load | first load |
| Load from stroage | load | storage | size | `bytes` |

View File

@@ -1,25 +1,9 @@
version: "3.8"
version: "3"
services:
excalidraw:
build:
context: .
args:
- NODE_ENV=development
build: .
container_name: excalidraw
ports:
- "3000:80"
- "5000:80"
restart: on-failure
stdin_open: true
healthcheck:
disable: true
environment:
- NODE_ENV=development
volumes:
- ./:/opt/node_app/app:delegated
- ./package.json:/opt/node_app/package.json
- ./package-lock.json:/opt/node_app/package-lock.json
- notused:/opt/node_app/app/node_modules
volumes:
notused:

View File

@@ -1,5 +0,0 @@
{
"projects": {
"default": "excalidraw-room-persistence"
}
}

View File

@@ -1,66 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
firebase-debug.log*
firebase-debug.*.log*
# Firebase cache
.firebase/
# Firebase config
# Uncomment this if you'd like others to create their own Firebase project.
# For a team working on the same Firebase project(s), it is recommended to leave
# it commented so all members can deploy to the same project(s) in .firebaserc.
# .firebaserc
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env

View File

@@ -1,6 +0,0 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
}
}

View File

@@ -1,4 +0,0 @@
{
"indexes": [],
"fieldOverrides": []
}

View File

@@ -1,10 +0,0 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow get, write: if true;
// never set this to true, otherwise anyone can delete anyone else's drawing.
allow list: if false;
}
}
}

18036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,48 +19,41 @@
]
},
"dependencies": {
"@sentry/browser": "5.28.0",
"@sentry/integrations": "5.28.0",
"@testing-library/jest-dom": "5.11.6",
"@testing-library/react": "11.2.2",
"@types/jest": "26.0.16",
"@sentry/browser": "5.15.5",
"@sentry/integrations": "5.15.5",
"@testing-library/jest-dom": "5.7.0",
"@testing-library/react": "10.0.4",
"@types/jest": "25.2.1",
"@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.11.1",
"clsx": "1.1.1",
"firebase": "8.1.2",
"i18next-browser-languagedetector": "6.0.1",
"@types/react": "16.9.35",
"@types/react-dom": "16.9.8",
"@types/socket.io-client": "1.4.32",
"browser-nativefs": "0.7.3",
"i18next-browser-languagedetector": "4.1.1",
"lodash.throttle": "4.1.1",
"nanoid": "2.1.11",
"node-sass": "4.14.1",
"open-color": "1.7.0",
"pako": "1.0.11",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"points-on-curve": "0.2.0",
"pwacompat": "2.0.17",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-scripts": "4.0.1",
"pwacompat": "2.0.12",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-scripts": "3.4.1",
"roughjs": "4.3.1",
"socket.io-client": "2.3.1",
"typescript": "4.0.5"
"socket.io-client": "2.3.0",
"typescript": "3.8.3"
},
"devDependencies": {
"@types/lodash.throttle": "4.1.6",
"@types/pako": "1.0.1",
"asar": "3.0.3",
"eslint-config-prettier": "7.0.0",
"eslint-plugin-prettier": "3.1.4",
"firebase-tools": "8.17.0",
"husky": "4.3.0",
"jest-canvas-mock": "2.3.0",
"lint-staged": "10.5.3",
"eslint": "6.8.0",
"eslint-config-prettier": "6.11.0",
"eslint-plugin-prettier": "3.1.3",
"husky": "4.2.5",
"jest-canvas-mock": "2.2.0",
"lint-staged": "10.2.2",
"pepjs": "0.5.2",
"prettier": "2.2.1",
"prettier": "2.0.5",
"rewire": "5.0.0"
},
"engines": {
@@ -75,32 +68,27 @@
"jest": {
"transformIgnorePatterns": [
"node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-nativefs)/)"
],
"resetMocks": false
]
},
"name": "excalidraw",
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "REACT_APP_INCLUDE_GTAG=false REACT_APP_DISABLE_SENTRY=true react-scripts build",
"build:app": "REACT_APP_INCLUDE_GTAG=true REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
"build:zip": "node ./scripts/build-version.js",
"build": "npm run build:app && npm run build:zip",
"build-node": "node ./scripts/build-node.js",
"build:app": "REACT_APP_GIT_SHA=$NOW_GITHUB_COMMIT_SHA react-scripts build",
"build:zip": "node ./scripts/build-version.js",
"eject": "react-scripts eject",
"fix": "npm run fix:other && npm run fix:code",
"fix:code": "npm run test:code -- --fix",
"fix:other": "npm run prettier -- --write",
"fix": "npm run fix:other && npm run fix:code",
"locales-coverage": "node scripts/build-locales-coverage.js",
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
"start": "react-scripts start",
"test": "npm run test:app",
"test:all": "npm run test:typecheck && npm run test:code && npm run test:other && npm run test:app -- --watchAll=false",
"test:app": "react-scripts test --passWithNoTests",
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
"test:app": "react-scripts test --env=jsdom --passWithNoTests",
"test:code": "eslint --max-warnings=0 --ignore-path .gitignore --ext .js,.ts,.tsx .",
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
"test:other": "npm run prettier -- --list-different",
"test:typecheck": "tsc",
"test:update": "npm run test:app -- --updateSnapshot --watchAll=false",
"test": "npm run test:app",
"changelog:check": "node ./scripts/changelog-check.js"
"test:typecheck": "tsc"
}
}

View File

@@ -13,6 +13,18 @@
<meta name="theme-color" content="#000" />
<!-- Origin Trial token for the Native File System API v1 https://developers.chrome.com/origintrials/#/view_trial/3868592079911256065 (Chrome 7881) -->
<meta
http-equiv="origin-trial"
content="AoGjY+6r8OQZ5c0AXpK+bbca0pJdCTSHWFqSFNulxiW4OwFBB63kHdDHNo433GeuEOir8IvSovR0LOZLfPnEDAUAAABceyJvcmlnaW4iOiJodHRwczovL3d3dy5leGNhbGlkcmF3LmNvbTo0NDMiLCJmZWF0dXJlIjoiTmF0aXZlRmlsZVN5c3RlbSIsImV4cGlyeSI6MTU4OTMyNzk5OX0="
/>
<!-- Origin Trial token for the Native File System API v2 https://developers.chrome.com/origintrials/#/view_trial/4019462667428167681 (Chrome 8385) -->
<meta
http-equiv="origin-trial"
content="AgMee3sqSZkE0QaZP8f/F9OJj5iSLdnNMRGttIDlOQy552MI4GoL41jyCAHOYsQ8UWM1kPdrb6PVmbSllX/JqwEAAABZeyJvcmlnaW4iOiJodHRwczovL2V4Y2FsaWRyYXcuY29tOjQ0MyIsImZlYXR1cmUiOiJOYXRpdmVGaWxlU3lzdGVtMiIsImV4cGlyeSI6MTU5MDU3MzM5MX0="
/>
<!-- General tags -->
<meta
name="description"
@@ -54,7 +66,7 @@
<!-- OG tags require absolute url for images -->
<meta name="twitter:image" content="https://excalidraw.com/og-image.png" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="fonts.css" type="text/css" />
<link
rel="preload"
href="FG_Virgil.woff2"
@@ -71,7 +83,7 @@
/>
<link
href="%REACT_APP_SOCKET_SERVER_URL%/socket.io"
href="https://excalidraw-socket.herokuapp.com/socket.io"
rel="preconnect"
crossorigin="anonymous"
/>
@@ -79,12 +91,38 @@
<link
rel="manifest"
href="manifest.json"
style="--pwacompat-splash-font: 24px Virgil"
style="--pwacompat-splash-font: 24px Virgil;"
/>
<link rel="stylesheet" href="fonts.css" type="text/css" />
<% if (process.env.REACT_APP_INCLUDE_GTAG === 'true') { %>
<style>
.LoadingMessage {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: rgba(255, 255, 255, 0.8);
border-radius: 5px;
padding: 0.8em 1.2em;
font-size: 1.3em;
}
.visually-hidden {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
}
</style>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=UA-387204-13"
@@ -97,56 +135,12 @@
gtag("js", new Date());
gtag("config", "UA-387204-13");
</script>
<% } %>
<!-- FIXME: remove this when we update CRA (fix SW caching) -->
<style>
body {
margin: 0;
--ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Helvetica, Arial, sans-serif;
font-family: var(--ui-font);
-webkit-text-size-adjust: 100%;
-webkit-user-select: none;
user-select: none;
width: 100vw;
height: 100vh;
}
.visually-hidden {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
}
.LoadingMessage {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.LoadingMessage span {
background-color: var(--button-gray-1);
border-radius: 5px;
padding: 0.8em 1.2em;
color: var(--popup-text-color);
font-size: 1.3em;
}
</style>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<header>
<h1 class="visually-hidden">Excalidraw</h1>
</header>

View File

@@ -14,16 +14,8 @@
"sizes": "256x256"
}
],
"start_url": "/",
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"file_handlers": [
{
"action": "/",
"accept": {
"application/vnd.excalidraw+json": [".excalidraw"]
}
}
]
"background_color": "#ffffff"
}

View File

@@ -1,3 +0,0 @@
user-agent: *
Allow: /$
Disallow: /

View File

@@ -1,32 +0,0 @@
const { readdirSync, writeFileSync } = require("fs");
const files = readdirSync(`${__dirname}/../src/locales`);
const flatten = (object) =>
Object.keys(object).reduce(
(initial, current) => ({ ...initial, ...object[current] }),
{},
);
const locales = files.filter(
(file) => file !== "README.md" && file !== "percentages.json",
);
const percentages = {};
for (let index = 0; index < locales.length; index++) {
const currentLocale = locales[index];
const data = flatten(require(`${__dirname}/../src/locales/${currentLocale}`));
const allKeys = Object.keys(data);
const translatedKeys = allKeys.filter((item) => data[item] !== "");
const percentage = (100 * translatedKeys.length) / allKeys.length;
percentages[currentLocale.replace(".json", "")] = parseInt(percentage);
}
writeFileSync(
`${__dirname}/../src/locales/percentages.json`,
`${JSON.stringify(percentages, null, 2)}\n`,
"utf8",
);

View File

@@ -9,9 +9,9 @@
// node build/static/js/build-node.js
// open test.png
const rewire = require("rewire");
const defaults = rewire("react-scripts/scripts/build.js");
const config = defaults.__get__("config");
var rewire = require("rewire");
var defaults = rewire("react-scripts/scripts/build.js");
var config = defaults.__get__("config");
// Disable multiple chunks
config.optimization.runtimeChunk = false;
@@ -29,7 +29,7 @@ config.entry = "./src/index-node";
// By default, webpack is going to replace the require of the canvas.node file
// to just a string with the path of the canvas.node file. We need to tell
// webpack to avoid rewriting that dependency.
config.externals = (context, request, callback) => {
config.externals = function (context, request, callback) {
if (/\.node$/.test(request)) {
return callback(
null,

View File

@@ -20,7 +20,7 @@ const now = new Date();
const data = JSON.stringify(
{
asar: "excalidraw.asar",
asar: `excalidraw.asar`,
version: versionDate(now),
},
undefined,

View File

@@ -1,34 +0,0 @@
const { exec } = require("child_process");
const changeLogCheck = () => {
exec(
"git diff origin/master --cached --name-only",
(error, stdout, stderr) => {
if (error || stderr) {
process.exit(1);
}
if (!stdout || stdout.includes("packages/excalidraw/CHANGELOG.MD")) {
process.exit(0);
}
const onlyNonSrcFilesUpdated = stdout.indexOf("src") < 0;
if (onlyNonSrcFilesUpdated) {
process.exit(0);
}
const changedFiles = stdout.trim().split("\n");
const filesToIgnoreRegex = /src\/excalidraw-app|packages\/utils/;
const excalidrawPackageFiles = changedFiles.filter((file) => {
return file.indexOf("src") >= 0 && !filesToIgnoreRegex.test(file);
});
if (excalidrawPackageFiles.length) {
process.exit(1);
}
process.exit(0);
},
);
};
changeLogCheck();

View File

@@ -1,24 +0,0 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { Library } from "../data/library";
import { EVENT_LIBRARY, trackEvent } from "../analytics";
export const actionAddToLibrary = register({
name: "addToLibrary",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
Library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
trackEvent(EVENT_LIBRARY, "add");
return false;
},
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary",
});

View File

@@ -1,214 +0,0 @@
import React from "react";
import { KEYS } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import {
AlignBottomIcon,
AlignLeftIcon,
AlignRightIcon,
AlignTopIcon,
CenterHorizontallyIcon,
CenterVerticallyIcon,
} from "../components/icons";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getElementMap, getNonDeletedElements } from "../element";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { alignElements, Alignment } from "../align";
import { getShortcutKey } from "../utils";
import { trackEvent, EVENT_ALIGN } from "../analytics";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
const alignSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
alignment: Alignment,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = alignElements(selectedElements, alignment);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
export const actionAlignTop = register({
name: "alignTop",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "top");
return {
appState,
elements: alignSelectedElements(elements, appState, {
position: "start",
axis: "y",
}),
commitToHistory: true,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<AlignTopIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.alignTop")}${getShortcutKey(
"CtrlOrCmd+Shift+Up",
)}`}
aria-label={t("labels.alignTop")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignBottom = register({
name: "alignBottom",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "bottom");
return {
appState,
elements: alignSelectedElements(elements, appState, {
position: "end",
axis: "y",
}),
commitToHistory: true,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<AlignBottomIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.alignBottom")}${getShortcutKey(
"CtrlOrCmd+Shift+Down",
)}`}
aria-label={t("labels.alignBottom")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignLeft = register({
name: "alignLeft",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "left");
return {
appState,
elements: alignSelectedElements(elements, appState, {
position: "start",
axis: "x",
}),
commitToHistory: true,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<AlignLeftIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.alignLeft")}${getShortcutKey(
"CtrlOrCmd+Shift+Left",
)}`}
aria-label={t("labels.alignLeft")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignRight = register({
name: "alignRight",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "align", "right");
return {
appState,
elements: alignSelectedElements(elements, appState, {
position: "end",
axis: "x",
}),
commitToHistory: true,
};
},
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<AlignRightIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.alignRight")}${getShortcutKey(
"CtrlOrCmd+Shift+Right",
)}`}
aria-label={t("labels.alignRight")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "vertically", "center");
return {
appState,
elements: alignSelectedElements(elements, appState, {
position: "center",
axis: "y",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<CenterVerticallyIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={t("labels.centerVertically")}
aria-label={t("labels.centerVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "horizontally", "center");
return {
appState,
elements: alignSelectedElements(elements, appState, {
position: "center",
axis: "x",
}),
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<CenterHorizontallyIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={t("labels.centerHorizontally")}
aria-label={t("labels.centerHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});

View File

@@ -4,31 +4,18 @@ import { getDefaultAppState } from "../appState";
import { trash, zoomIn, zoomOut, resetZoom } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { getNormalizedZoom } from "../scene";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom, calculateScrollCenter } from "../scene";
import { KEYS } from "../keys";
import { getShortcutKey } from "../utils";
import useIsMobile from "../is-mobile";
import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
import { AppState, NormalizedZoomValue } from "../types";
import { AppState, FlooredNumber } from "../types";
import { getCommonBounds } from "../element";
import { getNewZoom } from "../scene/zoom";
import { centerScrollOn } from "../scene/scroll";
import { EVENT_ACTION, EVENT_CHANGE, trackEvent } from "../analytics";
import colors from "../colors";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
perform: (_, appState, value) => {
if (value !== appState.viewBackgroundColor) {
trackEvent(
EVENT_CHANGE,
"canvas color",
colors.canvasBackground.includes(value)
? `${value} (picker ${colors.canvasBackground.indexOf(value)})`
: value,
);
}
return {
appState: { ...appState, viewBackgroundColor: value },
commitToHistory: true,
@@ -51,20 +38,13 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
perform: (elements, appState: AppState) => {
trackEvent(EVENT_ACTION, "clear canvas");
return {
elements: elements.map((element) =>
newElementWith(element, { isDeleted: true }),
),
appState: {
...getDefaultAppState(),
appearance: appState.appearance,
elementLocked: appState.elementLocked,
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
shouldAddWatermark: appState.shouldAddWatermark,
showStats: appState.showStats,
username: appState.username,
},
commitToHistory: true,
};
@@ -78,6 +58,10 @@ export const actionClearCanvas = register({
showAriaLabel={useIsMobile()}
onClick={() => {
if (window.confirm(t("alerts.clearReset"))) {
// TODO: Defined globally, since file handles aren't yet serializable.
// Once `FileSystemFileHandle` can be serialized, make this
// part of `AppState`.
(window as any).handle = null;
updateData(null);
}
}}
@@ -87,19 +71,23 @@ export const actionClearCanvas = register({
const ZOOM_STEP = 0.1;
const KEY_CODES = {
MINUS: "Minus",
EQUAL: "Equal",
ONE: "Digit1",
ZERO: "Digit0",
NUM_SUBTRACT: "NumpadSubtract",
NUM_ADD: "NumpadAdd",
NUM_ZERO: "Numpad0",
};
export const actionZoomIn = register({
name: "zoomIn",
perform: (_elements, appState) => {
const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value + ZOOM_STEP),
appState.zoom,
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "in", zoom.value * 100);
return {
appState: {
...appState,
zoom,
zoom: getNormalizedZoom(appState.zoom + ZOOM_STEP),
},
commitToHistory: false,
};
@@ -116,24 +104,17 @@ export const actionZoomIn = register({
/>
),
keyTest: (event) =>
(event.code === CODES.EQUAL || event.code === CODES.NUM_ADD) &&
(event.code === KEY_CODES.EQUAL || event.code === KEY_CODES.NUM_ADD) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
export const actionZoomOut = register({
name: "zoomOut",
perform: (_elements, appState) => {
const zoom = getNewZoom(
getNormalizedZoom(appState.zoom.value - ZOOM_STEP),
appState.zoom,
{ x: appState.width / 2, y: appState.height / 2 },
);
trackEvent(EVENT_ACTION, "zoom", "out", zoom.value * 100);
return {
appState: {
...appState,
zoom,
zoom: getNormalizedZoom(appState.zoom - ZOOM_STEP),
},
commitToHistory: false,
};
@@ -150,21 +131,17 @@ export const actionZoomOut = register({
/>
),
keyTest: (event) =>
(event.code === CODES.MINUS || event.code === CODES.NUM_SUBTRACT) &&
(event.code === KEY_CODES.MINUS || event.code === KEY_CODES.NUM_SUBTRACT) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
export const actionResetZoom = register({
name: "resetZoom",
perform: (_elements, appState) => {
trackEvent(EVENT_ACTION, "zoom", "reset", 100);
return {
appState: {
...appState,
zoom: getNewZoom(1 as NormalizedZoomValue, appState.zoom, {
x: appState.width / 2,
y: appState.height / 2,
}),
zoom: 1,
},
commitToHistory: false,
};
@@ -181,63 +158,66 @@ export const actionResetZoom = register({
/>
),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&
(event.code === KEY_CODES.ZERO || event.code === KEY_CODES.NUM_ZERO) &&
(event[KEYS.CTRL_OR_CMD] || event.shiftKey),
});
const zoomValueToFitBoundsOnViewport = (
bounds: [number, number, number, number],
viewportDimensions: { width: number; height: number },
) => {
const [x1, y1, x2, y2] = bounds;
const commonBoundsWidth = x2 - x1;
const zoomValueForWidth = viewportDimensions.width / commonBoundsWidth;
const commonBoundsHeight = y2 - y1;
const zoomValueForHeight = viewportDimensions.height / commonBoundsHeight;
const smallestZoomValue = Math.min(zoomValueForWidth, zoomValueForHeight);
const zoomAdjustedToSteps =
Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP;
const clampedZoomValueToFitElements = Math.min(
Math.max(zoomAdjustedToSteps, ZOOM_STEP),
1,
);
return clampedZoomValueToFitElements as NormalizedZoomValue;
const calculateZoom = (
commonBounds: number[],
currentZoom: number,
{
scrollX,
scrollY,
}: {
scrollX: FlooredNumber;
scrollY: FlooredNumber;
},
): number => {
const { innerWidth, innerHeight } = window;
const [x, y] = commonBounds;
const zoomX = -innerWidth / (2 * scrollX + 2 * x - innerWidth);
const zoomY = -innerHeight / (2 * scrollY + 2 * y - innerHeight);
const margin = 0.01;
let newZoom;
if (zoomX < zoomY) {
newZoom = zoomX - margin;
} else if (zoomY <= zoomX) {
newZoom = zoomY - margin;
} else {
newZoom = currentZoom;
}
if (newZoom <= 0.1) {
return 0.1;
}
if (newZoom >= 1) {
return 1;
}
return newZoom;
};
export const actionZoomToFit = register({
name: "zoomToFit",
perform: (elements, appState) => {
const nonDeletedElements = elements.filter((element) => !element.isDeleted);
const scrollCenter = calculateScrollCenter(nonDeletedElements);
const commonBounds = getCommonBounds(nonDeletedElements);
const zoom = calculateZoom(commonBounds, appState.zoom, scrollCenter);
const zoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
});
const newZoom = getNewZoom(zoomValue, appState.zoom);
const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
trackEvent(EVENT_ACTION, "zoom", "fit", newZoom.value * 100);
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: { x: centerX, y: centerY },
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
scrollX: scrollCenter.scrollX,
scrollY: scrollCenter.scrollY,
zoom,
},
commitToHistory: false,
};
},
keyTest: (event) =>
event.code === CODES.ONE &&
event.code === KEY_CODES.ONE &&
event.shiftKey &&
!event.altKey &&
!event[KEYS.CTRL_OR_CMD],

View File

@@ -1,4 +1,4 @@
import { isSomeElementSelected } from "../scene";
import { deleteSelectedElements, isSomeElementSelected } from "../scene";
import { KEYS } from "../keys";
import { ToolButton } from "../components/ToolButton";
import React from "react";
@@ -6,122 +6,14 @@ import { trash } from "../components/icons";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
return newElementWith(el, { isDeleted: true });
}
return el;
}),
appState: {
...appState,
selectedElementIds: {},
},
};
};
const handleGroupEditingState = (
appState: AppState,
elements: readonly ExcalidrawElement[],
): AppState => {
if (appState.editingGroupId) {
const siblingElements = getElementsInGroup(
getNonDeletedElements(elements),
appState.editingGroupId!,
);
if (siblingElements.length) {
return {
...appState,
selectedElementIds: { [siblingElements[0].id]: true },
};
}
}
return appState;
};
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
elementId,
activePointIndex,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
if (
// case: no point selected → delete whole element
activePointIndex == null ||
activePointIndex === -1 ||
// case: deleting last remaining point
element.points.length < 2
) {
const nextElements = elements.filter((el) => el.id !== element.id);
const nextAppState = handleGroupEditingState(appState, nextElements);
return {
elements: nextElements,
appState: {
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
};
}
// We cannot do this inside `movePoint` because it is also called
// when deleting the uncommitted point (which hasn't caused any binding)
const binding = {
startBindingElement:
activePointIndex === 0 ? null : startBindingElement,
endBindingElement:
activePointIndex === element.points.length - 1
? null
: endBindingElement,
};
LinearElementEditor.movePoint(element, activePointIndex, "delete");
return {
elements,
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
...binding,
activePointIndex: activePointIndex > 0 ? activePointIndex - 1 : 0,
},
},
commitToHistory: true,
};
}
let {
const {
elements: nextElements,
appState: nextAppState,
} = deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),
);
nextAppState = handleGroupEditingState(nextAppState, nextElements);
return {
elements: nextElements,
appState: {

View File

@@ -1,94 +0,0 @@
import React from "react";
import { CODES } from "../keys";
import { t } from "../i18n";
import { register } from "./register";
import {
DistributeHorizontallyIcon,
DistributeVerticallyIcon,
} from "../components/icons";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getElementMap, getNonDeletedElements } from "../element";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { distributeElements, Distribution } from "../disitrubte";
import { getShortcutKey } from "../utils";
import { EVENT_ALIGN, trackEvent } from "../analytics";
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
const distributeSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
distribution: Distribution,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
const updatedElements = distributeElements(selectedElements, distribution);
const updatedElementsMap = getElementMap(updatedElements);
return elements.map((element) => updatedElementsMap[element.id] || element);
};
export const distributeHorizontally = register({
name: "distributeHorizontally",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "distribute", "horizontally");
return {
appState,
elements: distributeSelectedElements(elements, appState, {
space: "between",
axis: "x",
}),
commitToHistory: true,
};
},
keyTest: (event) => event.altKey && event.code === CODES.H,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<DistributeHorizontallyIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.distributeHorizontally")}${getShortcutKey(
"Alt+H",
)}`}
aria-label={t("labels.distributeHorizontally")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});
export const distributeVertically = register({
name: "distributeVertically",
perform: (elements, appState) => {
trackEvent(EVENT_ALIGN, "distribute", "vertically");
return {
appState,
elements: distributeSelectedElements(elements, appState, {
space: "between",
axis: "y",
}),
commitToHistory: true,
};
},
keyTest: (event) => event.altKey && event.code === CODES.V,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<DistributeVerticallyIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.distributeVertically")}${getShortcutKey("Alt+V")}`}
aria-label={t("labels.distributeVertically")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
/>
),
});

View File

@@ -8,63 +8,32 @@ import { ToolButton } from "../components/ToolButton";
import { clone } from "../components/icons";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement } from "../element/mutateElement";
import {
selectGroupsForSelectedElements,
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
perform: (elements, appState) => {
// duplicate point if selected while editing multi-point element
if (appState.editingLinearElement) {
const { activePointIndex, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (!element || activePointIndex === null) {
return false;
}
const { points } = element;
const selectedPoint = points[activePointIndex];
const nextPoint = points[activePointIndex + 1];
mutateElement(element, {
points: [
...points.slice(0, activePointIndex + 1),
nextPoint
? [
(selectedPoint[0] + nextPoint[0]) / 2,
(selectedPoint[1] + nextPoint[1]) / 2,
]
: [selectedPoint[0] + 30, selectedPoint[1] + 30],
...points.slice(activePointIndex + 1),
],
});
return {
appState: {
...appState,
editingLinearElement: {
...appState.editingLinearElement,
activePointIndex: activePointIndex + 1,
},
},
elements,
commitToHistory: true,
};
}
return {
...duplicateElements(elements, appState),
appState,
elements: elements.reduce(
(acc: Array<ExcalidrawElement>, element: ExcalidrawElement) => {
if (appState.selectedElementIds[element.id]) {
const newElement = duplicateElement(element, {
x: element.x + 10,
y: element.y + 10,
});
appState.selectedElementIds[newElement.id] = true;
delete appState.selectedElementIds[element.id];
return acc.concat([element, newElement]);
}
return acc.concat(element);
},
[],
),
commitToHistory: true,
};
},
contextItemLabel: "labels.duplicateSelection",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "d",
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
@@ -78,74 +47,3 @@ export const actionDuplicateSelection = register({
/>
),
});
const duplicateElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
): Partial<ActionResult> => {
const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + GRID_SIZE / 2,
y: element.y + GRID_SIZE / 2,
},
);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
return newElement;
};
const finalElements: ExcalidrawElement[] = [];
let index = 0;
while (index < elements.length) {
const element = elements[index];
if (appState.selectedElementIds[element.id]) {
if (element.groupIds.length) {
const groupId = getSelectedGroupForElement(appState, element);
// if group selected, duplicate it atomically
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId);
finalElements.push(
...groupElements,
...groupElements.map((element) =>
duplicateAndOffsetElement(element),
),
);
index = index + groupElements.length;
continue;
}
}
finalElements.push(element, duplicateAndOffsetElement(element));
} else {
finalElements.push(element);
}
index++;
}
fixBindingsAfterDuplication(finalElements, oldElements, oldIdToDuplicatedId);
return {
elements: finalElements,
appState: selectGroupsForSelectedElements(
{
...appState,
selectedGroupIds: {},
selectedElementIds: newElements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as any),
},
getNonDeletedElements(finalElements),
),
};
};

View File

@@ -1,19 +1,16 @@
import React from "react";
import { EVENT_CHANGE, EVENT_IO, trackEvent } from "../analytics";
import { load, save, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { saveAsJSON, loadFromJSON } from "../data";
import { load, save } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { loadFromJSON, saveAsJSON } from "../data";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { KEYS } from "../keys";
import { muteFSAbortError } from "../utils";
import { register } from "./register";
import { KEYS } from "../keys";
export const actionChangeProjectName = register({
name: "changeProjectName",
perform: (_elements, appState, value) => {
trackEvent(EVENT_CHANGE, "title");
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData }) => (
@@ -45,26 +42,6 @@ export const actionChangeExportBackground = register({
),
});
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
<label title={t("labels.exportEmbedScene_details")}>
<input
type="checkbox"
checked={appState.exportEmbedScene}
onChange={(event) => updateData(event.target.checked)}
/>{" "}
{t("labels.exportEmbedScene")}
</label>
),
});
export const actionChangeShouldAddWatermark = register({
name: "changeShouldAddWatermark",
perform: (_elements, appState, value) => {
@@ -87,20 +64,13 @@ export const actionChangeShouldAddWatermark = register({
export const actionSaveScene = register({
name: "saveScene",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, appState);
trackEvent(EVENT_IO, "save");
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
}
return { commitToHistory: false };
}
perform: (elements, appState, value) => {
saveAsJSON(elements, appState).catch((error) => console.error(error));
return { commitToHistory: false };
},
keyTest: (event) => {
return event.key === "s" && event[KEYS.CTRL_OR_CMD];
},
keyTest: (event) =>
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
@@ -113,55 +83,23 @@ export const actionSaveScene = register({
),
});
export const actionSaveAsScene = register({
name: "saveAsScene",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, {
...appState,
fileHandle: null,
});
trackEvent(EVENT_IO, "save as");
return { commitToHistory: false, appState: { ...appState, fileHandle } };
} catch (error) {
if (error?.name !== "AbortError") {
console.error(error);
}
return { commitToHistory: false };
}
},
keyTest: (event) =>
event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={saveAs}
title={t("buttons.saveAs")}
aria-label={t("buttons.saveAs")}
showAriaLabel={useIsMobile()}
hidden={
!("chooseFileSystemEntries" in window || "showOpenFilePicker" in window)
}
onClick={() => updateData(null)}
/>
),
});
export const actionLoadScene = register({
name: "loadScene",
perform: (
elements,
appState,
{ elements: loadedElements, appState: loadedAppState, error },
) => ({
elements: loadedElements,
appState: {
...loadedAppState,
errorMessage: error,
},
commitToHistory: true,
}),
PanelComponent: ({ updateData, appState }) => (
) => {
return {
elements: loadedElements,
appState: {
...loadedAppState,
errorMessage: error,
},
commitToHistory: false,
};
},
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={load}
@@ -169,12 +107,15 @@ export const actionLoadScene = register({
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
onClick={() => {
loadFromJSON(appState)
loadFromJSON()
.then(({ elements, appState }) => {
updateData({ elements, appState });
updateData({ elements: elements, appState: appState });
})
.catch(muteFSAbortError)
.catch((error) => {
// if user cancels, ignore the error
if (error.name === "AbortError") {
return;
}
updateData({ error: error.message });
});
}}

View File

@@ -8,47 +8,10 @@ import { t } from "../i18n";
import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import Scene from "../scene/Scene";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
} from "../element/binding";
import { isBindingElement } from "../element/typeChecks";
export const actionFinalize = register({
name: "finalize",
perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
elementId,
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId);
if (element) {
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
);
}
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
: undefined,
appState: {
...appState,
editingLinearElement: null,
},
commitToHistory: true,
};
}
}
let newElements = elements;
if (window.document.activeElement instanceof HTMLElement) {
window.document.activeElement.blur();
@@ -83,17 +46,16 @@ export const actionFinalize = register({
// If the multi point line closes the loop,
// set the last point to first point.
// This ensures that loop remains closed at different scales.
const isLoop = isPathALoop(multiPointElement.points);
if (
multiPointElement.type === "line" ||
multiPointElement.type === "draw"
) {
if (isLoop) {
if (isPathALoop(multiPointElement.points)) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0];
mutateElement(multiPointElement, {
points: linePoints.map((point, index) =>
index === linePoints.length - 1
points: linePoints.map((point, i) =>
i === linePoints.length - 1
? ([firstPoint[0], firstPoint[1]] as const)
: point,
),
@@ -101,23 +63,6 @@ export const actionFinalize = register({
}
}
if (
isBindingElement(multiPointElement) &&
!isLoop &&
multiPointElement.points.length > 1
) {
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
);
maybeBindLinearElement(
multiPointElement,
appState,
Scene.getScene(multiPointElement)!,
{ x, y },
);
}
if (!appState.elementLocked) {
appState.selectedElementIds[multiPointElement.id] = true;
}
@@ -136,8 +81,6 @@ export const actionFinalize = register({
draggingElement: null,
multiElement: null,
editingElement: null,
startBoundElement: null,
suggestedBindings: [],
selectedElementIds:
multiPointElement && !appState.elementLocked
? {
@@ -146,13 +89,13 @@ export const actionFinalize = register({
}
: appState.selectedElementIds,
},
commitToHistory: appState.elementType === "draw",
commitToHistory: false,
};
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.editingLinearElement !== null ||
(!appState.draggingElement && appState.multiElement === null))) ||
!appState.draggingElement &&
appState.multiElement === null) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData }) => (

View File

@@ -1,193 +0,0 @@
import React from "react";
import { CODES, KEYS } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { UngroupIcon, GroupIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import {
getSelectedGroupIds,
selectGroup,
selectGroupsForSelectedElements,
getElementsInGroup,
addToGroup,
removeFromSelectedGroups,
isElementInGroup,
} from "../groups";
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
const groupIds = elements[0].groupIds;
for (const groupId of groupIds) {
if (
elements.reduce(
(acc, element) => acc && isElementInGroup(element, groupId),
true,
)
) {
return true;
}
}
}
return false;
};
const enableActionGroup = (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
return (
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
);
};
export const actionGroup = register({
name: "group",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, commitToHistory: false };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
if (selectedGroupIds.length === 1) {
const selectedGroupId = selectedGroupIds[0];
const elementIdsInGroup = new Set(
getElementsInGroup(elements, selectedGroupId).map(
(element) => element.id,
),
);
const selectedElementIds = new Set(
selectedElements.map((element) => element.id),
);
const combinedSet = new Set([
...Array.from(elementIdsInGroup),
...Array.from(selectedElementIds),
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, commitToHistory: false };
}
}
const newGroupId = randomId();
const updatedElements = elements.map((element) => {
if (!appState.selectedElementIds[element.id]) {
return element;
}
return newElementWith(element, {
groupIds: addToGroup(
element.groupIds,
newGroupId,
appState.editingGroupId,
),
});
});
// keep the z order within the group the same, but move them
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = updatedElements.lastIndexOf(
lastElementInGroup,
);
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = updatedElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
const updatedElementsInOrder = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
];
return {
appState: selectGroup(
newGroupId,
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(updatedElementsInOrder),
),
elements: updatedElementsInOrder,
commitToHistory: true,
};
},
contextMenuOrder: 4,
contextItemLabel: "labels.group",
contextItemPredicate: (elements, appState) =>
enableActionGroup(elements, appState),
keyTest: (event) =>
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.code === CODES.G,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
hidden={!enableActionGroup(elements, appState)}
type="button"
icon={<GroupIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.group")}${getShortcutKey("CtrlOrCmd+G")}`}
aria-label={t("labels.group")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
></ToolButton>
),
});
export const actionUngroup = register({
name: "ungroup",
perform: (elements, appState) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
}
const nextElements = elements.map((element) => {
const nextGroupIds = removeFromSelectedGroups(
element.groupIds,
appState.selectedGroupIds,
);
if (nextGroupIds.length === element.groupIds.length) {
return element;
}
return newElementWith(element, {
groupIds: nextGroupIds,
});
});
return {
appState: selectGroupsForSelectedElements(
{ ...appState, selectedGroupIds: {} },
getNonDeletedElements(nextElements),
),
elements: nextElements,
commitToHistory: true,
};
},
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,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
type="button"
hidden={getSelectedGroupIds(appState).length === 0}
icon={<UngroupIcon appearance={appState.appearance} />}
onClick={() => updateData(null)}
title={`${t("labels.ungroup")}${getShortcutKey("CtrlOrCmd+Shift+G")}`}
aria-label={t("labels.ungroup")}
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
></ToolButton>
),
});

View File

@@ -3,18 +3,20 @@ import React from "react";
import { undo, redo } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { SceneHistory, HistoryEntry } from "../history";
import { SceneHistory } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { getElementMap } from "../element";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
const writeData = (
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
updater: () => {
elements: ExcalidrawElement[];
appState: AppState;
} | null,
): ActionResult => {
const commitToHistory = false;
if (
@@ -31,29 +33,25 @@ const writeData = (
const prevElementMap = getElementMap(prevElements);
const nextElements = data.elements;
const nextElementMap = getElementMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
return {
elements,
elements: nextElements
.map((nextElement) =>
newElementWith(
prevElementMap[nextElement.id] || nextElement,
nextElement,
),
)
.concat(
prevElements
.filter(
(prevElement) => !nextElementMap.hasOwnProperty(prevElement.id),
)
.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
),
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
};
}
return { commitToHistory };

View File

@@ -5,9 +5,8 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
import { CODES, KEYS } from "../keys";
import { KEYS } from "../keys";
import { HelpIcon } from "../components/HelpIcon";
import { EVENT_DIALOG, trackEvent } from "../analytics";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@@ -66,13 +65,12 @@ export const actionFullScreen = register({
commitToHistory: false,
};
},
keyTest: (event) => event.code === CODES.F && !event[KEYS.CTRL_OR_CMD],
keyTest: (event) => event.keyCode === KEYS.F_KEY_CODE,
});
export const actionShortcuts = register({
name: "toggleShortcuts",
perform: (_elements, appState) => {
trackEvent(EVENT_DIALOG, "shortcuts");
return {
appState: {
...appState,

View File

@@ -1,60 +0,0 @@
import React from "react";
import { Avatar } from "../components/Avatar";
import { register } from "./register";
import { getClientColors, getClientInitials } from "../clients";
import { Collaborator } from "../types";
import { centerScrollOn } from "../scene/scroll";
import { EVENT_SHARE, trackEvent } from "../analytics";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
trackEvent(EVENT_SHARE, "go to collaborator");
if (!point) {
return { appState, commitToHistory: false };
}
return {
appState: {
...appState,
...centerScrollOn({
scenePoint: point,
viewportDimensions: {
width: appState.width,
height: appState.height,
},
zoom: appState.zoom,
}),
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
if (!clientId) {
return null;
}
const collaborator = appState.collaborators.get(clientId);
if (!collaborator) {
return null;
}
const { background, stroke } = getClientColors(clientId);
const shortName = getClientInitials(collaborator.username);
return (
<Avatar
color={background}
border={stroke}
onClick={() => updateData(collaborator.pointer)}
>
{shortName}
</Avatar>
);
},
});

View File

@@ -1,56 +1,25 @@
import React from "react";
import { getLanguage } from "../i18n";
import {
ExcalidrawElement,
ExcalidrawTextElement,
TextAlign,
FontFamily,
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types";
import {
getCommonAttributeOfSelectedElements,
isSomeElementSelected,
getTargetElements,
canChangeSharpness,
canHaveArrowheads,
} from "../scene";
import { ButtonSelect } from "../components/ButtonSelect";
import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { IconPicker } from "../components/IconPicker";
import {
isTextElement,
redrawTextBoundingBox,
getNonDeletedElements,
} from "../element";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
import { ColorPicker } from "../components/ColorPicker";
import { AppState } from "../../src/types";
import { t } from "../i18n";
import { DEFAULT_FONT } from "../appState";
import { register } from "./register";
import { newElementWith } from "../element/mutateElement";
import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../constants";
import { randomInteger } from "../random";
import {
FillHachureIcon,
FillCrossHatchIcon,
FillSolidIcon,
StrokeWidthIcon,
StrokeStyleSolidIcon,
StrokeStyleDashedIcon,
StrokeStyleDottedIcon,
EdgeSharpIcon,
EdgeRoundIcon,
SloppinessArchitectIcon,
SloppinessArtistIcon,
SloppinessCartoonistIcon,
ArrowheadArrowIcon,
ArrowheadBarIcon,
ArrowheadDotIcon,
ArrowheadNoneIcon,
} from "../components/icons";
import { EVENT_CHANGE, trackEvent } from "../analytics";
import colors from "../colors";
const changeProperty = (
elements: readonly ExcalidrawElement[],
@@ -92,15 +61,6 @@ const getFormValue = function <T>(
export const actionChangeStrokeColor = register({
name: "changeStrokeColor",
perform: (elements, appState, value) => {
if (value !== appState.currentItemStrokeColor) {
trackEvent(
EVENT_CHANGE,
"stroke color",
colors.elementStroke.includes(value)
? `${value} (picker ${colors.elementStroke.indexOf(value)})`
: value,
);
}
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -132,16 +92,6 @@ export const actionChangeStrokeColor = register({
export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor",
perform: (elements, appState, value) => {
if (value !== appState.currentItemBackgroundColor) {
trackEvent(
EVENT_CHANGE,
"background color",
colors.elementBackground.includes(value)
? `${value} (picker ${colors.elementBackground.indexOf(value)})`
: value,
);
}
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -173,7 +123,6 @@ export const actionChangeBackgroundColor = register({
export const actionChangeFillStyle = register({
name: "changeFillStyle",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "fill", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -187,23 +136,11 @@ export const actionChangeFillStyle = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.fill")}</legend>
<ButtonIconSelect
<ButtonSelect
options={[
{
value: "hachure",
text: t("labels.hachure"),
icon: <FillHachureIcon appearance={appState.appearance} />,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: <FillCrossHatchIcon appearance={appState.appearance} />,
},
{
value: "solid",
text: t("labels.solid"),
icon: <FillSolidIcon appearance={appState.appearance} />,
},
{ value: "hachure", text: t("labels.hachure") },
{ value: "cross-hatch", text: t("labels.crossHatch") },
{ value: "solid", text: t("labels.solid") },
]}
group="fill"
value={getFormValue(
@@ -223,7 +160,6 @@ export const actionChangeFillStyle = register({
export const actionChangeStrokeWidth = register({
name: "changeStrokeWidth",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "stroke", "width", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -237,39 +173,12 @@ export const actionChangeStrokeWidth = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.strokeWidth")}</legend>
<ButtonIconSelect
<ButtonSelect
group="stroke-width"
options={[
{
value: 1,
text: t("labels.thin"),
icon: (
<StrokeWidthIcon
appearance={appState.appearance}
strokeWidth={2}
/>
),
},
{
value: 2,
text: t("labels.bold"),
icon: (
<StrokeWidthIcon
appearance={appState.appearance}
strokeWidth={6}
/>
),
},
{
value: 4,
text: t("labels.extraBold"),
icon: (
<StrokeWidthIcon
appearance={appState.appearance}
strokeWidth={10}
/>
),
},
{ value: 1, text: t("labels.thin") },
{ value: 2, text: t("labels.bold") },
{ value: 4, text: t("labels.extraBold") },
]}
value={getFormValue(
elements,
@@ -286,11 +195,9 @@ export const actionChangeStrokeWidth = register({
export const actionChangeSloppiness = register({
name: "changeSloppiness",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "stroke", "sloppiness", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
seed: randomInteger(),
roughness: value,
}),
),
@@ -301,24 +208,12 @@ export const actionChangeSloppiness = register({
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.sloppiness")}</legend>
<ButtonIconSelect
<ButtonSelect
group="sloppiness"
options={[
{
value: 0,
text: t("labels.architect"),
icon: <SloppinessArchitectIcon appearance={appState.appearance} />,
},
{
value: 1,
text: t("labels.artist"),
icon: <SloppinessArtistIcon appearance={appState.appearance} />,
},
{
value: 2,
text: t("labels.cartoonist"),
icon: <SloppinessCartoonistIcon appearance={appState.appearance} />,
},
{ value: 0, text: t("labels.architect") },
{ value: 1, text: t("labels.artist") },
{ value: 2, text: t("labels.cartoonist") },
]}
value={getFormValue(
elements,
@@ -332,58 +227,9 @@ export const actionChangeSloppiness = register({
),
});
export const actionChangeStrokeStyle = register({
name: "changeStrokeStyle",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "style", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeStyle: value,
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.strokeStyle")}</legend>
<ButtonIconSelect
group="strokeStyle"
options={[
{
value: "solid",
text: t("labels.strokeStyle_solid"),
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
},
{
value: "dashed",
text: t("labels.strokeStyle_dashed"),
icon: <StrokeStyleDashedIcon appearance={appState.appearance} />,
},
{
value: "dotted",
text: t("labels.strokeStyle_dotted"),
icon: <StrokeStyleDottedIcon appearance={appState.appearance} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeStyle,
appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeOpacity = register({
name: "changeOpacity",
perform: (elements, appState, value) => {
trackEvent(EVENT_CHANGE, "opacity", "value", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
@@ -437,7 +283,7 @@ export const actionChangeFontSize = register({
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontSize: value,
font: `${value}px ${el.font.split("px ")[1]}`,
});
redrawTextBoundingBox(element);
return element;
@@ -447,7 +293,9 @@ export const actionChangeFontSize = register({
}),
appState: {
...appState,
currentItemFontSize: value,
currentItemFont: `${value}px ${
appState.currentItemFont.split("px ")[1]
}`,
},
commitToHistory: true,
};
@@ -466,8 +314,8 @@ export const actionChangeFontSize = register({
value={getFormValue(
elements,
appState,
(element) => isTextElement(element) && element.fontSize,
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
(element) => isTextElement(element) && +element.font.split("px ")[0],
+(appState.currentItemFont || DEFAULT_FONT).split("px ")[0],
)}
onChange={(value) => updateData(value)}
/>
@@ -482,7 +330,7 @@ export const actionChangeFontFamily = register({
elements: changeProperty(elements, appState, (el) => {
if (isTextElement(el)) {
const element: ExcalidrawTextElement = newElementWith(el, {
fontFamily: value,
font: `${el.font.split("px ")[0]}px ${value}`,
});
redrawTextBoundingBox(element);
return element;
@@ -492,35 +340,33 @@ export const actionChangeFontFamily = register({
}),
appState: {
...appState,
currentItemFontFamily: value,
currentItemFont: `${
appState.currentItemFont.split("px ")[0]
}px ${value}`,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const options: { value: FontFamily; text: string }[] = [
{ value: 1, text: t("labels.handDrawn") },
{ value: 2, text: t("labels.normal") },
{ value: 3, text: t("labels.code") },
];
return (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonSelect<FontFamily | false>
group="font-family"
options={options}
value={getFormValue(
elements,
appState,
(element) => isTextElement(element) && element.fontFamily,
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
);
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.fontFamily")}</legend>
<ButtonSelect
group="font-family"
options={[
{ value: "Virgil", text: t("labels.handDrawn") },
{ value: "Helvetica", text: t("labels.normal") },
{ value: "Cascadia", text: t("labels.code") },
]}
value={getFormValue(
elements,
appState,
(element) => isTextElement(element) && element.font.split("px ")[1],
(appState.currentItemFont || DEFAULT_FONT).split("px ")[1],
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeTextAlign = register({
@@ -566,232 +412,3 @@ export const actionChangeTextAlign = register({
</fieldset>
),
});
export const actionChangeSharpness = register({
name: "changeSharpness",
perform: (elements, appState, value) => {
const targetElements = getTargetElements(
getNonDeletedElements(elements),
appState,
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((el) => !isLinearElement(el))
: !isLinearElementType(appState.elementType);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType);
trackEvent(EVENT_CHANGE, "edge", value);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeSharpness: value,
}),
),
appState: {
...appState,
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
? value
: appState.currentItemStrokeSharpness,
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
? value
: appState.currentItemLinearStrokeSharpness,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonIconSelect
group="edges"
options={[
{
value: "sharp",
text: t("labels.sharp"),
icon: <EdgeSharpIcon appearance={appState.appearance} />,
},
{
value: "round",
text: t("labels.round"),
icon: <EdgeRoundIcon appearance={appState.appearance} />,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});
export const actionChangeArrowhead = register({
name: "changeArrowhead",
perform: (
elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
trackEvent(
EVENT_CHANGE,
`arrowhead ${value.position}`,
value.type || "none",
);
const { position, type } = value;
if (position === "start") {
const element: ExcalidrawLinearElement = newElementWith(el, {
startArrowhead: type,
});
return element;
} else if (position === "end") {
const element: ExcalidrawLinearElement = newElementWith(el, {
endArrowhead: type,
});
return element;
}
}
return el;
}),
appState: {
...appState,
[value.position === "start"
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
const isRTL = getLanguage().rtl;
return (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList">
<IconPicker
label="arrowhead_start"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
keyBinding: "q",
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
icon: (
<ArrowheadArrowIcon
appearance={appState.appearance}
flip={!isRTL}
/>
),
keyBinding: "w",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
icon: (
<ArrowheadBarIcon
appearance={appState.appearance}
flip={!isRTL}
/>
),
keyBinding: "e",
},
{
value: "dot",
text: t("labels.arrowhead_dot"),
icon: (
<ArrowheadDotIcon
appearance={appState.appearance}
flip={!isRTL}
/>
),
keyBinding: "r",
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemStartArrowhead,
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
/>
<IconPicker
label="arrowhead_end"
group="arrowheads"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: (
<ArrowheadArrowIcon
appearance={appState.appearance}
flip={isRTL}
/>
),
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: (
<ArrowheadBarIcon
appearance={appState.appearance}
flip={isRTL}
/>
),
},
{
value: "dot",
text: t("labels.arrowhead_dot"),
keyBinding: "r",
icon: (
<ArrowheadDotIcon
appearance={appState.appearance}
flip={isRTL}
/>
),
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemEndArrowhead,
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
/>
</div>
</fieldset>
);
},
});

View File

@@ -1,31 +1,22 @@
import { KEYS } from "../keys";
import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements } from "../element";
export const actionSelectAll = register({
name: "selectAll",
perform: (elements, appState) => {
if (appState.editingLinearElement) {
return false;
}
return {
appState: selectGroupsForSelectedElements(
{
...appState,
editingGroupId: null,
selectedElementIds: elements.reduce((map, element) => {
if (!element.isDeleted) {
map[element.id] = true;
}
return map;
}, {} as any),
},
getNonDeletedElements(elements),
),
appState: {
...appState,
selectedElementIds: elements.reduce((map, element) => {
if (!element.isDeleted) {
map[element.id] = true;
}
return map;
}, {} as any),
},
commitToHistory: true,
};
},
contextItemLabel: "labels.selectAll",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === "a",
});

View File

@@ -3,17 +3,12 @@ import {
isExcalidrawElement,
redrawTextBoundingBox,
} from "../element";
import { CODES, KEYS } from "../keys";
import { KEYS } from "../keys";
import { DEFAULT_FONT, DEFAULT_TEXT_ALIGN } from "../appState";
import { register } from "./register";
import { mutateElement, newElementWith } from "../element/mutateElement";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
@@ -28,7 +23,7 @@ export const actionCopyStyles = register({
},
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "C",
contextMenuOrder: 0,
});
@@ -46,15 +41,13 @@ export const actionPasteStyles = register({
backgroundColor: pastedElement?.backgroundColor,
strokeWidth: pastedElement?.strokeWidth,
strokeColor: pastedElement?.strokeColor,
strokeStyle: pastedElement?.strokeStyle,
fillStyle: pastedElement?.fillStyle,
opacity: pastedElement?.opacity,
roughness: pastedElement?.roughness,
});
if (isTextElement(newElement)) {
mutateElement(newElement, {
fontSize: pastedElement?.fontSize || DEFAULT_FONT_SIZE,
fontFamily: pastedElement?.fontFamily || DEFAULT_FONT_FAMILY,
font: pastedElement?.font || DEFAULT_FONT,
textAlign: pastedElement?.textAlign || DEFAULT_TEXT_ALIGN,
});
redrawTextBoundingBox(newElement);
@@ -68,6 +61,6 @@ export const actionPasteStyles = register({
},
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === "V",
contextMenuOrder: 1,
});

View File

@@ -5,22 +5,79 @@ import {
moveAllLeft,
moveAllRight,
} from "../zindex";
import { KEYS, isDarwin, CODES } from "../keys";
import { KEYS, isDarwin } from "../keys";
import { t } from "../i18n";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import {
SendBackwardIcon,
BringToFrontIcon,
SendToBackIcon,
BringForwardIcon,
sendBackward,
bringToFront,
sendToBack,
bringForward,
} from "../components/icons";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
function getElementIndices(
direction: "left" | "right",
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
const selectedIndices: number[] = [];
let deletedIndicesCache: number[] = [];
function cb(element: ExcalidrawElement, index: number) {
if (element.isDeleted) {
// we want to build an array of deleted elements that are preceeding
// a selected element so that we move them together
deletedIndicesCache.push(index);
} else {
if (appState.selectedElementIds[element.id]) {
selectedIndices.push(...deletedIndicesCache, index);
}
// always empty cache of deleted elements after either pushing a group
// of selected/deleted elements, of after encountering non-deleted elem
deletedIndicesCache = [];
}
}
// sending back → select contiguous deleted elements that are to the left of
// selected element(s)
if (direction === "left") {
let i = -1;
const len = elements.length;
while (++i < len) {
cb(elements[i], i);
}
// moving to front → loop from right to left so that we don't need to
// backtrack when gathering deleted elements
} else {
let i = elements.length;
while (--i > -1) {
cb(elements[i], i);
}
}
// sort in case we were gathering indexes from right to left
return selectedIndices.sort();
}
function moveElements(
func: typeof moveOneLeft,
elements: readonly ExcalidrawElement[],
appState: AppState,
) {
const _elements = elements.slice();
const direction =
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
const indices = getElementIndices(direction, _elements, appState);
return func(_elements, indices);
}
export const actionSendBackward = register({
name: "sendBackward",
perform: (elements, appState) => {
return {
elements: moveOneLeft(elements, appState),
elements: moveElements(moveOneLeft, elements, appState),
appState,
commitToHistory: true,
};
@@ -28,17 +85,15 @@ export const actionSendBackward = register({
contextItemLabel: "labels.sendBackward",
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketLeft",
PanelComponent: ({ updateData }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.sendBackward")}${getShortcutKey("CtrlOrCmd+[")}`}
>
<SendBackwardIcon appearance={appState.appearance} />
{sendBackward}
</button>
),
});
@@ -47,7 +102,7 @@ export const actionBringForward = register({
name: "bringForward",
perform: (elements, appState) => {
return {
elements: moveOneRight(elements, appState),
elements: moveElements(moveOneRight, elements, appState),
appState,
commitToHistory: true,
};
@@ -55,17 +110,15 @@ export const actionBringForward = register({
contextItemLabel: "labels.bringForward",
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
!event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
event[KEYS.CTRL_OR_CMD] && !event.shiftKey && event.code === "BracketRight",
PanelComponent: ({ updateData }) => (
<button
type="button"
className="zIndexButton"
onClick={() => updateData(null)}
title={`${t("labels.bringForward")}${getShortcutKey("CtrlOrCmd+]")}`}
>
<BringForwardIcon appearance={appState.appearance} />
{bringForward}
</button>
),
});
@@ -74,21 +127,20 @@ export const actionSendToBack = register({
name: "sendToBack",
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
elements: moveElements(moveAllLeft, elements, appState),
appState,
commitToHistory: true,
};
},
contextItemLabel: "labels.sendToBack",
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.code === CODES.BRACKET_LEFT
keyTest: (event) => {
return isDarwin
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketLeft"
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_LEFT,
PanelComponent: ({ updateData, appState }) => (
event.shiftKey &&
event.code === "BracketLeft";
},
PanelComponent: ({ updateData }) => (
<button
type="button"
className="zIndexButton"
@@ -99,7 +151,7 @@ export const actionSendToBack = register({
: getShortcutKey("CtrlOrCmd+Shift+[")
}`}
>
<SendToBackIcon appearance={appState.appearance} />
{sendToBack}
</button>
),
});
@@ -108,21 +160,20 @@ export const actionBringToFront = register({
name: "bringToFront",
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),
elements: moveElements(moveAllRight, elements, appState),
appState,
commitToHistory: true,
};
},
contextItemLabel: "labels.bringToFront",
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.code === CODES.BRACKET_RIGHT
keyTest: (event) => {
return isDarwin
? event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === "BracketRight"
: event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.code === CODES.BRACKET_RIGHT,
PanelComponent: ({ updateData, appState }) => (
event.shiftKey &&
event.code === "BracketRight";
},
PanelComponent: ({ updateData }) => (
<button
type="button"
className="zIndexButton"
@@ -133,7 +184,7 @@ export const actionBringToFront = register({
: getShortcutKey("CtrlOrCmd+Shift+]")
}`}
>
<BringToFrontIcon appearance={appState.appearance} />
{bringToFront}
</button>
),
});

View File

@@ -34,7 +34,6 @@ export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveScene,
actionSaveAsScene,
actionLoadScene,
} from "./actionExport";
@@ -45,23 +44,3 @@ export {
actionFullScreen,
actionShortcuts,
} from "./actionMenu";
export { actionGroup, actionUngroup } from "./actionGroup";
export { actionGoToCollaborator } from "./actionNavigate";
export { actionAddToLibrary } from "./actionAddToLibrary";
export {
actionAlignTop,
actionAlignBottom,
actionAlignLeft,
actionAlignRight,
actionAlignVerticallyCentered,
actionAlignHorizontallyCentered,
} from "./actionAlign";
export {
distributeHorizontally,
distributeVertically,
} from "./actionDistribute";

View File

@@ -5,36 +5,29 @@ import {
UpdaterFn,
ActionFilterFn,
ActionName,
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { t } from "../i18n";
import { ShortcutName } from "./shortcuts";
import { globalSceneState } from "../scene";
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
updater: UpdaterFn;
getAppState: () => Readonly<AppState>;
getAppState: () => AppState;
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
getElementsIncludingDeleted: () => ReturnType<
typeof globalSceneState["getElementsIncludingDeleted"]
>,
) {
this.updater = (actionResult) => {
if (actionResult && "then" in actionResult) {
actionResult.then((actionResult) => {
return updater(actionResult);
});
} else {
return updater(actionResult);
}
};
this.updater = updater;
this.getAppState = getAppState;
this.getElementsIncludingDeleted = getElementsIncludingDeleted;
}
@@ -89,22 +82,12 @@ export class ActionManager implements ActionsManagerInterface {
return Object.values(this.actions)
.filter(actionFilter)
.filter((action) => "contextItemLabel" in action)
.filter((action) =>
action.contextItemPredicate
? action.contextItemPredicate(
this.getElementsIncludingDeleted(),
this.getAppState(),
)
: true,
)
.sort(
(a, b) =>
(a.contextMenuOrder !== undefined ? a.contextMenuOrder : 999) -
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999),
)
.map((action) => ({
// take last bit of the label "labels.<shortcutName>"
shortcutName: action.contextItemLabel?.split(".").pop() as ShortcutName,
label: action.contextItemLabel ? t(action.contextItemLabel) : "",
action: () => {
this.updater(
@@ -118,11 +101,7 @@ export class ActionManager implements ActionsManagerInterface {
}));
}
// 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
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
renderAction = (name: ActionName) => {
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
@@ -141,7 +120,6 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
id={id}
/>
);
}

View File

@@ -2,7 +2,7 @@ import { Action } from "./types";
export let actions: readonly Action[] = [];
export const register = (action: Action): Action => {
export function register(action: Action): Action {
actions = actions.concat(action);
return action;
};
}

View File

@@ -1,63 +0,0 @@
import { t } from "../i18n";
import { isDarwin } from "../keys";
import { getShortcutKey } from "../utils";
export type ShortcutName =
| "cut"
| "copy"
| "paste"
| "copyStyles"
| "pasteStyles"
| "selectAll"
| "delete"
| "duplicateSelection"
| "sendBackward"
| "bringForward"
| "sendToBack"
| "bringToFront"
| "copyAsPng"
| "copyAsSvg"
| "group"
| "ungroup"
| "toggleGridMode"
| "toggleStats"
| "addToLibrary";
const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")],
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
selectAll: [getShortcutKey("CtrlOrCmd+A")],
delete: [getShortcutKey("Del")],
duplicateSelection: [
getShortcutKey("CtrlOrCmd+D"),
getShortcutKey(`Alt+${t("shortcutsDialog.drag")}`),
],
sendBackward: [getShortcutKey("CtrlOrCmd+[")],
bringForward: [getShortcutKey("CtrlOrCmd+]")],
sendToBack: [
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+[")
: getShortcutKey("CtrlOrCmd+Shift+["),
],
bringToFront: [
isDarwin
? getShortcutKey("CtrlOrCmd+Alt+]")
: getShortcutKey("CtrlOrCmd+Shift+]"),
],
copyAsPng: [getShortcutKey("Shift+Alt+C")],
copyAsSvg: [],
group: [getShortcutKey("CtrlOrCmd+G")],
ungroup: [getShortcutKey("CtrlOrCmd+Shift+G")],
toggleGridMode: [getShortcutKey("CtrlOrCmd+'")],
toggleStats: [],
addToLibrary: [],
};
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts availiable, take the first one
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};

View File

@@ -2,23 +2,19 @@ import React from "react";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
/** if false, the action should be prevented */
export type ActionResult =
| {
elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional<AppState, "offsetTop" | "offsetLeft"> | null;
commitToHistory: boolean;
syncHistory?: boolean;
}
| false;
export type ActionResult = {
elements?: readonly ExcalidrawElement[] | null;
appState?: AppState | null;
commitToHistory: boolean;
};
type ActionFn = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
appState: AppState,
formData: any,
) => ActionResult | Promise<ActionResult>;
) => ActionResult;
export type UpdaterFn = (res: ActionResult) => void;
export type UpdaterFn = (res: ActionResult, commitToHistory?: boolean) => void;
export type ActionFilterFn = (action: Action) => void;
export type ActionName =
@@ -34,8 +30,6 @@ export type ActionName =
| "changeFillStyle"
| "changeStrokeWidth"
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
@@ -45,10 +39,8 @@ export type ActionName =
| "finalize"
| "changeProjectName"
| "changeExportBackground"
| "changeExportEmbedScene"
| "changeShouldAddWatermark"
| "saveScene"
| "saveAsScene"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"
@@ -61,20 +53,7 @@ export type ActionName =
| "changeFontFamily"
| "changeTextAlign"
| "toggleFullScreen"
| "toggleShortcuts"
| "group"
| "ungroup"
| "goToCollaborator"
| "addToLibrary"
| "changeSharpness"
| "alignTop"
| "alignBottom"
| "alignLeft"
| "alignRight"
| "alignVerticallyCentered"
| "alignHorizontallyCentered"
| "distributeHorizontally"
| "distributeVertically";
| "toggleShortcuts";
export interface Action {
name: ActionName;
@@ -82,7 +61,6 @@ export interface Action {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
id?: string;
}>;
perform: ActionFn;
keyPriority?: number;
@@ -93,14 +71,12 @@ export interface Action {
) => boolean;
contextItemLabel?: string;
contextMenuOrder?: number;
contextItemPredicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => boolean;
}
export interface ActionsManagerInterface {
actions: Record<ActionName, Action>;
actions: {
[actionName in ActionName]: Action;
};
registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: (

View File

@@ -1,95 +0,0 @@
import { ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import { getCommonBounds } from "./element";
interface Box {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Alignment {
position: "start" | "center" | "end";
axis: "x" | "y";
}
export const alignElements = (
selectedElements: ExcalidrawElement[],
alignment: Alignment,
): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getMaximumGroups(selectedElements);
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
return groups.flatMap((group) => {
const translation = calculateTranslation(
group,
selectionBoundingBox,
alignment,
);
return group.map((element) =>
newElementWith(element, {
x: element.x + translation.x,
y: element.y + translation.y,
}),
);
});
};
export const getMaximumGroups = (
elements: ExcalidrawElement[],
): ExcalidrawElement[][] => {
const groups: Map<String, ExcalidrawElement[]> = new Map<
String,
ExcalidrawElement[]
>();
elements.forEach((element: ExcalidrawElement) => {
const groupId =
element.groupIds.length === 0
? element.id
: element.groupIds[element.groupIds.length - 1];
const currentGroupMembers = groups.get(groupId) || [];
groups.set(groupId, [...currentGroupMembers, element]);
});
return Array.from(groups.values());
};
const calculateTranslation = (
group: ExcalidrawElement[],
selectionBoundingBox: Box,
{ axis, position }: Alignment,
): { x: number; y: number } => {
const groupBoundingBox = getCommonBoundingBox(group);
const [min, max]: ["minX" | "minY", "maxX" | "maxY"] =
axis === "x" ? ["minX", "maxX"] : ["minY", "maxY"];
const noTranslation = { x: 0, y: 0 };
if (position === "start") {
return {
...noTranslation,
[axis]: selectionBoundingBox[min] - groupBoundingBox[min],
};
} else if (position === "end") {
return {
...noTranslation,
[axis]: selectionBoundingBox[max] - groupBoundingBox[max],
};
} // else if (position === "center") {
return {
...noTranslation,
[axis]:
(selectionBoundingBox[min] + selectionBoundingBox[max]) / 2 -
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
};
};
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return { minX, minY, maxX, maxY };
};

View File

@@ -1,24 +0,0 @@
export const EVENT_ACTION = "action";
export const EVENT_ALIGN = "align";
export const EVENT_CHANGE = "change";
export const EVENT_DIALOG = "dialog";
export const EVENT_EXIT = "exit";
export const EVENT_IO = "io";
export const EVENT_LAYER = "layer";
export const EVENT_LIBRARY = "library";
export const EVENT_LOAD = "load";
export const EVENT_SHAPE = "shape";
export const EVENT_SHARE = "share";
export const EVENT_MAGIC = "magic";
export const trackEvent = window.gtag
? (category: string, name: string, label?: string, value?: number) => {
window.gtag("event", name, {
event_category: category,
event_label: label,
value,
});
}
: (category: string, name: string, label?: string, value?: number) => {
console.info("Track Event", category, name, label, value);
};

View File

@@ -1,46 +1,31 @@
import oc from "open-color";
import { AppState, FlooredNumber, NormalizedZoomValue } from "./types";
import { AppState, FlooredNumber } from "./types";
import { getDateTime } from "./utils";
import { t } from "./i18n";
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "./constants";
export const getDefaultAppState = (): Omit<
AppState,
"offsetTop" | "offsetLeft"
> => {
export const DEFAULT_FONT = "20px Virgil";
export const DEFAULT_TEXT_ALIGN = "left";
export function getDefaultAppState(): AppState {
return {
appearance: "light",
isLoading: false,
errorMessage: null,
draggingElement: null,
resizingElement: null,
multiElement: null,
editingElement: null,
startBoundElement: null,
editingLinearElement: null,
elementType: "selection",
elementLocked: false,
exportBackground: true,
exportEmbedScene: false,
shouldAddWatermark: false,
currentItemStrokeColor: oc.black,
currentItemBackgroundColor: "transparent",
currentItemFillStyle: "hachure",
currentItemStrokeWidth: 1,
currentItemStrokeStyle: "solid",
currentItemRoughness: 1,
currentItemOpacity: 100,
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemFont: DEFAULT_FONT,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentItemStrokeSharpness: "sharp",
currentItemLinearStrokeSharpness: "round",
currentItemStartArrowhead: null,
currentItemEndArrowhead: "arrow",
viewBackgroundColor: oc.white,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
@@ -49,140 +34,63 @@ export const getDefaultAppState = (): Omit<
cursorButton: "up",
scrolledOutside: false,
name: `${t("labels.untitled")}-${getDateTime()}`,
isBindingEnabled: true,
username: "",
isCollaborating: false,
isResizing: false,
isRotating: false,
selectionElement: null,
zoom: {
value: 1 as NormalizedZoomValue,
translation: { x: 0, y: 0 },
},
zoom: 1,
openMenu: null,
lastPointerDownWith: "mouse",
selectedElementIds: {},
previousSelectedElementIds: {},
collaborators: new Map(),
shouldCacheIgnoreZoom: false,
showShortcutsDialog: false,
suggestedBindings: [],
zenModeEnabled: false,
gridSize: null,
editingGroupId: null,
selectedGroupIds: {},
width: window.innerWidth,
height: window.innerHeight,
isLibraryOpen: false,
fileHandle: null,
collaborators: new Map(),
showStats: false,
};
};
}
/**
* Config containing all AppState keys. Used to determine whether given state
* prop should be stripped when exporting to given storage type.
*/
const APP_STATE_STORAGE_CONF = (<
Values extends {
/** whether to keep when storing to browser storage (localStorage/IDB) */
browser: boolean;
/** whether to keep when exporting to file/database */
export: boolean;
},
T extends Record<keyof AppState, Values>
>(
config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
) => config)({
appearance: { browser: true, export: false },
currentItemBackgroundColor: { browser: true, export: false },
currentItemFillStyle: { browser: true, export: false },
currentItemFontFamily: { browser: true, export: false },
currentItemFontSize: { browser: true, export: false },
currentItemOpacity: { browser: true, export: false },
currentItemRoughness: { browser: true, export: false },
currentItemStrokeColor: { browser: true, export: false },
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemStartArrowhead: { browser: true, export: false },
currentItemEndArrowhead: { browser: true, export: false },
cursorButton: { browser: true, export: false },
cursorX: { browser: true, export: false },
cursorY: { browser: true, export: false },
draggingElement: { browser: false, export: false },
editingElement: { browser: false, export: false },
startBoundElement: { browser: false, export: false },
editingGroupId: { browser: true, export: false },
editingLinearElement: { browser: false, export: false },
elementLocked: { browser: true, export: false },
elementType: { browser: true, export: false },
errorMessage: { browser: false, export: false },
exportBackground: { browser: true, export: false },
exportEmbedScene: { browser: true, export: false },
gridSize: { browser: true, export: true },
height: { browser: false, export: false },
isBindingEnabled: { browser: false, export: false },
isLibraryOpen: { browser: false, export: false },
isLoading: { browser: false, export: false },
isResizing: { browser: false, export: false },
isRotating: { browser: false, export: false },
lastPointerDownWith: { browser: true, export: false },
multiElement: { browser: false, export: false },
name: { browser: true, export: false },
openMenu: { browser: true, export: false },
previousSelectedElementIds: { browser: true, export: false },
resizingElement: { browser: false, export: false },
scrolledOutside: { browser: true, export: false },
scrollX: { browser: true, export: false },
scrollY: { browser: true, export: false },
selectedElementIds: { browser: true, export: false },
selectedGroupIds: { browser: true, export: false },
selectionElement: { browser: false, export: false },
shouldAddWatermark: { browser: true, export: false },
shouldCacheIgnoreZoom: { browser: true, export: false },
showShortcutsDialog: { browser: false, export: false },
suggestedBindings: { browser: false, export: false },
viewBackgroundColor: { browser: true, export: true },
width: { browser: false, export: false },
zenModeEnabled: { browser: true, export: false },
zoom: { browser: true, export: false },
offsetTop: { browser: false, export: false },
offsetLeft: { browser: false, export: false },
fileHandle: { browser: false, export: false },
collaborators: { browser: false, export: false },
showStats: { browser: true, export: false },
});
export function clearAppStateForLocalStorage(appState: AppState) {
const {
draggingElement,
resizingElement,
multiElement,
editingElement,
selectionElement,
isResizing,
isRotating,
collaborators,
isCollaborating,
isLoading,
errorMessage,
showShortcutsDialog,
...exportedState
} = appState;
return exportedState;
}
const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
appState: Partial<AppState>,
exportType: ExportType,
) => {
type ExportableKeys = {
[K in keyof typeof APP_STATE_STORAGE_CONF]: typeof APP_STATE_STORAGE_CONF[K][ExportType] extends true
? K
: never;
}[keyof typeof APP_STATE_STORAGE_CONF];
const stateForExport = {} as { [K in ExportableKeys]?: typeof appState[K] };
for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
const propConfig = APP_STATE_STORAGE_CONF[key];
if (!propConfig) {
console.error(
`_clearAppStateForStorage: appState key "${key}" config doesn't exist for "${exportType}" export type`,
);
}
if (propConfig?.[exportType]) {
// @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
stateForExport[key] = appState[key];
}
}
return stateForExport;
};
export function clearAppStatePropertiesForHistory(
appState: AppState,
): Partial<AppState> {
return {
selectedElementIds: appState.selectedElementIds,
exportBackground: appState.exportBackground,
shouldAddWatermark: appState.shouldAddWatermark,
currentItemStrokeColor: appState.currentItemStrokeColor,
currentItemBackgroundColor: appState.currentItemBackgroundColor,
currentItemFillStyle: appState.currentItemFillStyle,
currentItemStrokeWidth: appState.currentItemStrokeWidth,
currentItemRoughness: appState.currentItemRoughness,
currentItemOpacity: appState.currentItemOpacity,
currentItemFont: appState.currentItemFont,
currentItemTextAlign: appState.currentItemTextAlign,
viewBackgroundColor: appState.viewBackgroundColor,
name: appState.name,
};
}
export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "browser");
};
export const cleanAppStateForExport = (appState: Partial<AppState>) => {
return _clearAppStateForStorage(appState, "export");
};
export function cleanAppStateForExport(appState: AppState) {
return {
viewBackgroundColor: appState.viewBackgroundColor,
};
}

View File

@@ -1,279 +0,0 @@
import { EVENT_MAGIC, trackEvent } from "./analytics";
import colors from "./colors";
import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from "./constants";
import { newElement, newTextElement, newLinearElement } from "./element";
import { ExcalidrawElement } from "./element/types";
import { randomId } from "./random";
const BAR_WIDTH = 32;
const BAR_GAP = 12;
const BAR_HEIGHT = 256;
export interface Spreadsheet {
title: string | null;
labels: string[] | null;
values: number[];
}
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
type ParseSpreadsheetResult =
| { type: typeof NOT_SPREADSHEET }
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
const tryParseNumber = (s: string): number | null => {
const match = /^[$€£¥₩]?([0-9]+(\.[0-9]+)?)$/.exec(s);
if (!match) {
return null;
}
return parseFloat(match[1]);
};
const isNumericColumn = (lines: string[][], columnIndex: number) =>
lines.slice(1).every((line) => tryParseNumber(line[columnIndex]) !== null);
const tryParseCells = (cells: string[][]): ParseSpreadsheetResult => {
const numCols = cells[0].length;
if (numCols > 2) {
return { type: NOT_SPREADSHEET };
}
if (numCols === 1) {
if (!isNumericColumn(cells, 0)) {
return { type: NOT_SPREADSHEET };
}
const hasHeader = tryParseNumber(cells[0][0]) === null;
const values = (hasHeader ? cells.slice(1) : cells).map((line) =>
tryParseNumber(line[0]),
);
if (values.length < 2) {
return { type: NOT_SPREADSHEET };
}
return {
type: VALID_SPREADSHEET,
spreadsheet: {
title: hasHeader ? cells[0][0] : null,
labels: null,
values: values as number[],
},
};
}
const valueColumnIndex = isNumericColumn(cells, 0) ? 0 : 1;
if (!isNumericColumn(cells, valueColumnIndex)) {
return { type: NOT_SPREADSHEET };
}
const labelColumnIndex = (valueColumnIndex + 1) % 2;
const hasHeader = tryParseNumber(cells[0][valueColumnIndex]) === null;
const rows = hasHeader ? cells.slice(1) : cells;
if (rows.length < 2) {
return { type: NOT_SPREADSHEET };
}
return {
type: VALID_SPREADSHEET,
spreadsheet: {
title: hasHeader ? cells[0][valueColumnIndex] : null,
labels: rows.map((row) => row[labelColumnIndex]),
values: rows.map((row) => tryParseNumber(row[valueColumnIndex])!),
},
};
};
const transposeCells = (cells: string[][]) => {
const nextCells: string[][] = [];
for (let col = 0; col < cells[0].length; col++) {
const nextCellRow: string[] = [];
for (let row = 0; row < cells.length; row++) {
nextCellRow.push(cells[row][col]);
}
nextCells.push(nextCellRow);
}
return nextCells;
};
export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
// Copy/paste from excel, spreadhseets, tsv, csv.
// For now we only accept 2 columns with an optional header
// Check for tab separeted values
let lines = text
.trim()
.split("\n")
.map((line) => line.trim().split("\t"));
// Check for comma separeted files
if (lines.length && lines[0].length !== 2) {
lines = text
.trim()
.split("\n")
.map((line) => line.trim().split(","));
}
if (lines.length === 0) {
return { type: NOT_SPREADSHEET };
}
const numColsFirstLine = lines[0].length;
const isSpreadsheet = lines.every((line) => line.length === numColsFirstLine);
if (!isSpreadsheet) {
return { type: NOT_SPREADSHEET };
}
const result = tryParseCells(lines);
if (result.type !== VALID_SPREADSHEET) {
const transposedResults = tryParseCells(transposeCells(lines));
if (transposedResults.type === VALID_SPREADSHEET) {
return transposedResults;
}
}
return result;
};
// For the maths behind it https://excalidraw.com/#json=6320864370884608,O_5xfD-Agh32tytHpRJx1g
export const renderSpreadsheet = (
spreadsheet: Spreadsheet,
x: number,
y: number,
): ExcalidrawElement[] => {
const values = spreadsheet.values;
const max = Math.max(...values);
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
const chartWidth = (BAR_WIDTH + BAR_GAP) * values.length + BAR_GAP;
const maxColors = colors.elementBackground.length;
const bgColors = colors.elementBackground.slice(2, maxColors);
// Put all the common properties here so when the whole chart is selected
// the properties dialog shows the correct selected values
const commonProps = {
backgroundColor: bgColors[Math.floor(Math.random() * bgColors.length)],
fillStyle: "hachure",
fontFamily: DEFAULT_FONT_FAMILY,
fontSize: DEFAULT_FONT_SIZE,
groupIds: [randomId()],
opacity: 100,
roughness: 1,
strokeColor: colors.elementStroke[0],
strokeSharpness: "sharp",
strokeStyle: "solid",
strokeWidth: 1,
verticalAlign: "middle",
} as const;
const minYLabel = newTextElement({
...commonProps,
x: x - BAR_GAP,
y: y - BAR_GAP,
text: "0",
textAlign: "right",
});
const maxYLabel = newTextElement({
...commonProps,
x: x - BAR_GAP,
y: y - BAR_HEIGHT - minYLabel.height / 2,
text: max.toLocaleString(),
textAlign: "right",
});
const xAxisLine = newLinearElement({
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
points: [
[0, 0],
[chartWidth, 0],
],
...commonProps,
});
const yAxisLine = newLinearElement({
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
points: [
[0, 0],
[0, -chartHeight],
],
...commonProps,
});
const maxValueLine = newLinearElement({
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
...commonProps,
strokeStyle: "dotted",
points: [
[0, 0],
[chartWidth, 0],
],
});
const bars = values.map((value, index) => {
const barHeight = (value / max) * BAR_HEIGHT;
return newElement({
...commonProps,
type: "rectangle",
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP,
y: y - barHeight - BAR_GAP,
width: BAR_WIDTH,
height: barHeight,
});
});
const xLabels =
spreadsheet.labels?.map((label, index) => {
return newTextElement({
...commonProps,
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
y: y + BAR_GAP / 2,
width: BAR_WIDTH,
angle: 5.87,
fontSize: 16,
textAlign: "center",
verticalAlign: "top",
});
}) || [];
const title = spreadsheet.title
? newTextElement({
...commonProps,
text: spreadsheet.title,
x: x + chartWidth / 2,
y: y - BAR_HEIGHT - BAR_GAP * 2 - maxYLabel.height,
strokeSharpness: "sharp",
strokeStyle: "solid",
textAlign: "center",
})
: null;
trackEvent(EVENT_MAGIC, "chart", "bars", bars.length);
return [
title,
...bars,
...xLabels,
xAxisLine,
yAxisLine,
maxValueLine,
minYLabel,
maxYLabel,
].filter((element) => element !== null) as ExcalidrawElement[];
};

View File

@@ -1,30 +0,0 @@
import colors from "./colors";
export const getClientColors = (clientId: string) => {
// Naive way of getting an integer out of the clientId
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
// Skip transparent background.
const backgrounds = colors.elementBackground.slice(1);
const strokes = colors.elementStroke.slice(1);
return {
background: backgrounds[sum % backgrounds.length],
stroke: strokes[sum % strokes.length],
};
};
export const getClientInitials = (username?: string | null) => {
if (!username) {
return "?";
}
const names = username.trim().split(" ");
if (names.length < 2) {
return names[0].substring(0, 2).toUpperCase();
}
const firstName = names[0];
const lastName = names[names.length - 1];
return (firstName[0] + lastName[0]).toUpperCase();
};

View File

@@ -5,16 +5,6 @@ import {
import { getSelectedElements } from "./scene";
import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { canvasToBlob } from "./data/blob";
const TYPE_ELEMENTS = "excalidraw/elements";
type ElementsClipboard = {
type: typeof TYPE_ELEMENTS;
created: number;
elements: ExcalidrawElement[];
};
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
@@ -31,139 +21,104 @@ export const probablySupportsClipboardBlob =
"ClipboardItem" in window &&
"toBlob" in HTMLCanvasElement.prototype;
const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
if (contents?.type === TYPE_ELEMENTS) {
return true;
}
return false;
};
export const copyToClipboard = async (
export async function copyToAppClipboard(
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const contents: ElementsClipboard = {
type: TYPE_ELEMENTS,
created: Date.now(),
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
) {
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
try {
// when copying to in-app clipboard, clear system clipboard so that if
// system clip contains text on paste we know it was copied *after* user
// copied elements, and thus we should prefer the text content.
await copyTextToSystemClipboard(null);
PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json);
} catch (error) {
} catch {
// if clearing system clipboard didn't work, we should prefer in-app
// clipboard even if there's text in system clipboard on paste, because
// we can't be sure of the order of copy operations
PREFER_APP_CLIPBOARD = true;
console.error(error);
}
};
}
const getAppClipboard = (): Partial<ElementsClipboard> => {
export function getAppClipboard(): {
elements?: readonly ExcalidrawElement[];
} {
if (!CLIPBOARD) {
return {};
}
try {
return JSON.parse(CLIPBOARD);
const clipboardElements = JSON.parse(CLIPBOARD);
if (
Array.isArray(clipboardElements) &&
clipboardElements.length > 0 &&
clipboardElements[0].type // need to implement a better check here...
) {
return { elements: clipboardElements };
}
} catch (error) {
console.error(error);
return {};
}
};
const parsePotentialSpreadsheet = (
text: string,
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
const result = tryParseSpreadsheet(text);
if (result.type === VALID_SPREADSHEET) {
return { spreadsheet: result.spreadsheet };
}
return null;
};
return {};
}
/**
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
const getSystemClipboard = async (
export async function getClipboardContent(
event: ClipboardEvent | null,
): Promise<string> => {
): Promise<{
text?: string;
elements?: readonly ExcalidrawElement[];
}> {
try {
const text = event
? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return text || "";
} catch {
return "";
}
};
/**
* Attemps to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
return getAppClipboard();
}
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try {
const systemClipboardData = JSON.parse(systemClipboard);
// system clipboard elements are newer than in-app clipboard
if (
isElementsClipboard(systemClipboardData) &&
(!appClipboardData?.created ||
appClipboardData.created < systemClipboardData.created)
) {
return { elements: systemClipboardData.elements };
if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
return { text };
}
// in-app clipboard is newer than system clipboard
return appClipboardData;
} catch {
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? appClipboardData
: { text: systemClipboard };
} catch (error) {
console.error(error);
}
};
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
};
return getAppClipboard();
}
export const copyTextToSystemClipboard = async (text: string | null) => {
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
return new Promise((resolve, reject) => {
try {
canvas.toBlob(async function (blob: any) {
try {
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
resolve();
} catch (error) {
reject(error);
}
});
} catch (error) {
reject(error);
}
});
}
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
try {
await navigator.clipboard.writeText(svgroot.outerHTML);
} catch (error) {
console.error(error);
}
}
export async function copyTextToSystemClipboard(text: string | null) {
let copied = false;
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
// not focused
await navigator.clipboard.writeText(text || "");
copied = true;
} catch (error) {
@@ -172,14 +127,14 @@ export const copyTextToSystemClipboard = async (text: string | null) => {
}
// Note that execCommand doesn't allow copying empty strings, so if we're
// clearing clipboard using this API, we must copy at least an empty char
// clearing clipboard using this API, we must copy at least an empty char
if (!copied && !copyTextViaExecCommand(text || " ")) {
throw new Error("couldn't copy");
}
};
}
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string) => {
function copyTextViaExecCommand(text: string) {
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea");
@@ -213,4 +168,4 @@ const copyTextViaExecCommand = (text: string) => {
textarea.remove();
return success;
};
}

View File

@@ -1,18 +1,18 @@
import oc from "open-color";
const shades = (index: number) => [
oc.red[index],
oc.pink[index],
oc.grape[index],
oc.violet[index],
oc.indigo[index],
oc.blue[index],
oc.cyan[index],
oc.teal[index],
oc.green[index],
oc.lime[index],
oc.yellow[index],
oc.orange[index],
const shades = (i: number) => [
oc.red[i],
oc.pink[i],
oc.grape[i],
oc.violet[i],
oc.indigo[i],
oc.blue[i],
oc.cyan[i],
oc.teal[i],
oc.green[i],
oc.lime[i],
oc.yellow[i],
oc.orange[i],
];
export default {

View File

@@ -1,25 +1,17 @@
import React from "react";
import { AppState, Zoom } from "../types";
import { AppState } from "../types";
import { ExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import {
hasBackground,
hasStroke,
canChangeSharpness,
hasText,
canHaveArrowheads,
getTargetElements,
} from "../scene";
import { hasBackground, hasStroke, hasText, getTargetElement } from "../scene";
import { t } from "../i18n";
import { SHAPES } from "../shapes";
import { ToolButton } from "./ToolButton";
import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
import { capitalizeString, setCursorForShape } from "../utils";
import Stack from "./Stack";
import useIsMobile from "../is-mobile";
import { getNonDeletedElements } from "../element";
import { trackEvent, EVENT_SHAPE, EVENT_DIALOG } from "../analytics";
export const SelectedShapeActions = ({
export function SelectedShapeActions({
appState,
elements,
renderAction,
@@ -29,45 +21,35 @@ export const SelectedShapeActions = ({
elements: readonly ExcalidrawElement[];
renderAction: ActionManager["renderAction"];
elementType: ExcalidrawElement["type"];
}) => {
const targetElements = getTargetElements(
}) {
const targetElements = getTargetElement(
getNonDeletedElements(elements),
appState,
);
const isEditing = Boolean(appState.editingElement);
const isMobile = useIsMobile();
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
hasBackground(elementType) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showChangeBackgroundIcons =
hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type));
return (
<div className="panelColumn">
{renderAction("changeStrokeColor")}
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
{showFillIcons && renderAction("changeFillStyle")}
{(hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type))) && (
<>
{renderAction("changeBackgroundColor")}
{renderAction("changeFillStyle")}
</>
)}
{(hasStroke(elementType) ||
targetElements.some((element) => hasStroke(element.type))) && (
<>
{renderAction("changeStrokeWidth")}
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
)}
{(canChangeSharpness(elementType) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(elementType) ||
targetElements.some((element) => hasText(element.type))) && (
<>
@@ -79,11 +61,6 @@ export const SelectedShapeActions = ({
</>
)}
{(canHaveArrowheads(elementType) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
)}
{renderAction("changeOpacity")}
<fieldset>
@@ -95,138 +72,76 @@ export const SelectedShapeActions = ({
{renderAction("bringForward")}
</div>
</fieldset>
{targetElements.length > 1 && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
{
// swap this order for RTL so the button positions always match their action
// (i.e. the leftmost button aligns left)
}
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{targetElements.length > 2 &&
renderAction("distributeHorizontally")}
<div className="iconRow">
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{targetElements.length > 2 &&
renderAction("distributeVertically")}
</div>
</div>
</fieldset>
)}
{!isMobile && !isEditing && targetElements.length > 0 && (
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("duplicateSelection")}
{renderAction("deleteSelectedElements")}
{renderAction("group")}
{renderAction("ungroup")}
</div>
</fieldset>
)}
</div>
);
};
}
const LIBRARY_ICON = (
// fa-th-large
<svg viewBox="0 0 512 512">
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
</svg>
);
export const ShapesSwitcher = ({
export function ShapesSwitcher({
elementType,
setAppState,
isLibraryOpen,
}: {
elementType: ExcalidrawElement["type"];
setAppState: React.Component<any, AppState>["setState"];
isLibraryOpen: boolean;
}) => (
<>
{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}`;
return (
<ToolButton
className="Shape"
key={value}
type="radio"
icon={icon}
checked={elementType === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={value}
onChange={() => {
trackEvent(EVENT_SHAPE, value, "toolbar");
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(value);
setAppState({});
}}
/>
);
})}
<ToolButton
className="Shape ToolIcon_type_button__library"
type="button"
icon={LIBRARY_ICON}
name="editor-library"
keyBindingLabel="9"
aria-keyshortcuts="9"
title={`${capitalizeString(t("toolBar.library"))} — 9`}
aria-label={capitalizeString(t("toolBar.library"))}
onClick={() => {
if (!isLibraryOpen) {
trackEvent(EVENT_DIALOG, "library");
}
setAppState({ isLibraryOpen: !isLibraryOpen });
}}
/>
</>
);
setAppState: any;
}) {
return (
<>
{SHAPES.map(({ value, icon, key }, index) => {
const label = t(`toolBar.${value}`);
const shortcut = `${capitalizeString(key)} ${t("shortcutsDialog.or")} ${
index + 1
}`;
return (
<ToolButton
key={value}
type="radio"
icon={icon}
checked={elementType === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={`${index + 1}`}
aria-label={capitalizeString(label)}
aria-keyshortcuts={`${key} ${index + 1}`}
data-testid={value}
onChange={() => {
setAppState({
elementType: value,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(value);
setAppState({});
}}
></ToolButton>
);
})}
</>
);
}
export const ZoomActions = ({
export function ZoomActions({
renderAction,
zoom,
}: {
renderAction: ActionManager["renderAction"];
zoom: Zoom;
}) => (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);
zoom: number;
}) {
return (
<Stack.Col gap={1}>
<Stack.Row gap={1} align="center">
{renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
</Stack.Row>
</Stack.Col>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +0,0 @@
@import "../css/_variables";
.excalidraw {
.Avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 1.25rem;
display: flex;
justify-content: center;
align-items: center;
color: $oc-white;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
}
}

View File

@@ -1,20 +0,0 @@
import "./Avatar.scss";
import React from "react";
type AvatarProps = {
children: string;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
color: string;
border: string;
};
export const Avatar = ({ children, color, border, onClick }: AvatarProps) => (
<div
className="Avatar"
style={{ background: color, border: `1px solid ${border}` }}
onClick={onClick}
>
{children}
</div>
);

View File

@@ -1,29 +0,0 @@
import React from "react";
import { ActionManager } from "../actions/manager";
import { EVENT_CHANGE, trackEvent } from "../analytics";
import { AppState } from "../types";
import { DarkModeToggle } from "./DarkModeToggle";
export const BackgroundPickerAndDarkModeToggle = ({
appState,
setAppState,
actionManager,
}: {
actionManager: ActionManager;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}) => (
<div style={{ display: "flex" }}>
{actionManager.renderAction("changeViewBackgroundColor")}
<div style={{ marginInlineStart: "0.25rem" }}>
<DarkModeToggle
value={appState.appearance}
onChange={(appearance) => {
// TODO: track the theme on the first load too
trackEvent(EVENT_CHANGE, "theme", appearance);
setAppState({ appearance });
}}
/>
</div>
</div>
);

View File

@@ -1,29 +0,0 @@
import React from "react";
import clsx from "clsx";
export const ButtonIconCycle = <T extends any>({
options,
value,
onChange,
group,
}: {
options: { value: T; text: string; icon: JSX.Element }[];
value: T | null;
onChange: (value: T) => void;
group: string;
}) => {
const current = options.find((op) => op.value === value);
function cycle() {
const index = options.indexOf(current!);
const next = (index + 1) % options.length;
onChange(options[next].value);
}
return (
<label key={group} className={clsx({ active: current!.value !== null })}>
<input type="button" name={group} onClick={cycle} />
{current!.icon}
</label>
);
};

View File

@@ -1,33 +0,0 @@
import React from "react";
import clsx from "clsx";
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
export const ButtonIconSelect = <T extends Object>({
options,
value,
onChange,
group,
}: {
options: { value: T; text: string; icon: JSX.Element }[];
value: T | null;
onChange: (value: T) => void;
group: string;
}) => (
<div className="buttonList buttonListIcon">
{options.map((option) => (
<label
key={option.text}
className={clsx({ active: value === option.value })}
title={option.text}
>
<input
type="radio"
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
/>
{option.icon}
</label>
))}
</div>
);

View File

@@ -1,7 +1,6 @@
import React from "react";
import clsx from "clsx";
export const ButtonSelect = <T extends Object>({
export function ButtonSelect<T>({
options,
value,
onChange,
@@ -11,21 +10,23 @@ export const ButtonSelect = <T extends Object>({
value: T | null;
onChange: (value: T) => void;
group: string;
}) => (
<div className="buttonList">
{options.map((option) => (
<label
key={option.text}
className={clsx({ active: value === option.value })}
>
<input
type="radio"
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value}
/>
{option.text}
</label>
))}
</div>
);
}) {
return (
<div className="buttonList">
{options.map((option) => (
<label
key={option.text}
className={value === option.value ? "active" : ""}
>
<input
type="radio"
name={group}
onChange={() => onChange(option.value)}
checked={value === option.value ? true : false}
/>
{option.text}
</label>
))}
</div>
);
}

View File

@@ -1,29 +0,0 @@
@import "../css/_variables";
.excalidraw {
.CollabButton.is-collaborating {
background-color: var(--button-special-active-background-color);
.ToolIcon__icon svg {
color: var(--icon-green-fill-color);
}
}
.CollabButton-collaborators {
:root[dir="ltr"] & {
right: -5px;
}
:root[dir="rtl"] & {
left: -5px;
}
min-width: 1em;
position: absolute;
bottom: -5px;
padding: 3px;
border-radius: 50%;
background-color: $oc-green-6;
color: $oc-white;
font-size: 0.7em;
font-family: var(--ui-font);
}
}

View File

@@ -1,44 +0,0 @@
import React from "react";
import clsx from "clsx";
import { ToolButton } from "./ToolButton";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { users } from "./icons";
import "./CollabButton.scss";
import { EVENT_DIALOG, trackEvent } from "../analytics";
const CollabButton = ({
isCollaborating,
collaboratorCount,
onClick,
}: {
isCollaborating: boolean;
collaboratorCount: number;
onClick: () => void;
}) => {
return (
<>
<ToolButton
className={clsx("CollabButton", {
"is-collaborating": isCollaborating,
})}
onClick={() => {
trackEvent(EVENT_DIALOG, "collaboration");
onClick();
}}
icon={users}
type="button"
title={t("buttons.roomDialog")}
aria-label={t("buttons.roomDialog")}
showAriaLabel={useIsMobile()}
>
{collaboratorCount > 0 && (
<div className="CollabButton-collaborators">{collaboratorCount}</div>
)}
</ToolButton>
</>
);
};
export default CollabButton;

View File

@@ -1,250 +1,191 @@
@import "../css/_variables";
@import "open-color/open-color.scss";
.excalidraw {
.color-picker {
background: var(--popup-background-color);
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
border-radius: 4px;
position: absolute;
:root[dir="ltr"] & {
left: -5.5px;
}
:root[dir="rtl"] & {
right: -5.5px;
}
.color-picker {
background: $oc-white;
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
border-radius: 4px;
position: absolute;
:root[dir="ltr"] & {
left: -5.5px;
}
.color-picker-control-container {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
}
.color-picker-triangle {
width: 0px;
height: 0px;
border-style: solid;
border-width: 0px 9px 10px;
border-color: transparent transparent var(--popup-background-color);
position: absolute;
top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
}
.color-picker-triangle-shadow {
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -11px;
}
.color-picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(5, auto);
grid-gap: 0.5rem;
border-radius: 4px;
&:focus {
outline: none;
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
}
.color-picker-content .color-input-container {
grid-column: 1 / span 5;
}
.color-picker-swatch {
position: relative;
height: 1.875rem;
width: 1.875rem;
cursor: pointer;
border-radius: 4px;
margin: 0;
box-sizing: border-box;
border: 1px solid #ddd;
background-color: currentColor !important;
filter: var(--appearance-filter);
&:focus {
/* TODO: only show the border when the color is too light to see as a shadow */
box-shadow: 0 0 4px 1px currentColor;
border-color: var(--select-highlight-color);
}
}
.color-picker-transparent {
border-radius: 4px;
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
.color-picker-transparent,
.color-picker-label-swatch {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
}
.color-picker-hash {
background: var(--input-border-color);
height: 1.875rem;
width: 1.875rem;
:root[dir="ltr"] & {
border-radius: 4px 0px 0px 4px;
}
:root[dir="rtl"] & {
border-radius: 0px 4px 4px 0px;
}
color: var(--input-label-color);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.color-input-container:focus-within .color-picker-hash {
box-shadow: 0 0 0 2px var(--focus-highlight-color);
}
.color-input-container:focus-within .color-picker-hash::before,
.color-input-container:focus-within .color-picker-hash::after {
content: "";
width: 1px;
height: 100%;
position: absolute;
top: 0;
}
.color-input-container:focus-within .color-picker-hash::before {
background: var(--input-border-color);
:root[dir="ltr"] & {
right: -1px;
}
:root[dir="rtl"] & {
left: -1px;
}
}
.color-input-container:focus-within .color-picker-hash::after {
background: var(--input-background-color);
:root[dir="ltr"] & {
right: -2px;
}
:root[dir="rtl"] & {
left: -2px;
}
}
.color-input-container {
display: flex;
}
.color-picker-input {
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
background-color: var(--input-background-color);
color: var(--text-color-primary);
border: 0px;
outline: none;
height: 1.75em;
box-shadow: var(--input-border-color) 0px 0px 0px 1px inset;
:root[dir="ltr"] & {
border-radius: 0px 4px 4px 0px;
}
:root[dir="rtl"] & {
border-radius: 4px 0px 0px 4px;
}
float: left;
padding: 1px;
padding-inline-start: 0.5em;
appearance: none;
}
.color-picker-label-swatch {
height: 1.875rem;
width: 1.875rem;
margin-inline-end: 0.25rem;
border: 1px solid $oc-gray-3;
position: relative;
overflow: hidden;
background-color: transparent !important;
filter: var(--appearance-filter);
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--swatch-color);
}
}
.color-picker-keybinding {
position: absolute;
bottom: 2px;
font-size: 0.7em;
:root[dir="ltr"] & {
right: 2px;
}
:root[dir="rtl"] & {
left: 2px;
}
@media #{$media-query} {
display: none;
}
}
.color-picker-type-canvasBackground .color-picker-keybinding {
color: #aaa;
}
.color-picker-type-elementBackground .color-picker-keybinding {
color: #fff;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: #aaa;
}
.color-picker-type-elementStroke .color-picker-keybinding {
color: #d4d4d4;
}
&.Appearance_dark {
.color-picker-type-elementBackground .color-picker-keybinding {
color: #000;
}
.color-picker-swatch[aria-label="transparent"] .color-picker-keybinding {
color: #000;
}
:root[dir="rtl"] & {
right: -5.5px;
}
}
.color-picker-control-container {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
}
.color-picker-triangle {
width: 0px;
height: 0px;
border-style: solid;
border-width: 0px 9px 10px;
border-color: transparent transparent $oc-white;
position: absolute;
top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
}
.color-picker-triangle-shadow {
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -11px;
}
.color-picker-content {
padding: 0.5rem;
display: grid;
grid-template-columns: repeat(5, auto);
grid-gap: 0.5rem;
}
.color-picker-content .color-input-container {
grid-column: 1 / span 5;
}
.color-picker-swatch {
position: relative;
height: 1.875rem;
width: 1.875rem;
cursor: pointer;
border-radius: 4px;
margin: 0;
box-sizing: border-box;
border: 1px solid #ddd;
background-color: currentColor !important;
}
.color-picker-swatch:focus {
/* TODO: only show the border when the color is too light to see as a shadow */
box-shadow: 0 0 4px 1px currentColor;
border-color: $oc-blue-5;
}
.color-picker-transparent {
border-radius: 4px;
box-shadow: transparentize($oc-black, 0.9) 0px 0px 0px 1px inset;
position: absolute;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
.color-picker-transparent,
.color-picker-label-swatch {
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
}
.color-picker-hash {
background: $oc-gray-3;
height: 1.875rem;
width: 1.875rem;
:root[dir="ltr"] & {
border-radius: 4px 0px 0px 4px;
}
:root[dir="rtl"] & {
border-radius: 0px 4px 4px 0px;
}
color: $oc-gray-7;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
position: relative;
}
.color-input-container:focus-within .color-picker-hash {
box-shadow: 0 0 0 2px $oc-blue-2;
}
.color-input-container:focus-within .color-picker-hash::before,
.color-input-container:focus-within .color-picker-hash::after {
content: "";
width: 1px;
height: 100%;
position: absolute;
top: 0;
}
.color-input-container:focus-within .color-picker-hash::before {
background: $oc-gray-3;
:root[dir="ltr"] & {
right: -1px;
}
:root[dir="rtl"] & {
left: -1px;
}
}
.color-input-container:focus-within .color-picker-hash::after {
background: #fff;
:root[dir="ltr"] & {
right: -2px;
}
:root[dir="rtl"] & {
left: -2px;
}
}
.color-input-container {
display: flex;
}
.color-picker-input {
width: 12ch; /* length of `transparent` + 1 */
margin: 0;
font-size: 1rem;
color: $oc-gray-8;
border: 0px;
outline: none;
height: 1.75em;
box-shadow: $oc-gray-3 0px 0px 0px 1px inset;
:root[dir="ltr"] & {
border-radius: 0px 4px 4px 0px;
}
:root[dir="rtl"] & {
border-radius: 4px 0px 0px 4px;
}
float: left;
padding: 1px;
padding-inline-start: 0.5em;
appearance: none;
}
.color-picker-label-swatch {
height: 1.875rem;
width: 1.875rem;
margin-inline-end: 0.25rem;
border: 1px solid $oc-gray-3;
position: relative;
overflow: hidden;
background-color: transparent !important;
}
.color-picker-label-swatch::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--swatch-color);
}
.color-picker-keybinding {
position: absolute;
bottom: 2px;
:root[dir="ltr"] & {
right: 2px;
}
:root[dir="rtl"] & {
left: 2px;
}
font-size: 0.7em;
color: #ccc;
}

View File

@@ -2,16 +2,16 @@ import React from "react";
import { Popover } from "./Popover";
import "./ColorPicker.scss";
import { isArrowKey, KEYS } from "../keys";
import { KEYS } from "../keys";
import { t, getLanguage } from "../i18n";
import { isWritableElement } from "../utils";
import colors from "../colors";
const isValidColor = (color: string) => {
function isValidColor(color: string) {
const style = new Option().style;
style.color = color;
return !!style.color;
};
}
const getColor = (color: string): string | null => {
if (color === "transparent") {
@@ -36,14 +36,13 @@ const keyBindings = [
["a", "s", "d", "f", "g"],
].flat();
const Picker = ({
const Picker = function ({
colors,
color,
onChange,
onClose,
label,
showInput = true,
type,
}: {
colors: string[];
color: string | null;
@@ -51,21 +50,19 @@ const Picker = ({
onClose: () => void;
label: string;
showInput: boolean;
type: "canvasBackground" | "elementBackground" | "elementStroke";
}) => {
}) {
const firstItem = React.useRef<HTMLButtonElement>();
const activeItem = React.useRef<HTMLButtonElement>();
const gallery = React.useRef<HTMLDivElement>();
const colorInput = React.useRef<HTMLInputElement>();
React.useEffect(() => {
// After the component is first mounted focus on first input
// After the component is first mounted
// focus on first input
if (activeItem.current) {
activeItem.current.focus();
} else if (colorInput.current) {
colorInput.current.focus();
} else if (gallery.current) {
gallery.current.focus();
}
}, []);
@@ -77,11 +74,18 @@ const Picker = ({
colorInput.current?.focus();
event.preventDefault();
}
} else if (activeElement === colorInput.current) {
firstItem.current?.focus();
event.preventDefault();
} else {
if (activeElement === colorInput.current) {
firstItem.current?.focus();
event.preventDefault();
}
}
} else if (isArrowKey(event.key)) {
} else if (
event.key === KEYS.ARROW_RIGHT ||
event.key === KEYS.ARROW_LEFT ||
event.key === KEYS.ARROW_UP ||
event.key === KEYS.ARROW_DOWN
) {
const { activeElement } = document;
const isRTL = getLanguage().rtl;
const index = Array.prototype.indexOf.call(
@@ -119,7 +123,7 @@ const Picker = ({
return (
<div
className={`color-picker color-picker-type-${type}`}
className="color-picker"
role="dialog"
aria-modal="true"
aria-label={t("labels.colorPicker")}
@@ -134,7 +138,6 @@ const Picker = ({
gallery.current = el;
}
}}
tabIndex={0}
>
{colors.map((_color, i) => (
<button
@@ -232,7 +235,7 @@ const ColorInput = React.forwardRef(
},
);
export const ColorPicker = ({
export function ColorPicker({
type,
color,
onChange,
@@ -242,7 +245,7 @@ export const ColorPicker = ({
color: string | null;
onChange: (color: string) => void;
label: string;
}) => {
}) {
const [isActive, setActive] = React.useState(false);
const pickerButton = React.useRef<HTMLButtonElement>(null);
@@ -252,7 +255,11 @@ export const ColorPicker = ({
<button
className="color-picker-label-swatch"
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
style={
color
? ({ "--swatch-color": color } as React.CSSProperties)
: undefined
}
onClick={() => setActive(!isActive)}
ref={pickerButton}
/>
@@ -283,11 +290,10 @@ export const ColorPicker = ({
}}
label={label}
showInput={false}
type={type}
/>
</Popover>
) : null}
</React.Suspense>
</div>
);
};
}

View File

@@ -1,54 +1,36 @@
@import "open-color/open-color.scss";
.excalidraw {
.context-menu {
position: relative;
border-radius: 4px;
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
padding: 0;
list-style: none;
user-select: none;
margin: -0.25rem 0 0 0.125rem;
padding: 0.25rem 0;
background-color: var(--popup-secondary-background-color);
border: 1px solid var(--button-gray-3);
}
.context-menu button {
color: var(--popup-text-color);
}
.context-menu-option {
position: relative;
width: 100%;
min-width: 9.5rem;
margin: 0;
padding: 0.25rem 1rem 0.25rem 1.25rem;
text-align: start;
border-radius: 0;
background-color: transparent;
border: none;
white-space: nowrap;
display: grid;
grid-template-columns: 1fr 0.2fr;
div:nth-child(1) {
justify-self: start;
margin-inline-end: 20px;
}
div:nth-child(2) {
justify-self: end;
opacity: 0.6;
font-size: 0.7rem;
}
}
.context-menu-option:hover {
color: var(--popup-background-color);
background-color: var(--select-highlight-color);
}
.context-menu-option:focus {
z-index: 1;
}
.context-menu {
position: relative;
border-radius: 4px;
box-shadow: 0px 3px 10px transparentize($oc-black, 0.8);
padding: 0;
list-style: none;
user-select: none;
margin: -0.25rem 0 0 0.125rem;
padding: 0.25rem 0;
background-color: $oc-gray-1;
border: 1px solid $oc-gray-5;
}
.context-menu-option {
position: relative;
width: 100%;
min-width: 9.5rem;
margin: 0;
padding: 0.25rem 1rem 0.25rem 1.25rem;
text-align: start;
border-radius: 0;
background-color: transparent;
border: none;
white-space: nowrap;
}
.context-menu-option:hover {
color: $oc-white;
background-color: $oc-blue-5;
}
.context-menu-option:focus {
z-index: 1;
}

View File

@@ -1,16 +1,10 @@
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import clsx from "clsx";
import { Popover } from "./Popover";
import { render, unmountComponentAtNode } from "react-dom";
import "./ContextMenu.scss";
import {
getShortcutFromShortcutName,
ShortcutName,
} from "../actions/shortcuts";
type ContextMenuOption = {
shortcutName: ShortcutName;
label: string;
action(): void;
};
@@ -22,54 +16,45 @@ type Props = {
left: number;
};
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => {
const isDarkTheme = !!document
.querySelector(".excalidraw")
?.classList.contains("Appearance_dark");
function ContextMenu({ options, onCloseRequest, top, left }: Props) {
return (
<div
className={clsx("excalidraw", {
"Appearance_dark Appearance_dark-background-none": isDarkTheme,
})}
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
>
<Popover
onCloseRequest={onCloseRequest}
top={top}
left={left}
fitInViewport={true}
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
<ul
className="context-menu"
onContextMenu={(event) => event.preventDefault()}
>
{options.map(({ action, shortcutName, label }, idx) => (
<li data-testid={shortcutName} key={idx} onClick={onCloseRequest}>
<button className="context-menu-option" onClick={action}>
<div>{label}</div>
<div>
{shortcutName
? getShortcutFromShortcutName(shortcutName)
: ""}
</div>
</button>
</li>
))}
</ul>
</Popover>
</div>
{options.map((option, idx) => (
<li key={idx} onClick={onCloseRequest}>
<ContextMenuOption {...option} />
</li>
))}
</ul>
</Popover>
);
};
}
function ContextMenuOption({ label, action }: ContextMenuOption) {
return (
<button className="context-menu-option" onClick={action}>
{label}
</button>
);
}
let contextMenuNode: HTMLDivElement;
const getContextMenuNode = (): HTMLDivElement => {
function getContextMenuNode(): HTMLDivElement {
if (contextMenuNode) {
return contextMenuNode;
}
const div = document.createElement("div");
document.body.appendChild(div);
return (contextMenuNode = div);
};
}
type ContextMenuParams = {
options: (ContextMenuOption | false | null | undefined)[];
@@ -77,9 +62,9 @@ type ContextMenuParams = {
left: number;
};
const handleClose = () => {
function handleClose() {
unmountComponentAtNode(getContextMenuNode());
};
}
export default {
push(params: ContextMenuParams) {

View File

@@ -1,52 +0,0 @@
import "./ToolIcon.scss";
import React from "react";
import { t } from "../i18n";
export type Appearence = "light" | "dark";
// We chose to use only explicit toggle and not a third option for system value,
// but this could be added in the future.
export const DarkModeToggle = (props: {
value: Appearence;
onChange: (value: Appearence) => void;
}) => {
return (
<label
className={`ToolIcon ToolIcon_type_floating ToolIcon_size_M`}
title={t("buttons.toggleDarkMode")}
>
<input
className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
onChange={(event) =>
props.onChange(event.target.checked ? "dark" : "light")
}
checked={props.value === "dark"}
aria-label={t("buttons.toggleDarkMode")}
/>
<div className="ToolIcon__icon">
{props.value === "light" ? ICONS.MOON : ICONS.SUN}
</div>
</label>
);
};
const ICONS = {
SUN: (
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
<path
fill="currentColor"
d="M256 160c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zm246.4 80.5l-94.7-47.3 33.5-100.4c4.5-13.6-8.4-26.5-21.9-21.9l-100.4 33.5-47.4-94.8c-6.4-12.8-24.6-12.8-31 0l-47.3 94.7L92.7 70.8c-13.6-4.5-26.5 8.4-21.9 21.9l33.5 100.4-94.7 47.4c-12.8 6.4-12.8 24.6 0 31l94.7 47.3-33.5 100.5c-4.5 13.6 8.4 26.5 21.9 21.9l100.4-33.5 47.3 94.7c6.4 12.8 24.6 12.8 31 0l47.3-94.7 100.4 33.5c13.6 4.5 26.5-8.4 21.9-21.9l-33.5-100.4 94.7-47.3c13-6.5 13-24.7.2-31.1zm-155.9 106c-49.9 49.9-131.1 49.9-181 0-49.9-49.9-49.9-131.1 0-181 49.9-49.9 131.1-49.9 181 0 49.9 49.9 49.9 131.1 0 181z"
></path>
</svg>
),
MOON: (
<svg width="512" height="512" className="rtl-mirror" viewBox="0 0 512 512">
<path
fill="currentColor"
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
></path>
</svg>
),
};

View File

@@ -1,66 +1,60 @@
@import "../css/_variables";
.excalidraw {
.Dialog__title {
display: grid;
align-items: center;
margin-top: 0;
grid-template-columns: 1fr calc(var(--space-factor) * 7);
grid-gap: var(--metric);
}
.Dialog__title {
display: grid;
align-items: center;
margin-top: 0;
grid-template-columns: 1fr calc(var(--space-factor) * 7);
grid-gap: var(--metric);
}
.Dialog__titleContent {
flex: 1;
}
.Dialog .Modal__close {
margin: 0;
}
@media #{$media-query} {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};
--inset-right: #{"max(var(--metric), var(--sar))"};
}
.Dialog__title {
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
var(--space-factor) * 7
);
position: sticky;
top: calc(-1 * var(--metric));
margin: calc(-1 * var(--inset-right));
margin-top: calc(-1 * var(--metric));
margin-bottom: var(--metric);
padding: calc(var(--space-factor) * 2);
padding-left: var(--inset-left);
padding-right: var(--inset-right);
background: white;
font-size: 1.25em;
box-sizing: border-box;
border-bottom: 1px solid $oc-gray-4;
z-index: 1;
}
.Dialog__titleContent {
flex: 1;
text-align: center;
}
.Dialog .Island {
width: 100vw;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
}
.Dialog .Modal__close {
color: var(--icon-fill-color);
margin: 0;
}
@media #{$media-query} {
.Dialog {
--metric: calc(var(--space-factor) * 4);
--inset-left: #{"max(var(--metric), var(--sal))"};
--inset-right: #{"max(var(--metric), var(--sar))"};
}
.Dialog__title {
grid-template-columns: calc(var(--space-factor) * 7) 1fr calc(
var(--space-factor) * 7
);
position: sticky;
top: calc(-1 * var(--metric));
margin: calc(-1 * var(--inset-right));
margin-top: calc(-1 * var(--metric));
margin-bottom: var(--metric);
padding: calc(var(--space-factor) * 2);
padding-left: var(--inset-left);
padding-right: var(--inset-right);
background: var(--bg-color-island);
font-size: 1.25em;
box-sizing: border-box;
border-bottom: 1px solid var(--button-gray-2);
z-index: 1;
}
.Dialog__titleContent {
text-align: center;
}
.Dialog .Island {
width: 100vw;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"};
padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"};
padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"};
}
.Dialog .Modal__close {
order: -1;
}
order: -1;
}
}

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import clsx from "clsx";
import React, { useEffect, useRef } from "react";
import { Modal } from "./Modal";
import { Island } from "./Island";
import { t } from "../i18n";
@@ -9,38 +8,32 @@ import { KEYS } from "../keys";
import "./Dialog.scss";
const useRefState = <T,>() => {
const [refValue, setRefValue] = useState<T | null>(null);
const refCallback = useCallback((value: T) => {
setRefValue(value);
}, []);
return [refValue, refCallback] as const;
};
export const Dialog = (props: {
export function Dialog(props: {
children: React.ReactNode;
className?: string;
maxWidth?: number;
onCloseRequest(): void;
title: React.ReactNode;
}) => {
const [islandNode, setIslandNode] = useRefState<HTMLDivElement>();
}) {
const islandRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!islandNode) {
return;
}
const focusableElements = queryFocusableElements(islandNode);
const focusableElements = queryFocusableElements();
if (focusableElements.length > 0) {
// If there's an element other than close, focus it.
(focusableElements[1] || focusableElements[0]).focus();
}
}, []);
const handleKeyDown = (event: KeyboardEvent) => {
useEffect(() => {
if (!islandRef.current) {
return;
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === KEYS.TAB) {
const focusableElements = queryFocusableElements(islandNode);
const focusableElements = queryFocusableElements();
const { activeElement } = document;
const currentIndex = focusableElements.findIndex(
(element) => element === activeElement,
@@ -57,29 +50,30 @@ export const Dialog = (props: {
event.preventDefault();
}
}
};
}
islandNode.addEventListener("keydown", handleKeyDown);
const node = islandRef.current;
node.addEventListener("keydown", handleKeyDown);
return () => islandNode.removeEventListener("keydown", handleKeyDown);
}, [islandNode]);
return () => node.removeEventListener("keydown", handleKeyDown);
}, []);
const queryFocusableElements = (node: HTMLElement) => {
const focusableElements = node.querySelectorAll<HTMLElement>(
function queryFocusableElements() {
const focusableElements = islandRef.current?.querySelectorAll<HTMLElement>(
"button, a, input, select, textarea, div[tabindex]",
);
return focusableElements ? Array.from(focusableElements) : [];
};
}
return (
<Modal
className={clsx("Dialog", props.className)}
className={`${props.className ?? ""} Dialog`}
labelledBy="dialog-title"
maxWidth={props.maxWidth}
onCloseRequest={props.onCloseRequest}
>
<Island padding={4} ref={setIslandNode}>
<Island padding={4} ref={islandRef}>
<h2 id="dialog-title" className="Dialog__title">
<span className="Dialog__titleContent">{props.title}</span>
<button
@@ -94,4 +88,4 @@ export const Dialog = (props: {
</Island>
</Modal>
);
};
}

View File

@@ -3,13 +3,13 @@ import { t } from "../i18n";
import { Dialog } from "./Dialog";
export const ErrorDialog = ({
export function ErrorDialog({
message,
onClose,
}: {
message: string;
onClose?: () => void;
}) => {
}) {
const [modalIsShown, setModalIsShown] = useState(!!message);
const handleClose = React.useCallback(() => {
@@ -33,4 +33,4 @@ export const ErrorDialog = ({
)}
</>
);
};
}

View File

@@ -1,71 +1,57 @@
@import "../css/_variables";
.excalidraw {
.ExportDialog__preview {
--preview-padding: calc(var(--space-factor) * 4);
.ExportDialog__preview {
--preview-padding: calc(var(--space-factor) * 4);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
text-align: center;
padding: var(--preview-padding);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
text-align: center;
padding: var(--preview-padding);
margin-bottom: calc(var(--space-factor) * 3);
}
.ExportDialog__preview canvas {
max-width: calc(100% - var(--preview-padding) * 2);
max-height: 25rem;
}
.ExportDialog__actions {
width: 100%;
display: flex;
grid-gap: calc(var(--space-factor) * 2);
align-items: top;
justify-content: space-between;
}
.ExportDialog__name {
grid-column: project-name;
margin: auto;
}
@media (max-width: 550px) {
.ExportDialog {
display: flex;
flex-direction: column;
}
.ExportDialog__actions {
flex-direction: column;
align-items: center;
}
.ExportDialog__actions > * {
margin-bottom: calc(var(--space-factor) * 3);
}
}
@media #{$media-query} {
.ExportDialog__preview canvas {
max-width: calc(100% - var(--preview-padding) * 2);
max-height: 25rem;
max-height: 30vh;
}
&.Appearance_dark .ExportDialog__preview canvas {
filter: none;
.ExportDialog__dialog,
.ExportDialog__dialog .Island {
height: 100%;
box-sizing: border-box;
}
.ExportDialog__actions {
width: 100%;
display: flex;
grid-gap: calc(var(--space-factor) * 2);
align-items: top;
justify-content: space-between;
}
.ExportDialog__name {
grid-column: project-name;
margin: auto;
.TextInput {
height: calc(1rem - 3px);
}
}
@media (max-width: 550px) {
.ExportDialog {
display: flex;
flex-direction: column;
}
.ExportDialog__actions {
flex-direction: column;
align-items: center;
}
.ExportDialog__actions > * {
margin-bottom: calc(var(--space-factor) * 3);
}
}
@media #{$media-query} {
.ExportDialog__preview canvas {
max-height: 30vh;
}
.ExportDialog__dialog,
.ExportDialog__dialog .Island {
height: 100%;
box-sizing: border-box;
}
.ExportDialog__dialog .Island {
overflow-y: auto;
}
.ExportDialog__dialog .Island {
overflow-y: auto;
}
}

View File

@@ -1,56 +1,30 @@
import React, { useEffect, useRef, useState } from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { ActionsManagerInterface } from "../actions/types";
import { EVENT_DIALOG, trackEvent } from "../analytics";
import { probablySupportsClipboardBlob } from "../clipboard";
import { canvasToBlob } from "../data/blob";
import { NonDeletedExcalidrawElement } from "../element/types";
import { CanvasError } from "../errors";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, getExportSize } from "../scene/export";
import { AppState } from "../types";
import { Dialog } from "./Dialog";
import "./ExportDialog.scss";
import { clipboard, exportFile, link } from "./icons";
import Stack from "./Stack";
import React, { useState, useEffect, useRef } from "react";
import { ToolButton } from "./ToolButton";
import { clipboard, exportFile, link } from "./icons";
import { NonDeletedExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { exportToCanvas } from "../scene/export";
import { ActionsManagerInterface } from "../actions/types";
import Stack from "./Stack";
import { t } from "../i18n";
import { probablySupportsClipboardBlob } from "../clipboard";
import { getSelectedElements, isSomeElementSelected } from "../scene";
import useIsMobile from "../is-mobile";
import { Dialog } from "./Dialog";
const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
export const ErrorCanvasPreview = () => {
return (
<div>
<h3>{t("canvasError.cannotShowPreview")}</h3>
<p>
<span>{t("canvasError.canvasTooBig")}</span>
</p>
<em>({t("canvasError.canvasTooBigTip")})</em>
</div>
);
};
const renderPreview = (
content: HTMLCanvasElement | Error,
previewNode: HTMLDivElement,
) => {
unmountComponentAtNode(previewNode);
previewNode.innerHTML = "";
if (content instanceof HTMLCanvasElement) {
previewNode.appendChild(content);
} else {
render(<ErrorCanvasPreview />, previewNode);
}
};
export type ExportCB = (
elements: readonly NonDeletedExcalidrawElement[],
scale?: number,
) => void;
const ExportModal = ({
function ExportModal({
elements,
appState,
exportPadding = 10,
@@ -69,7 +43,7 @@ const ExportModal = ({
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
onCloseRequest: () => void;
}) => {
}) {
const someElementIsSelected = isSomeElementSelected(elements, appState);
const [scale, setScale] = useState(defaultScale);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
@@ -90,32 +64,17 @@ const ExportModal = ({
useEffect(() => {
const previewNode = previewRef.current;
if (!previewNode) {
return;
}
try {
const canvas = exportToCanvas(exportedElements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
canvasToBlob(canvas)
.then(() => {
renderPreview(canvas, previewNode);
})
.catch((error) => {
console.error(error);
renderPreview(new CanvasError(), previewNode);
});
} catch (error) {
console.error(error);
renderPreview(new CanvasError(), previewNode);
}
const canvas = exportToCanvas(exportedElements, appState, {
exportBackground,
viewBackgroundColor,
exportPadding,
scale,
shouldAddWatermark,
});
previewNode?.appendChild(canvas);
return () => {
previewNode?.removeChild(canvas);
};
}, [
appState,
exportedElements,
@@ -128,7 +87,7 @@ const ExportModal = ({
return (
<div className="ExportDialog">
<div className="ExportDialog__preview" ref={previewRef} />
<div className="ExportDialog__preview" ref={previewRef}></div>
<Stack.Col gap={2} align="center">
<div className="ExportDialog__actions">
<Stack.Row gap={2}>
@@ -167,33 +126,19 @@ const ExportModal = ({
{actionManager.renderAction("changeProjectName")}
</div>
<Stack.Row gap={2}>
{scales.map((s) => {
const [width, height] = getExportSize(
exportedElements,
exportPadding,
shouldAddWatermark,
s,
);
const scaleButtonTitle = `${t(
"buttons.scale",
)} ${s}x (${width}x${height})`;
return (
<ToolButton
key={s}
size="s"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
title={scaleButtonTitle}
aria-label={scaleButtonTitle}
id="export-canvas-scale"
checked={s === scale}
onChange={() => setScale(s)}
/>
);
})}
{scales.map((s) => (
<ToolButton
key={s}
size="s"
type="radio"
icon={`x${s}`}
name="export-canvas-scale"
aria-label={`Scale ${s} x`}
id="export-canvas-scale"
checked={s === scale}
onChange={() => setScale(s)}
/>
))}
</Stack.Row>
</div>
{actionManager.renderAction("changeExportBackground")}
@@ -211,14 +156,13 @@ const ExportModal = ({
</label>
</div>
)}
{actionManager.renderAction("changeExportEmbedScene")}
{actionManager.renderAction("changeShouldAddWatermark")}
</Stack.Col>
</div>
);
};
}
export const ExportDialog = ({
export function ExportDialog({
elements,
appState,
exportPadding = 10,
@@ -236,7 +180,7 @@ export const ExportDialog = ({
onExportToSvg: ExportCB;
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
}) => {
}) {
const [modalIsShown, setModalIsShown] = useState(false);
const triggerButton = useRef<HTMLButtonElement>(null);
@@ -248,10 +192,7 @@ export const ExportDialog = ({
return (
<>
<ToolButton
onClick={() => {
trackEvent(EVENT_DIALOG, "export");
setModalIsShown(true);
}}
onClick={() => setModalIsShown(true)}
icon={exportFile}
type="button"
aria-label={t("buttons.export")}
@@ -280,4 +221,4 @@ export const ExportDialog = ({
)}
</>
);
};
}

View File

@@ -0,0 +1,36 @@
.FixedSideContainer {
--margin: 0.25rem;
position: fixed;
pointer-events: none;
}
.FixedSideContainer > * {
pointer-events: all;
}
.FixedSideContainer_side_top {
left: var(--margin);
top: var(--margin);
right: var(--margin);
z-index: 2;
}
.FixedSideContainer_side_top.zen-mode {
right: 42px;
}
/* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left {
left: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 1;
}
.FixedSideContainer_side_right {
right: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 3;
}
*/

View File

@@ -1,38 +0,0 @@
.excalidraw {
.FixedSideContainer {
--margin: 0.25rem;
position: absolute;
pointer-events: none;
}
.FixedSideContainer > * {
pointer-events: all;
}
.FixedSideContainer_side_top {
left: var(--margin);
top: var(--margin);
right: var(--margin);
z-index: 2;
}
.FixedSideContainer_side_top.zen-mode {
right: 42px;
}
}
/* TODO: if these are used, make sure to implement RTL support
.FixedSideContainer_side_left {
left: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 1;
}
.FixedSideContainer_side_right {
right: var(--margin);
top: var(--margin);
bottom: var(--margin);
z-index: 3;
}
*/

View File

@@ -1,7 +1,6 @@
import "./FixedSideContainer.scss";
import "./FixedSideContainer.css";
import React from "react";
import clsx from "clsx";
type FixedSideContainerProps = {
children: React.ReactNode;
@@ -9,18 +8,16 @@ type FixedSideContainerProps = {
className?: string;
};
export const FixedSideContainer = ({
export function FixedSideContainer({
children,
side,
className,
}: FixedSideContainerProps) => (
<div
className={clsx(
"FixedSideContainer",
`FixedSideContainer_side_${side}`,
className,
)}
>
{children}
</div>
);
}: FixedSideContainerProps) {
return (
<div
className={`FixedSideContainer FixedSideContainer_side_${side} ${className}`}
>
{children}
</div>
);
}

View File

@@ -1,42 +1,33 @@
import React from "react";
import oc from "open-color";
import { EVENT_EXIT, trackEvent } from "../analytics";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="github-corner rtl-mirror"
export const GitHubCorner = React.memo(() => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 250 250"
className="github-corner rtl-mirror"
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
>
<a
href="https://github.com/excalidraw/excalidraw"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub repository"
onClick={() => {
trackEvent(EVENT_EXIT, "github");
}}
>
<path
d="M0 0l115 115h15l12 27 108 108V0z"
fill={appearance === "light" ? oc.gray[6] : oc.gray[8]}
/>
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={appearance === "light" ? oc.white : oc.black}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={appearance === "light" ? oc.white : oc.black}
/>
</a>
</svg>
),
);
<path d="M0 0l115 115h15l12 27 108 108V0z" fill={oc.gray[6]} />
<path
className="octo-arm"
d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
style={{ transformOrigin: "130px 106px" }}
fill={oc.white}
/>
<path
className="octo-body"
d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
fill={oc.white}
/>
</a>
</svg>
));

View File

@@ -18,8 +18,10 @@ const ICON = (
</svg>
);
export const HelpIcon = (props: HelpIconProps) => (
<label title={`${props.title} — ?`} className="help-icon">
<div onClick={props.onClick}>{ICON}</div>
</label>
);
export function HelpIcon(props: HelpIconProps) {
return (
<label title={`${props.title} — ?`} className="help-icon">
<div onClick={props.onClick}>{ICON}</div>
</label>
);
}

View File

@@ -1,33 +1,24 @@
@import "../css/_variables";
.excalidraw {
.HintViewer {
pointer-events: none;
box-sizing: border-box;
position: absolute;
display: flex;
justify-content: center;
left: 0;
top: 100%;
max-width: 100%;
width: 100%;
margin-top: 6px;
text-align: center;
color: $oc-gray-6;
font-size: 0.8rem;
.HintViewer {
color: $oc-gray-6;
font-size: 0.8rem;
left: 50%;
pointer-events: none;
position: absolute;
top: 54px;
transform: translateX(calc(-50% - 16px)); /* 16px is half of lock icon */
white-space: pre;
text-align: center;
@media #{$media-query} {
position: static;
transform: none;
margin-top: 0.5rem;
}
@media (min-width: 1200px) {
white-space: pre;
}
@media #{$media-query} {
position: static;
}
> span {
padding: 0.2rem 0.4rem;
background-color: var(--overlay-background-color);
border-radius: 4px;
}
> span {
background-color: transparentize($oc-white, 0.12);
padding: 0.2rem 0.4rem;
border-radius: 3px;
}
}

View File

@@ -6,7 +6,6 @@ import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
import { AppState } from "../types";
import { isLinearElement } from "../element/typeChecks";
import { getShortcutKey } from "../utils";
interface Hint {
appState: AppState;
@@ -27,10 +26,6 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.freeDraw");
}
if (elementType === "text") {
return t("hints.text");
}
const selectedElements = getSelectedElements(elements, appState);
if (
isResizing &&
@@ -48,20 +43,11 @@ const getHints = ({ appState, elements }: Hint) => {
return t("hints.rotate");
}
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
if (appState.editingLinearElement) {
return appState.editingLinearElement.activePointIndex
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}
return t("hints.lineEditor_info");
}
return null;
};
export const HintViewer = ({ appState, elements }: Hint) => {
let hint = getHints({
const hint = getHints({
appState,
elements,
});
@@ -69,8 +55,6 @@ export const HintViewer = ({ appState, elements }: Hint) => {
return null;
}
hint = getShortcutKey(hint);
return (
<div className="HintViewer">
<span>{hint}</span>

View File

@@ -1,139 +0,0 @@
@import "../css/_variables";
.excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker {
background: var(--popup-background-color);
border: 0px solid transparentize($oc-white, 0.75);
box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px;
border-radius: 4px;
position: absolute;
}
.picker-container button,
.picker button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
&:focus {
outline: transparent;
background-color: var(--button-gray-2);
& svg {
opacity: 1;
}
}
&:hover {
background-color: var(--button-gray-2);
}
&:active {
background-color: var(--button-gray-3);
}
&:disabled {
cursor: not-allowed;
}
svg {
margin: 0;
width: 36px;
height: 18px;
opacity: 0.6;
pointer-events: none;
}
}
.picker button {
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
}
.picker-triangle {
width: 0px;
height: 0px;
position: relative;
top: -10px;
:root[dir="ltr"] & {
left: 12px;
}
:root[dir="rtl"] & {
right: 12px;
}
z-index: 10;
&:before {
content: "";
position: absolute;
border-style: solid;
border-width: 0px 9px 10px;
border-color: transparent transparent transparentize($oc-black, 0.9);
top: -1px;
}
&:after {
content: "";
position: absolute;
border-style: solid;
border-width: 0px 9px 10px;
border-color: transparent transparent var(--popup-background-color);
}
}
.picker-content {
padding: 0.5rem;
display: grid;
grid-auto-flow: column;
grid-gap: 0.5rem;
border-radius: 4px;
}
.picker-keybinding {
position: absolute;
bottom: 2px;
font-size: 0.7em;
:root[dir="ltr"] & {
right: 2px;
}
:root[dir="rtl"] & {
left: 2px;
}
@media #{$media-query} {
display: none;
}
}
.picker-type-canvasBackground .picker-keybinding {
color: #aaa;
}
.picker-type-elementBackground .picker-keybinding {
color: #fff;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #aaa;
}
.picker-type-elementStroke .picker-keybinding {
color: #d4d4d4;
}
&.Appearance_dark {
.picker-type-elementBackground .picker-keybinding {
color: #000;
}
.picker-swatch[aria-label="transparent"] .picker-keybinding {
color: #000;
}
}
}

View File

@@ -1,188 +0,0 @@
import React from "react";
import { Popover } from "./Popover";
import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n";
function Picker<T>({
options,
value,
label,
onChange,
onClose,
}: {
label: string;
value: T;
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
onChange: (value: T) => void;
onClose: () => void;
}) {
const rFirstItem = React.useRef<HTMLButtonElement>();
const rActiveItem = React.useRef<HTMLButtonElement>();
const rGallery = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// After the component is first mounted focus on first input
if (rActiveItem.current) {
rActiveItem.current.focus();
} else if (rGallery.current) {
rGallery.current.focus();
}
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
(option) => option.keyBinding === event.key.toLowerCase(),
)!;
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation
const index = options.indexOf(pressedOption);
(rGallery!.current!.children![index] as any).focus();
event.preventDefault();
} else if (event.key === KEYS.TAB) {
// Tab navigation cycle through options. If the user tabs
// away from the picker, close the picker. We need to use
// a timeout here to let the stack clear before checking.
setTimeout(() => {
const active = rActiveItem.current;
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
} else if (isArrowKey(event.key)) {
// Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl;
const index = Array.prototype.indexOf.call(
rGallery!.current!.children,
activeElement,
);
if (index !== -1) {
const length = options.length;
let nextIndex = index;
switch (event.key) {
// Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length;
break;
}
// Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
case KEYS.ARROW_UP: {
nextIndex = (length + index - 1) % length;
break;
}
}
(rGallery.current!.children![nextIndex] as any).focus();
}
event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
// Close on escape or enter
event.preventDefault();
onClose();
}
event.nativeEvent.stopImmediatePropagation();
};
return (
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={handleKeyDown}
>
<div className="picker-content" ref={rGallery}>
{options.map((option, i) => (
<button
className="picker-option"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value);
}}
title={`${option.text}${option.keyBinding.toUpperCase()}`}
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding}
key={option.text}
ref={(el) => {
if (el && i === 0) {
rFirstItem.current = el;
}
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}}
>
{option.icon}
<span className="picker-keybinding">{option.keyBinding}</span>
</button>
))}
</div>
</div>
);
}
export function IconPicker<T>({
value,
label,
options,
onChange,
group = "",
}: {
label: string;
value: T;
options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
onChange: (value: T) => void;
group?: string;
}) {
const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return (
<label className={"picker-container"}>
<button
name={group}
className={isActive ? "active" : ""}
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
>
{options.find((option) => option.value === value)?.icon}
</button>
<React.Suspense fallback="">
{isActive ? (
<>
<Popover
onCloseRequest={(event) =>
event.target !== rPickerButton.current && setActive(false)
}
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
>
<Picker
options={options}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
</label>
);
}

View File

@@ -1,24 +0,0 @@
import React from "react";
import { LoadingMessage } from "./LoadingMessage";
import { setLanguageFirstTime } from "../i18n";
export class InitializeApp extends React.Component<
any,
{ isLoading: boolean }
> {
public state: { isLoading: boolean } = {
isLoading: true,
};
async componentDidMount() {
await setLanguageFirstTime();
this.setState({
isLoading: false,
});
}
public render() {
return this.state.isLoading ? <LoadingMessage /> : this.props.children;
}
}

View File

@@ -1,16 +1,14 @@
.excalidraw {
.Island {
--padding: 0;
background-color: var(--bg-color-island);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-m);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
.Island {
--padding: 0;
background-color: var(--bg-color-island);
backdrop-filter: saturate(100%) blur(10px);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-m);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
&.zen-mode {
box-shadow: none;
}
&.zen-mode {
box-shadow: none;
}
}

View File

@@ -1,7 +1,6 @@
import "./Island.scss";
import React from "react";
import clsx from "clsx";
type IslandProps = {
children: React.ReactNode;
@@ -13,8 +12,8 @@ type IslandProps = {
export const Island = React.forwardRef<HTMLDivElement, IslandProps>(
({ children, padding, className, style }, ref) => (
<div
className={clsx("Island", className)}
style={{ "--padding": padding, ...style }}
className={`${className ?? ""} Island`}
style={{ "--padding": padding, ...style } as React.CSSProperties}
ref={ref}
>
{children}

View File

@@ -1,8 +1,7 @@
import React from "react";
import clsx from "clsx";
import * as i18n from "../i18n";
export const LanguageList = ({
export function LanguageList({
onChange,
languages = i18n.languages,
currentLanguage = i18n.getLanguage().lng,
@@ -12,21 +11,23 @@ export const LanguageList = ({
onChange: (value: string) => void;
currentLanguage?: string;
floating?: boolean;
}) => (
<React.Fragment>
<select
className={clsx("dropdown-select dropdown-select__language", {
"dropdown-select--floating": floating,
})}
onChange={({ target }) => onChange(target.value)}
value={currentLanguage}
aria-label={i18n.t("buttons.selectLanguage")}
>
{languages.map((language) => (
<option key={language.lng} value={language.lng}>
{language.label}
</option>
))}
</select>
</React.Fragment>
);
}) {
return (
<React.Fragment>
<select
className={`dropdown-select dropdown-select__language${
floating ? " dropdown-select--floating" : ""
}`}
onChange={({ target }) => onChange(target.value)}
value={currentLanguage}
aria-label={i18n.t("buttons.selectLanguage")}
>
{languages.map((language) => (
<option key={language.lng} value={language.lng}>
{language.label}
</option>
))}
</select>
</React.Fragment>
);
}

View File

@@ -1,185 +1,133 @@
@import "open-color/open-color";
.excalidraw {
.layer-ui__library {
margin: auto;
.layer-ui__wrapper {
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
border-radius: var(--space-factor);
color: $oc-green-9;
.browse-libraries {
position: absolute;
right: 12px;
top: 16px;
white-space: nowrap;
}
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.layer-ui__library-items {
max-height: 50vh;
overflow: auto;
}
.layer-ui__wrapper {
.encrypted-icon {
position: relative;
margin-inline-start: 15px;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--space-factor);
color: $oc-green-9;
svg {
width: 1.2rem;
height: 1.2rem;
}
&.tooltip .tooltip-text {
visibility: hidden;
width: 20rem;
bottom: calc(50% + 0.8rem + 6px);
:root[dir="ltr"] & {
left: -5px;
}
:root[dir="rtl"] & {
right: -5px;
}
background-color: $oc-black;
color: $oc-white;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 10;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
&::after {
--size: 6px;
content: "";
border: var(--size) solid transparent;
border-top-color: $oc-black;
position: absolute;
bottom: calc(-2 * var(--size));
:root[dir="ltr"] & {
left: calc(5px + var(--size) / 2);
}
:root[dir="rtl"] & {
right: calc(5px + var(--size) / 2);
}
}
}
// the following 3 rules ensure that the tooltip doesn't show (nor affect
// the cursor) when you drag over when you draw on canvas, but at the same
// time it still works when clicking on the link/shield
body:active &.tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) &.tooltip:hover .tooltip-text {
visibility: visible;
}
.tooltip-text:hover {
visibility: visible;
}
svg {
width: 1.2rem;
height: 1.2rem;
}
&__github-corner {
top: 0;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
position: absolute;
width: 40px;
}
&__footer {
position: absolute;
z-index: 100;
bottom: 0px;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
width: 190px;
}
.zen-mode-transition {
transition: transform 0.5s ease-in-out;
:root[dir="ltr"] &.transition-left {
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.transition-right {
transform: translate(999px, 0px);
}
:root[dir="rtl"] &.transition-left {
transform: translate(999px, 0);
}
:root[dir="rtl"] &.transition-right {
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.App-menu_bottom--transition-left {
transform: translate(-92px, 0);
}
:root[dir="rtl"] &.App-menu_bottom--transition-left {
transform: translate(92px, 0);
}
}
.disable-zen-mode {
height: 30px;
position: absolute;
bottom: 10px;
[dir="ltr"] & {
right: 15px;
}
[dir="rtl"] & {
left: 15px;
}
font-size: 10px;
padding: 10px;
font-weight: 500;
opacity: 0;
&.tooltip .tooltip-text {
visibility: hidden;
transition: visibility 0s linear 0s, opacity 0.5s;
&--visible {
opacity: 1;
visibility: visible;
transition: visibility 0s linear 300ms, opacity 0.5s;
transition-delay: 0.8s;
width: 20rem;
bottom: calc(50% + 0.8rem + 6px);
:root[dir="ltr"] & {
left: -5px;
}
:root[dir="rtl"] & {
right: -5px;
}
background-color: $oc-black;
color: $oc-white;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 10;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
&::after {
--size: 6px;
content: "";
border: var(--size) solid transparent;
border-top-color: $oc-black;
position: absolute;
bottom: calc(-2 * var(--size));
:root[dir="ltr"] & {
left: calc(5px + var(--size) / 2);
}
:root[dir="rtl"] & {
right: calc(5px + var(--size) / 2);
}
}
}
// the following 3 rules ensure that the tooltip doesn't show (nor affect
// the cursor) when you drag over when you draw on canvas, but at the same
// time it still works when clicking on the link/shield
body:active &.tooltip:not(:hover) {
pointer-events: none;
}
body:not(:active) &.tooltip:hover .tooltip-text {
visibility: visible;
}
.tooltip-text:hover {
visibility: visible;
}
}
&__github-corner {
top: 0;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
position: absolute;
width: 40px;
}
&__footer {
position: absolute;
bottom: 0px;
:root[dir="ltr"] & {
right: 0;
}
:root[dir="rtl"] & {
left: 0;
}
width: 190px;
}
.zen-mode-transition {
transition: transform 0.5s ease-in-out;
:root[dir="ltr"] &.transition-left {
transform: translate(-999px, 0);
}
:root[dir="ltr"] &.transition-right {
transform: translate(999px, 0px);
}
:root[dir="rtl"] &.transition-left {
transform: translate(999px, 0);
}
:root[dir="rtl"] &.transition-right {
transform: translate(-999px, 0);
}
&.App-menu_bottom--transition-left {
transform: translate(-92px, 0);
}
}
.disable-zen-mode {
height: 30px;
position: absolute;
bottom: 10px;
right: 15px;
font-size: 10px;
padding: 10px;
font-weight: 500;
opacity: 0;
visibility: hidden;
transition: visibility 0s linear 0s, opacity 0.5s;
&--visible {
opacity: 1;
visibility: visible;
transition: visibility 0s linear 300ms, opacity 0.5s;
transition-delay: 0.8s;
}
}
}

View File

@@ -1,22 +1,15 @@
import React, {
useRef,
useState,
RefObject,
useEffect,
useCallback,
} from "react";
import React from "react";
import { showSelectedShapeActions } from "../element";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { calculateScrollCenter } from "../scene";
import { exportCanvas } from "../data";
import { AppState, LibraryItems, LibraryItem } from "../types";
import { AppState } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { Island } from "./Island";
import Stack from "./Stack";
import { FixedSideContainer } from "./FixedSideContainer";
import { UserList } from "./UserList";
import { LockIcon } from "./LockIcon";
import { ExportDialog, ExportCB } from "./ExportDialog";
import { LanguageList } from "./LanguageList";
@@ -28,310 +21,53 @@ import { ExportType } from "../scene/types";
import { MobileMenu } from "./MobileMenu";
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import CollabButton from "./CollabButton";
import { RoomDialog } from "./RoomDialog";
import { ErrorDialog } from "./ErrorDialog";
import { ShortcutsDialog } from "./ShortcutsDialog";
import { LoadingMessage } from "./LoadingMessage";
import { CLASSES } from "../constants";
import { shield, exportFile, load } from "./icons";
import { shield } from "./icons";
import { GitHubCorner } from "./GitHubCorner";
import { Tooltip } from "./Tooltip";
import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { ToolButton } from "./ToolButton";
import { saveLibraryAsJSON, importLibraryFromJSON } from "../data/json";
import { muteFSAbortError } from "../utils";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
import clsx from "clsx";
import { Library } from "../data/library";
import {
EVENT_ACTION,
EVENT_EXIT,
EVENT_LIBRARY,
trackEvent,
} from "../analytics";
interface LayerUIProps {
actionManager: ActionManager;
appState: AppState;
canvas: HTMLCanvasElement | null;
setAppState: React.Component<any, AppState>["setState"];
setAppState: any;
elements: readonly NonDeletedExcalidrawElement[];
onCollabButtonClick?: () => void;
onRoomCreate: () => void;
onUsernameChange: (username: string) => void;
onRoomDestroy: () => void;
onLockToggle: () => void;
onInsertShape: (elements: LibraryItem) => void;
zenModeEnabled: boolean;
toggleZenMode: () => void;
lng: string;
isCollaborating: boolean;
}
const useOnClickOutside = (
ref: RefObject<HTMLElement>,
cb: (event: MouseEvent) => void,
) => {
useEffect(() => {
const listener = (event: MouseEvent) => {
if (!ref.current) {
return;
}
if (
event.target instanceof Element &&
(ref.current.contains(event.target) ||
!document.body.contains(event.target))
) {
return;
}
cb(event);
};
document.addEventListener("pointerdown", listener, false);
return () => {
document.removeEventListener("pointerdown", listener);
};
}, [ref, cb]);
};
const LibraryMenuItems = ({
library,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
setAppState,
}: {
library: LibraryItems;
pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: (elements: LibraryItem) => void;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const isMobile = useIsMobile();
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = isMobile ? 4 : 6;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = [];
let addedPendingElements = false;
rows.push(
<>
<a
className="browse-libraries"
href="https://libraries.excalidraw.com"
target="_excalidraw_libraries"
onClick={() => {
trackEvent(EVENT_EXIT, "libraries");
}}
>
{t("labels.libraries")}
</a>
<Stack.Row
align="center"
gap={1}
key={"actions"}
style={{ padding: "2px" }}
>
<ToolButton
key="import"
type="button"
title={t("buttons.load")}
aria-label={t("buttons.load")}
icon={load}
onClick={() => {
importLibraryFromJSON()
.then(() => {
// Maybe we should close and open the menu so that the items get updated.
// But for now we just close the menu.
setAppState({ isLibraryOpen: false });
})
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
<ToolButton
key="export"
type="button"
title={t("buttons.export")}
aria-label={t("buttons.export")}
icon={exportFile}
onClick={() => {
saveLibraryAsJSON()
.catch(muteFSAbortError)
.catch((error) => {
setAppState({ errorMessage: error.message });
});
}}
/>
</Stack.Row>
</>,
);
for (let row = 0; row < numRows; row++) {
const y = CELLS_PER_ROW * row;
const children = [];
for (let x = 0; x < CELLS_PER_ROW; x++) {
const shouldAddPendingElements: boolean =
pendingElements.length > 0 &&
!addedPendingElements &&
y + x >= library.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={x}>
<LibraryUnit
elements={library[y + x]}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, library[y + x])
}
/>
</Stack.Col>,
);
}
rows.push(
<Stack.Row align="center" gap={1} key={row}>
{children}
</Stack.Row>,
);
}
return (
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
{rows}
</Stack.Col>
);
};
const LibraryMenu = ({
onClickOutside,
onInsertShape,
pendingElements,
onAddToLibrary,
setAppState,
}: {
pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: LibraryItem) => void;
onAddToLibrary: () => void;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => {
// If click on the library icon, do nothing.
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
return;
}
onClickOutside(event);
});
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = setTimeout(() => {
resolve("loading");
}, 100);
}),
Library.loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, []);
const removeFromLibrary = useCallback(async (indexToRemove) => {
const items = await Library.loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
Library.saveLibrary(nextItems);
trackEvent(EVENT_LIBRARY, "remove");
setLibraryItems(nextItems);
}, []);
const addToLibrary = useCallback(
async (elements: LibraryItem) => {
const items = await Library.loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
trackEvent(EVENT_LIBRARY, "add");
Library.saveLibrary(nextItems);
setLibraryItems(nextItems);
},
[onAddToLibrary],
);
return loadingState === "preloading" ? null : (
<Island padding={1} ref={ref} className="layer-ui__library">
{loadingState === "loading" ? (
<div className="layer-ui__library-message">
{t("labels.libraryLoadingMessage")}
</div>
) : (
<LibraryMenuItems
library={libraryItems}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
setAppState={setAppState}
/>
)}
</Island>
);
};
const LayerUI = ({
actionManager,
appState,
setAppState,
canvas,
elements,
onCollabButtonClick,
onRoomCreate,
onUsernameChange,
onRoomDestroy,
onLockToggle,
onInsertShape,
zenModeEnabled,
toggleZenMode,
isCollaborating,
}: LayerUIProps) => {
const isMobile = useIsMobile();
// TODO: Extend tooltip component and use here.
const renderEncryptedIcon = () => (
<a
className={clsx("encrypted-icon tooltip zen-mode-visibility", {
"zen-mode-visibility--hidden": zenModeEnabled,
})}
className={`encrypted-icon tooltip zen-mode-visibility ${
zenModeEnabled ? "zen-mode-visibility--hidden" : ""
}`}
href="https://blog.excalidraw.com/end-to-end-encryption/"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
trackEvent(EVENT_EXIT, "e2ee shield");
}}
>
<span className="tooltip-text" dir="auto">
{t("encrypted.tooltip")}
@@ -341,23 +77,18 @@ const LayerUI = ({
);
const renderExportDialog = () => {
const createExporter = (type: ExportType): ExportCB => async (
const createExporter = (type: ExportType): ExportCB => (
exportedElements,
scale,
) => {
if (canvas) {
await exportCanvas(type, exportedElements, appState, canvas, {
exportCanvas(type, exportedElements, appState, canvas, {
exportBackground: appState.exportBackground,
name: appState.name,
viewBackgroundColor: appState.viewBackgroundColor,
scale,
shouldAddWatermark: appState.shouldAddWatermark,
})
.catch(muteFSAbortError)
.catch((error) => {
console.error(error);
setAppState({ errorMessage: error.message });
});
});
}
};
return (
@@ -368,26 +99,18 @@ const LayerUI = ({
onExportToPng={createExporter("png")}
onExportToSvg={createExporter("svg")}
onExportToClipboard={createExporter("clipboard")}
onExportToBackend={async (exportedElements) => {
onExportToBackend={(exportedElements) => {
if (canvas) {
try {
await exportCanvas(
"backend",
exportedElements,
{
...appState,
selectedElementIds: {},
},
canvas,
appState,
);
} catch (error) {
if (error.name !== "AbortError") {
const { width, height } = canvas;
console.error(error, { width, height });
setAppState({ errorMessage: error.message });
}
}
exportCanvas(
"backend",
exportedElements,
{
...appState,
selectedElementIds: {},
},
canvas,
appState,
);
}
}}
/>
@@ -397,33 +120,27 @@ const LayerUI = ({
const renderCanvasActions = () => (
<Section
heading="canvasActions"
className={clsx("zen-mode-transition", {
"transition-left": zenModeEnabled,
})}
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
<Island padding={2} style={{ zIndex: 1 }}>
<Island padding={4} style={{ zIndex: 1 }}>
<Stack.Col gap={4}>
<Stack.Row gap={1} justifyContent="space-between">
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{actionManager.renderAction("saveAsScene")}
{renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
{onCollabButtonClick && (
<CollabButton
isCollaborating={isCollaborating}
collaboratorCount={appState.collaborators.size}
onClick={onCollabButtonClick}
/>
)}
<RoomDialog
isCollaborating={appState.isCollaborating}
collaboratorCount={appState.collaborators.size}
username={appState.username}
onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
/>
</Stack.Row>
<BackgroundPickerAndDarkModeToggle
actionManager={actionManager}
appState={appState}
setAppState={setAppState}
/>
{actionManager.renderAction("changeViewBackgroundColor")}
</Stack.Col>
</Island>
</Section>
@@ -432,11 +149,9 @@ const LayerUI = ({
const renderSelectedShapeActions = () => (
<Section
heading="selectedShapeActions"
className={clsx("zen-mode-transition", {
"transition-left": zenModeEnabled,
})}
className={`zen-mode-transition ${zenModeEnabled && "transition-left"}`}
>
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={2}>
<Island className={CLASSES.SHAPE_ACTIONS_MENU} padding={4}>
<SelectedShapeActions
appState={appState}
elements={elements}
@@ -447,42 +162,18 @@ const LayerUI = ({
</Section>
);
const closeLibrary = useCallback(
(event) => {
setAppState({ isLibraryOpen: false });
},
[setAppState],
);
const deselectItems = useCallback(() => {
setAppState({
selectedElementIds: {},
selectedGroupIds: {},
});
}, [setAppState]);
const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState)}
onClickOutside={closeLibrary}
onInsertShape={onInsertShape}
onAddToLibrary={deselectItems}
setAppState={setAppState}
/>
) : null;
const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
appState,
elements,
);
return (
<FixedSideContainer side="top">
<HintViewer appState={appState} elements={elements} />
<div className="App-menu App-menu_top">
<Stack.Col
gap={4}
className={clsx({ "disable-pointerEvents": zenModeEnabled })}
className={zenModeEnabled && "disable-pointerEvents"}
>
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
@@ -491,17 +182,12 @@ const LayerUI = ({
{(heading) => (
<Stack.Col gap={4} align="start">
<Stack.Row gap={1}>
<Island
padding={1}
className={clsx({ "zen-mode": zenModeEnabled })}
>
<HintViewer appState={appState} elements={elements} />
<Island padding={1} className={zenModeEnabled && "zen-mode"}>
{heading}
<Stack.Row gap={1}>
<ShapesSwitcher
elementType={appState.elementType}
setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/>
</Stack.Row>
</Island>
@@ -512,64 +198,44 @@ const LayerUI = ({
title={t("toolBar.lock")}
/>
</Stack.Row>
{libraryMenu}
</Stack.Col>
)}
</Section>
<UserList
className={clsx("zen-mode-transition", {
"transition-right": zenModeEnabled,
})}
>
{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]) => (
<Tooltip
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
</Tooltip>
))}
</UserList>
<div />
</div>
{
<div
className={`App-menu App-menu_bottom zen-mode-transition ${
zenModeEnabled && "App-menu_bottom--transition-left"
}`}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{renderEncryptedIcon()}
</Section>
</Stack.Col>
</div>
}
</FixedSideContainer>
);
};
const renderBottomAppMenu = () => {
return (
<div
className={clsx("App-menu App-menu_bottom zen-mode-transition", {
"App-menu_bottom--transition-left": zenModeEnabled,
})}
>
<Stack.Col gap={2}>
<Section heading="canvasActions">
<Island padding={1}>
<ZoomActions
renderAction={actionManager.renderAction}
zoom={appState.zoom}
/>
</Island>
{renderEncryptedIcon()}
</Section>
</Stack.Col>
</div>
);
};
const renderFooter = () => (
<footer role="contentinfo" className="layer-ui__wrapper__footer">
<div
className={clsx("zen-mode-transition", {
"transition-right disable-pointerEvents": zenModeEnabled,
})}
className={`zen-mode-transition ${
zenModeEnabled && "transition-right disable-pointerEvents"
}`}
>
<LanguageList
onChange={async (lng) => {
await setLanguage(lng);
onChange={(lng) => {
setLanguage(lng);
setAppState({});
}}
languages={languages}
@@ -578,9 +244,9 @@ const LayerUI = ({
{actionManager.renderAction("toggleShortcuts")}
</div>
<button
className={clsx("disable-zen-mode", {
"disable-zen-mode--visible": zenModeEnabled,
})}
className={`disable-zen-mode ${
zenModeEnabled && "disable-zen-mode--visible"
}`}
onClick={toggleZenMode}
>
{t("buttons.exitZenMode")}
@@ -589,10 +255,7 @@ const LayerUI = ({
<button
className="scroll-back-to-content"
onClick={() => {
trackEvent(EVENT_ACTION, "scroll to content");
setAppState({
...calculateScrollCenter(elements, appState, canvas),
});
setAppState({ ...calculateScrollCenter(elements) });
}}
>
{t("buttons.scrollBackToContent")}
@@ -606,13 +269,12 @@ const LayerUI = ({
appState={appState}
elements={elements}
actionManager={actionManager}
libraryMenu={libraryMenu}
exportButton={renderExportDialog()}
setAppState={setAppState}
onCollabButtonClick={onCollabButtonClick}
onUsernameChange={onUsernameChange}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
onLockToggle={onLockToggle}
canvas={canvas}
isCollaborating={isCollaborating}
/>
) : (
<div className="layer-ui__wrapper">
@@ -625,21 +287,17 @@ const LayerUI = ({
)}
{appState.showShortcutsDialog && (
<ShortcutsDialog
onClose={() => setAppState({ showShortcutsDialog: false })}
onClose={() => setAppState({ showShortcutsDialog: null })}
/>
)}
{renderFixedSideContainer()}
{renderBottomAppMenu()}
{
<aside
className={clsx(
"layer-ui__wrapper__github-corner zen-mode-transition",
{
"transition-right": zenModeEnabled,
},
)}
className={`layer-ui__wrapper__github-corner zen-mode-transition ${
zenModeEnabled && "transition-right"
}`}
>
<GitHubCorner appearance={appState.appearance} />
<GitHubCorner />
</aside>
}
{renderFooter()}
@@ -650,10 +308,13 @@ const LayerUI = ({
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
const {
draggingElement,
resizingElement,
multiElement,
editingElement,
isResizing,
cursorX,
cursorY,
suggestedBindings,
startBoundElement: boundElement,
...ret
} = appState;
return ret;
@@ -664,7 +325,6 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return (
prev.lng === next.lng &&
prev.elements === next.elements &&
keys.every((key) => prevAppState[key] === nextAppState[key])
);

View File

@@ -1,79 +0,0 @@
.excalidraw {
.library-unit {
align-items: center;
border: 1px solid var(--button-gray-2);
display: flex;
justify-content: center;
position: relative;
width: 63px;
height: 63px; // match width
}
.library-unit__dragger {
display: flex;
height: 100%;
width: 100%;
}
.library-unit__dragger > svg {
filter: var(--appearance-filter);
flex-grow: 1;
max-height: 100%;
max-width: 100%;
}
.library-unit__removeFromLibrary,
.library-unit__removeFromLibrary:hover,
.library-unit__removeFromLibrary:active {
align-items: center;
background: none;
border: none;
color: var(--icon-fill-color);
display: flex;
justify-content: center;
margin: 0;
padding: 0;
position: absolute;
right: 5px;
top: 5px;
}
.library-unit__removeFromLibrary > svg {
height: 16px;
width: 16px;
}
.library-unit__pulse {
transform: scale(1);
animation: library-unit__pulse-animation 1s ease-in infinite;
}
.library-unit__adder {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
margin-left: -10px;
margin-top: -10px;
pointer-events: none;
}
.library-unit__active {
cursor: pointer;
}
@keyframes library-unit__pulse-animation {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.95);
}
}
}

View File

@@ -1,100 +0,0 @@
import React, { useRef, useEffect, useState } from "react";
import clsx from "clsx";
import { exportToSvg } from "../scene/export";
import { close } from "../components/icons";
import "./LibraryUnit.scss";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { LibraryItem } from "../types";
import { MIME_TYPES } from "../constants";
// fa-plus
const PLUS_ICON = (
<svg viewBox="0 0 1792 1792">
<path
fill="currentColor"
d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z"
/>
</svg>
);
export const LibraryUnit = ({
elements,
pendingElements,
onRemoveFromLibrary,
onClick,
}: {
elements?: LibraryItem;
pendingElements?: LibraryItem;
onRemoveFromLibrary: () => void;
onClick: () => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return;
}
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: "#fff",
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
ref.current!.removeChild(child);
}
ref.current!.appendChild(svg);
const current = ref.current!;
return () => {
current.removeChild(svg);
};
}, [elements, pendingElements]);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
const adder = (isHovered || isMobile) && pendingElements && (
<div className="library-unit__adder">{PLUS_ICON}</div>
);
return (
<div
className={clsx("library-unit", {
"library-unit__active": elements || pendingElements,
})}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={clsx("library-unit__dragger", {
"library-unit__pulse": !!pendingElements,
})}
ref={ref}
draggable={!!elements}
onClick={!!elements || !!pendingElements ? onClick : undefined}
onDragStart={(event) => {
setIsHovered(false);
event.dataTransfer.setData(
MIME_TYPES.excalidrawlib,
JSON.stringify(elements),
);
}}
/>
{adder}
{elements && (isHovered || isMobile) && (
<button
className="library-unit__removeFromLibrary"
aria-label={t("labels.removeFromLibrary")}
onClick={onRemoveFromLibrary}
>
{close}
</button>
)}
</div>
);
};

View File

@@ -1,11 +1,10 @@
import React from "react";
import { t } from "../i18n";
export const LoadingMessage = () => {
// !! KEEP THIS IN SYNC WITH index.html !!
return (
<div className="LoadingMessage">
<span>{t("labels.loadingScene")}</span>
<span>{"Loading scene..."}</span>
</div>
);
};

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