Compare commits
237 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f62f20d5 | |||
| 21dd1cfacc | |||
| fa1f7d9f22 | |||
| 3d8c12fba4 | |||
| 757dfeb6ad | |||
| a0e93b6040 | |||
| 499e9d64a5 | |||
| c1dbbdf678 | |||
| 47c254216b | |||
| d1cff91b75 | |||
| 437595fa65 | |||
| 60b275880d | |||
| cae9d2bcbd | |||
| 2874f9e48c | |||
| 0b3a5e7cc4 | |||
| 7ea3229e17 | |||
| b0404b10b6 | |||
| eb959128ac | |||
| 4c3d037f9c | |||
| 5852d0d410 | |||
| c1e00c44f5 | |||
| ffcb67b21f | |||
| 46ddd60948 | |||
| 89a9badc27 | |||
| 8b3e149db6 | |||
| a70417f23f | |||
| 1c8e8bb0f3 | |||
| 3a5ef4020d | |||
| 063533aede | |||
| b43260d97b | |||
| 83d3943cd0 | |||
| f39ac4a653 | |||
| 54a9826817 | |||
| d29fd62e41 | |||
| b57f3e0096 | |||
| f12ae80ba1 | |||
| f7b537a8b1 | |||
| 94364af68f | |||
| dfa1ce572b | |||
| b552c60714 | |||
| 216afc3625 | |||
| 802cde3501 | |||
| f5cf81ce42 | |||
| 6a891365b9 | |||
| 54fa0c9089 | |||
| dfdd994dbb | |||
| 28691e14b1 | |||
| 60759d314d | |||
| d5e37cda81 | |||
| 6135548534 | |||
| acf54c6f38 | |||
| 84a309d669 | |||
| 3c8e893cab | |||
| 9ba0f5dbc9 | |||
| 60ab14c2f6 | |||
| 0988ecfef4 | |||
| 1f47d61e8c | |||
| 9d760336d1 | |||
| 0443511954 | |||
| 5a73b9a363 | |||
| 24a6941861 | |||
| a0b98a944f | |||
| 6ebf52279d | |||
| 3b97f5a10c | |||
| da59205846 | |||
| b9a255407f | |||
| cc6c29c0b9 | |||
| 87faa5d3da | |||
| c158187f20 | |||
| 63e1148280 | |||
| b5fc873323 | |||
| 6c908553a9 | |||
| 0586fc138c | |||
| e95222ed32 | |||
| d87620b239 | |||
| 7cc31ac64a | |||
| 071b17a217 | |||
| 859207b8bc | |||
| becaabfa0f | |||
| f06484c6ab | |||
| bf4c65f483 | |||
| 8d18078f5c | |||
| d080833f4d | |||
| 451bcac0b7 | |||
| 06f01e11f8 | |||
| 51ad8951d4 | |||
| 7497a08270 | |||
| 210dc85c8c | |||
| 019ce4c52c | |||
| c141960ada | |||
| d7e63e66a7 | |||
| b660478164 | |||
| 37882c66cb | |||
| 7f66e1fe89 | |||
| 2b4540225d | |||
| dc2f25c14a | |||
| 8fb16669ab | |||
| f2600fe3e8 | |||
| 95ddc66339 | |||
| 5bcd8280c9 | |||
| c99e81678b | |||
| d1f39823f1 | |||
| 47cbb5b6fb | |||
| 8fd970320e | |||
| 8d8f696628 | |||
| 19b3dc658a | |||
| 4e0441eeb4 | |||
| 8013eb5e16 | |||
| 725412ebd3 | |||
| 7da176ff7d | |||
| 5fffc4743f | |||
| 8608d7b2e0 | |||
| 19b03b4ca9 | |||
| 416e8b3e42 | |||
| 98e0cd9078 | |||
| f3c16a600d | |||
| 835eb8d2fd | |||
| fde796a7a0 | |||
| 7c41944856 | |||
| f1b097ad06 | |||
| 9fcbbe0d27 | |||
| ec070911b8 | |||
| dcdeb2be57 | |||
| a8acc8212d | |||
| a89a03c66c | |||
| e32836f799 | |||
| 06c40006db | |||
| 91c7748c3d | |||
| f738b74791 | |||
| 00ae455873 | |||
| 06c5ea94d3 | |||
| f55ecb96cc | |||
| a6a32b9b29 | |||
| ac0d3059dc | |||
| 1161f1b8ba | |||
| 204e06b77b | |||
| 414182f599 | |||
| b9d27d308e | |||
| 3bdaafe4b5 | |||
| ae89608985 | |||
| 3085f4af81 | |||
| 531f3e5524 | |||
| 90ec2739ae | |||
| f29e9df72d | |||
| b5ad7ae4e3 | |||
| c78e4aab7f | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe | |||
| 178eca5828 | |||
| cb33de25f4 | |||
| 37ad85cbaf | |||
| d6a934ed19 | |||
| 416da62138 | |||
| f38f381989 | |||
| e5e07260c6 | |||
| 8492b144b0 | |||
| e46f038132 | |||
| 678dff25ed | |||
| 0cfa53b764 | |||
| cde46793f8 | |||
| 2d127f8c22 | |||
| 4eadb891f8 | |||
| 258605d1d5 | |||
| c141500400 | |||
| 8e27de2cdc | |||
| 0a19c93509 | |||
| 958597dfaa | |||
| 058918f8e5 | |||
| 3f194918e6 | |||
| 93c92d13e9 | |||
| 84e96e9393 | |||
| 320af405e9 | |||
| 60512f13d5 | |||
| f0458cc216 | |||
| 9f3fdf5505 | |||
| f42e1ab64e | |||
| 18808481fd | |||
| a7b64f02b3 | |||
| 0d4abd1ddc | |||
| 9e77373c81 | |||
| d108053351 | |||
| d4e85a9480 | |||
| 08cd4c4f9a | |||
| 469caadb87 | |||
| ca1a4f25e7 | |||
| 56c05b3099 | |||
| 6c0ff7fc5c | |||
| 7cad3645a0 | |||
| 5921ebc416 | |||
| 864353be5f | |||
| db2911c6c4 | |||
| fc3e062074 | |||
| 87c87a9fb1 | |||
| 4dc205537c | |||
| cc571c4681 | |||
| 14d512f321 | |||
| 41c036e1a5 | |||
| 91d36e9b81 | |||
| 27522110df | |||
| 712f267519 | |||
| 41a7613dff | |||
| 95d89a751a | |||
| 6b5fb30d69 | |||
| d92a849038 | |||
| 0a534f1bc6 | |||
| 4ca5f53b1f | |||
| f7dcc893ea | |||
| 4dfb8a3f8e | |||
| 298812e1d0 | |||
| 35bb449a4b | |||
| c4c064982f | |||
| 51dbd4831b | |||
| 7e41026812 | |||
| a8ebe514da | |||
| a30e1b25c6 | |||
| ff2ed5d26a | |||
| e058a08b33 | |||
| a306a909a0 | |||
| 3dc54a724a | |||
| a7c61319dd | |||
| cec5232a7a | |||
| d4f70e9f31 | |||
| e19fd1332a | |||
| 6e655cdb24 | |||
| 192c4e7658 | |||
| 195a743874 | |||
| 4a60fe3d22 | |||
| 2a0d15799c | |||
| a18b139a60 | |||
| 1913599594 |
+4
-2
@@ -1,3 +1,5 @@
|
||||
MODE="development"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
@@ -10,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002
|
||||
VITE_APP_PLUS_LP=https://plus.excalidraw.com
|
||||
VITE_APP_PLUS_APP=http://localhost:3000
|
||||
|
||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||
VITE_APP_AI_BACKEND=http://localhost:3016
|
||||
|
||||
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
||||
@@ -25,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true
|
||||
FAST_REFRESH=false
|
||||
|
||||
# The port the run the dev server
|
||||
VITE_APP_PORT=3000
|
||||
VITE_APP_PORT=3001
|
||||
|
||||
#Debug flags
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
MODE="production"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
|
||||
+28
-1
@@ -32,6 +32,33 @@
|
||||
"name": "jotai",
|
||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-jotai\")."
|
||||
}
|
||||
],
|
||||
"react/jsx-no-target-blank": [
|
||||
"error",
|
||||
{
|
||||
"allowReferrer": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/excalidraw/**/*.{ts,tsx}"],
|
||||
"excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@excalidraw/excalidraw"],
|
||||
"message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.",
|
||||
"allowTypeImports": true
|
||||
}
|
||||
],
|
||||
"paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Project coding standards
|
||||
|
||||
## Generic Communication Guidelines
|
||||
|
||||
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||
- Stop apologising if corrected, just provide the correct information or code
|
||||
- Prefer code unless asked for explanation
|
||||
- Stop summarizing what you've changed after modifications unless asked for
|
||||
|
||||
## TypeScript Guidelines
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Where possible, prefer implementations without allocation
|
||||
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||
- Prefer immutable data (const, readonly)
|
||||
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||
|
||||
## React Guidelines
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the React hooks rules (no conditional hooks)
|
||||
- Keep components small and focused
|
||||
- Use CSS modules for component styling
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Use PascalCase for component names, interfaces, and type aliases
|
||||
- Use camelCase for variables, functions, and methods
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use try/catch blocks for async operations
|
||||
- Implement proper error boundaries in React components
|
||||
- Always log errors with contextual information
|
||||
|
||||
## Testing
|
||||
|
||||
- Always attempt to fix #problems
|
||||
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||
|
||||
## Types
|
||||
|
||||
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease
|
||||
yarn release --tag=next --non-interactive
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Auto release excalidraw preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: "+1"
|
||||
- name: Get PR SHA
|
||||
id: sha
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const pr = await github.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
});
|
||||
return pr.data.head.sha
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ steps.sha.outputs.result }}
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release preview
|
||||
id: "autorelease"
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease preview ${{ github.event.issue.number }}
|
||||
- name: Post comment post release
|
||||
if: always()
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
|
||||
@@ -17,9 +17,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
|
||||
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
|
||||
sentry-cli releases set-commits --auto $SENTRY_RELEASE
|
||||
sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli releases finalize $SENTRY_RELEASE
|
||||
sentry-cli releases deploys $SENTRY_RELEASE new -e production
|
||||
env:
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install in packages/excalidraw
|
||||
run: yarn
|
||||
working-directory: packages/excalidraw
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "18.x"
|
||||
node-version: "20.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn install
|
||||
- name: "Test Coverage"
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js 18.x
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
+2
-1
@@ -25,4 +25,5 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
meta*.json
|
||||
.claude
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Structure
|
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features
|
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||
3. **Testing**: Always run `yarn test:update` before committing
|
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn test:typecheck # TypeScript type checking
|
||||
yarn test:update # Run all tests (with snapshot updates)
|
||||
yarn fix # Auto-fix formatting and linting issues
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Package System
|
||||
|
||||
- Uses Yarn workspaces for monorepo management
|
||||
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||
- Build system uses esbuild for packages, Vite for the app
|
||||
- TypeScript throughout with strict configuration
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
FROM node:18 AS build
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,13 +6,14 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN yarn --network-timeout 600000
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN yarn build:app:docker
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -23,20 +23,17 @@
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||
</a>
|
||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
|
||||
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
|
||||
</a>
|
||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
|
||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
|
||||
</a>
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
||||
</a>
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<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>
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -63,7 +60,7 @@ The Excalidraw editor (npm package) supports:
|
||||
- 🏗️ Customizable.
|
||||
- 📷 Image support.
|
||||
- 😀 Shape libraries support.
|
||||
- 👅 Localization (i18n) support.
|
||||
- 🌐 Localization (i18n) support.
|
||||
- 🖼️ Export to PNG, SVG & clipboard.
|
||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||
|
||||
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
|
||||
```jsx live
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ height: "500px"}}>
|
||||
<div style={{ height: "500px" }}>
|
||||
<Excalidraw>
|
||||
<Footer>
|
||||
<button
|
||||
@@ -27,19 +27,19 @@ function App() {
|
||||
|
||||
This will only work for `Desktop` devices.
|
||||
|
||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useEditorInterface`](#useEditorInterface) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||
|
||||
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
||||
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
className="custom-footer"
|
||||
style= {{ marginLeft: '20px', height: '2rem'}}
|
||||
style={{ marginLeft: "20px", height: "2rem" }}
|
||||
onClick={() => alert("This is custom footer in mobile menu")}
|
||||
>
|
||||
custom footer
|
||||
|
||||
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
```ts
|
||||
(
|
||||
tool: (
|
||||
| (
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| {
|
||||
type: Extract<ToolType, "image">;
|
||||
insertOnCanvasDirectly?: boolean;
|
||||
}
|
||||
)
|
||||
| { type: ToolType }
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean },
|
||||
) => {};
|
||||
@@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
|
||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
|
||||
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
||||
|
||||
## setCursor
|
||||
|
||||
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords({ clientX: number, clientY: number },<br/> 
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||
</pre>
|
||||
|
||||
### useDevice
|
||||
### useEditorInterface
|
||||
|
||||
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
||||
|
||||
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
|
||||
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@@ -336,12 +336,20 @@ render(<App />);
|
||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
|
||||
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
|
||||
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
|
||||
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
|
||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
|
||||
| ---- | ---- | ----------- |
|
||||
|
||||
The `EditorInterface` object has the following properties:
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
|
||||
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
|
||||
| `userAgent.raw` | `string` | Raw user agent string |
|
||||
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
|
||||
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
|
||||
| `isTouchScreen` | `boolean` | True if touch events are detected |
|
||||
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
|
||||
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
|
||||
|
||||
### i18n
|
||||
|
||||
|
||||
@@ -28,32 +28,12 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
||||
|
||||
## Releasing
|
||||
|
||||
### Create a test release
|
||||
|
||||
You can create a test release by posting the below comment in your pull request:
|
||||
|
||||
```bash
|
||||
@excalibot trigger release
|
||||
```
|
||||
|
||||
Once the version is released `@excalibot` will post a comment with the release version.
|
||||
|
||||
### Creating a production release
|
||||
|
||||
To release the next stable version follow the below steps:
|
||||
|
||||
```bash
|
||||
yarn prerelease:excalidraw
|
||||
yarn release --tag=latest --version=0.19.0
|
||||
```
|
||||
|
||||
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
|
||||
|
||||
The next step is to run the `release` script:
|
||||
|
||||
```bash
|
||||
yarn release:excalidraw
|
||||
```
|
||||
|
||||
This will publish the package.
|
||||
|
||||
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
|
||||
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
|
||||
|
||||
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
||||
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
excalidraw:
|
||||
build:
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"build:packages": "yarn --cwd ../../ build:packages",
|
||||
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.excalidraw .panelColumn {
|
||||
.excalidraw .selected-shape-actions {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ const MobileFooter = ({
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}) => {
|
||||
const { useDevice, Footer } = excalidrawLib;
|
||||
const { useEditorInterface, Footer } = excalidrawLib;
|
||||
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
return (
|
||||
<Footer>
|
||||
<CustomFooter
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.29.1"
|
||||
"browser-fs-access": "0.38.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.0.12",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "5.0.12"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"build:preview": "yarn build && yarn preview",
|
||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
||||
"build:packages": "yarn --cwd ../../ build:packages"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { unstable_batchedUpdates } from "react-dom";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -54,40 +52,6 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
extensions,
|
||||
mimeTypes,
|
||||
multiple: opts.multiple ?? false,
|
||||
legacySetup: (resolve, reject, input) => {
|
||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
||||
const focusHandler = () => {
|
||||
checkForFile();
|
||||
document.addEventListener("keyup", scheduleRejection);
|
||||
document.addEventListener("pointerup", scheduleRejection);
|
||||
scheduleRejection();
|
||||
};
|
||||
const checkForFile = () => {
|
||||
// this hack might not work when expecting multiple files
|
||||
if (input.files?.length) {
|
||||
const ret = opts.multiple ? [...input.files] : input.files[0];
|
||||
resolve(ret as RetType);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("focus", focusHandler);
|
||||
});
|
||||
const interval = window.setInterval(() => {
|
||||
checkForFile();
|
||||
}, INPUT_CHANGE_INTERVAL_MS);
|
||||
return (rejectPromise) => {
|
||||
clearInterval(interval);
|
||||
scheduleRejection.cancel();
|
||||
window.removeEventListener("focus", focusHandler);
|
||||
document.removeEventListener("keyup", scheduleRejection);
|
||||
document.removeEventListener("pointerup", scheduleRejection);
|
||||
if (rejectPromise) {
|
||||
// so that something is shown in console if we need to debug this
|
||||
console.warn("Opening the file was canceled (legacy-fs).");
|
||||
rejectPromise(new Error("Request Aborted"));
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
}
|
||||
|
||||
+162
-41
@@ -4,6 +4,9 @@ import {
|
||||
TTDDialogTrigger,
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
useEditorInterface,
|
||||
ExcalidrawAPIProvider,
|
||||
useExcalidrawAPI,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -20,7 +23,6 @@ import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -34,7 +36,6 @@ import {
|
||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import {
|
||||
@@ -47,10 +48,14 @@ import {
|
||||
share,
|
||||
youtubeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { isElementLink } from "@excalidraw/element/elementLink";
|
||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { isElementLink } from "@excalidraw/element";
|
||||
import {
|
||||
bumpElementVersions,
|
||||
restoreAppState,
|
||||
restoreElements,
|
||||
} from "@excalidraw/excalidraw/data/restore";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
@@ -70,6 +75,7 @@ import type {
|
||||
BinaryFiles,
|
||||
ExcalidrawInitialDataState,
|
||||
UIAppState,
|
||||
ExcalidrawProps,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { ResolutionType } from "@excalidraw/common/utility-types";
|
||||
import type { ResolvablePromise } from "@excalidraw/common/utils";
|
||||
@@ -105,11 +111,12 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
||||
import {
|
||||
exportToBackend,
|
||||
getCollaborationLinkData,
|
||||
importFromBackend,
|
||||
isCollaborationLink,
|
||||
loadScene,
|
||||
} from "./data";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { FileStatusStore } from "./data/fileStatusStore";
|
||||
import {
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
@@ -120,6 +127,7 @@ import {
|
||||
LibraryIndexedDBAdapter,
|
||||
LibraryLocalStorageMigrationAdapter,
|
||||
LocalData,
|
||||
localStorageQuotaExceededAtom,
|
||||
} from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
@@ -137,6 +145,9 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
|
||||
import { AppSidebar } from "./components/AppSidebar";
|
||||
|
||||
import type { CollabAPI } from "./collab/Collab";
|
||||
|
||||
polyfill();
|
||||
@@ -220,9 +231,20 @@ const initializeScene = async (opts: {
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: RestoredDataState & {
|
||||
let scene: Omit<
|
||||
RestoredDataState,
|
||||
// we're not storing files in the scene database/localStorage, and instead
|
||||
// fetch them async from a different store
|
||||
"files"
|
||||
> & {
|
||||
scrollToContent?: boolean;
|
||||
} = await loadScene(null, null, localDataState);
|
||||
} = {
|
||||
elements: restoreElements(localDataState?.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
appState: restoreAppState(localDataState?.appState, null),
|
||||
};
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
@@ -236,11 +258,26 @@ const initializeScene = async (opts: {
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
const imported = await importFromBackend(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
localDataState,
|
||||
);
|
||||
|
||||
scene = {
|
||||
elements: bumpElementVersions(
|
||||
restoreElements(imported.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
localDataState?.elements,
|
||||
),
|
||||
appState: restoreAppState(
|
||||
imported.appState,
|
||||
// local appState when importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
localDataState?.appState,
|
||||
),
|
||||
};
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
@@ -335,6 +372,8 @@ const initializeScene = async (opts: {
|
||||
};
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const excalidrawAPI = useExcalidrawAPI();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
@@ -342,6 +381,8 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
const editorInterface = useEditorInterface();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -363,9 +404,6 @@ const ExcalidrawWrapper = () => {
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@@ -397,18 +435,15 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadImages = useCallback(
|
||||
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||
if (!data.scene || !excalidrawAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
@@ -435,6 +470,12 @@ const ExcalidrawWrapper = () => {
|
||||
}, [] as FileId[]) || [];
|
||||
|
||||
if (data.isExternalScene) {
|
||||
if (fileIds.length) {
|
||||
// Direct Firebase call (not through FileManager), so track manually
|
||||
FileStatusStore.updateStatuses(
|
||||
fileIds.map((id) => [id, "loading"]),
|
||||
);
|
||||
}
|
||||
loadFilesFromFirebase(
|
||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||
data.key,
|
||||
@@ -446,12 +487,18 @@ const ExcalidrawWrapper = () => {
|
||||
erroredFiles,
|
||||
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
});
|
||||
FileStatusStore.updateStatuses([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
});
|
||||
} else if (isInitialLoad) {
|
||||
if (fileIds.length) {
|
||||
LocalData.fileStorage
|
||||
.getFiles(fileIds)
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
.then(async ({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
@@ -464,10 +511,19 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
// on fresh load, clear unused files from IDB (from previous
|
||||
// session)
|
||||
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
|
||||
LocalData.fileStorage.clearObsoleteFiles({
|
||||
currentFileIds: fileIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[collabAPI, excalidrawAPI],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
@@ -490,8 +546,10 @@ const ExcalidrawWrapper = () => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
elements: restoreElements(data.scene.elements, null, {
|
||||
repairBindings: true,
|
||||
}),
|
||||
appState: restoreAppState(data.scene.appState, null),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
}
|
||||
@@ -499,11 +557,6 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
@@ -594,9 +647,8 @@ const ExcalidrawWrapper = () => {
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@@ -669,8 +721,8 @@ const ExcalidrawWrapper = () => {
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
elements,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -734,11 +786,63 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||
|
||||
const onCollabDialogOpen = useCallback(
|
||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||
[setShareDialogState],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onExport — intercepts file save to wait for pending image loads
|
||||
// ---------------------------------------------------------------------------
|
||||
const onExport: Required<ExcalidrawProps>["onExport"] = useCallback(
|
||||
async function* () {
|
||||
let snapshot = FileStatusStore.getSnapshot();
|
||||
const { pending, total } = FileStatusStore.getPendingCount(
|
||||
snapshot.value,
|
||||
);
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yield initial progress
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (total - pending) / total,
|
||||
message: `Loading images (${total - pending}/${total})...`,
|
||||
};
|
||||
|
||||
// Wait for all pending images to finish
|
||||
while (true) {
|
||||
snapshot = await FileStatusStore.pull(snapshot.version);
|
||||
const { pending: nowPending, total: nowTotal } =
|
||||
FileStatusStore.getPendingCount(snapshot.value);
|
||||
|
||||
yield {
|
||||
type: "progress",
|
||||
progress: (nowTotal - nowPending) / nowTotal,
|
||||
message: `Loading images (${nowTotal - nowPending}/${nowTotal})...`,
|
||||
};
|
||||
|
||||
if (nowPending === 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
yield {
|
||||
type: "progress",
|
||||
message: `Preparing export...`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// const onExport = () => {
|
||||
// return new Promise((r) => setTimeout(r, 2500));
|
||||
// // console.log("onExport");
|
||||
// };
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
@@ -805,8 +909,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
@@ -852,14 +956,22 @@ const ExcalidrawWrapper = () => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="top-right-ui">
|
||||
<div className="excalidraw-ui-top-right">
|
||||
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
||||
<ExcalidrawPlusPromoBanner
|
||||
isSignedIn={isExcalidrawPlusSignedUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
{collabError.message && <CollabError collabError={collabError} />}
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
editorInterface={editorInterface}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -908,10 +1020,15 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
<div className="alertalert--warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{localStorageQuotaExceeded && (
|
||||
<div className="alert alert--danger">
|
||||
{t("alerts.localStorageQuotaExceeded")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
@@ -940,6 +1057,8 @@ const ExcalidrawWrapper = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppSidebar />
|
||||
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
@@ -1157,7 +1276,9 @@ const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawWrapper />
|
||||
<ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</ExcalidrawAPIProvider>
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
@@ -45,6 +46,7 @@ export const STORAGE_KEYS = {
|
||||
VERSION_FILES: "version-files",
|
||||
|
||||
IDB_LIBRARY: "excalidraw-library",
|
||||
IDB_TTD_CHATS: "excalidraw-ttd-chats",
|
||||
|
||||
// do not use apart from migrations
|
||||
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
reconcileElements,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, EVENT } from "@excalidraw/common";
|
||||
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
@@ -19,12 +19,9 @@ import {
|
||||
throttleRAF,
|
||||
} from "@excalidraw/common";
|
||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
@@ -32,6 +29,8 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
|
||||
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
|
||||
|
||||
import type {
|
||||
ReconciledExcalidrawElement,
|
||||
RemoteExcalidrawElement,
|
||||
@@ -73,6 +72,7 @@ import {
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { FileStatusStore } from "../data/fileStatusStore";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -150,6 +150,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.fileManager = new FileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles: async (fileIds) => {
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
@@ -314,6 +315,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
syncableElements = cloneJSON(syncableElements);
|
||||
try {
|
||||
const storedElements = await saveToFirebase(
|
||||
this.portal,
|
||||
@@ -444,7 +446,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array,
|
||||
iv: Uint8Array<ArrayBuffer>,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
@@ -533,7 +535,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existingRoomLinkData) {
|
||||
if (existingRoomLinkData) {
|
||||
// when joining existing room, don't merge it with current scene data
|
||||
this.excalidrawAPI.resetScene();
|
||||
} else {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
@@ -562,7 +567,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
@@ -579,7 +584,9 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const remoteElements = toBrandedType<
|
||||
readonly RemoteExcalidrawElement[]
|
||||
>(decryptedData.payload.elements);
|
||||
const reconciledElements =
|
||||
this._reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements);
|
||||
@@ -593,7 +600,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this._reconcileElements(decryptedData.payload.elements),
|
||||
this._reconcileElements(
|
||||
toBrandedType<readonly RemoteExcalidrawElement[]>(
|
||||
decryptedData.payload.elements,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
@@ -742,17 +753,28 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private _reconcileElements = (
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
remoteElements: readonly RemoteExcalidrawElement[],
|
||||
): ReconciledExcalidrawElement[] => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||
const reconciledElements = reconcileElements(
|
||||
localElements,
|
||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||
|
||||
const existingElements = this.getSceneElementsIncludingDeleted();
|
||||
|
||||
// NOTE ideally we restore _after_ reconciliation but we can't do that
|
||||
// as we'd regenerate even elements such as appState.newElement which would
|
||||
// break the state
|
||||
remoteElements = restoreElements(remoteElements, existingElements);
|
||||
|
||||
let reconciledElements = reconcileElements(
|
||||
existingElements,
|
||||
remoteElements,
|
||||
appState,
|
||||
);
|
||||
|
||||
reconciledElements = bumpElementVersions(
|
||||
reconciledElements,
|
||||
existingElements,
|
||||
);
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
getTextFromElements,
|
||||
MIME_TYPES,
|
||||
TTDDialog,
|
||||
TTDStreamFetch,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||
import { safelyParseJSON } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { TTDIndexedDBAdapter } from "../data/TTDStorage";
|
||||
|
||||
export const AIComponents = ({
|
||||
excalidrawAPI,
|
||||
}: {
|
||||
@@ -73,7 +76,7 @@ export const AIComponents = ({
|
||||
</br>
|
||||
<div>You can also try <a href="${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noopener">Excalidraw+</a> to get more requests.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
@@ -99,61 +102,23 @@ export const AIComponents = ({
|
||||
/>
|
||||
|
||||
<TTDDialog
|
||||
onTextSubmit={async (input) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ prompt: input }),
|
||||
},
|
||||
);
|
||||
onTextSubmit={async (props) => {
|
||||
const { onChunk, onStreamCreated, signal, messages } = props;
|
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
const result = await TTDStreamFetch({
|
||||
url: `${
|
||||
import.meta.env.VITE_APP_AI_BACKEND
|
||||
}/v1/ai/text-to-diagram/chat-streaming`,
|
||||
messages,
|
||||
onChunk,
|
||||
onStreamCreated,
|
||||
extractRateLimits: true,
|
||||
signal,
|
||||
});
|
||||
|
||||
const rateLimitRemaining = response.headers.has(
|
||||
"X-Ratelimit-Remaining",
|
||||
)
|
||||
? parseInt(
|
||||
response.headers.get("X-Ratelimit-Remaining") || "0",
|
||||
10,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
rateLimit,
|
||||
rateLimitRemaining,
|
||||
error: new Error(
|
||||
"Too many requests today, please try again tomorrow!",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(json.message || "Generation failed...");
|
||||
}
|
||||
|
||||
const generatedResponse = json.generatedResponse;
|
||||
if (!generatedResponse) {
|
||||
throw new Error("Generation failed...");
|
||||
}
|
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining };
|
||||
} catch (err: any) {
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
persistenceAdapter={TTDIndexedDBAdapter}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
|
||||
export const AppFooter = React.memo(
|
||||
({ onChange }: { onChange: () => void }) => {
|
||||
@@ -19,11 +18,7 @@ export const AppFooter = React.memo(
|
||||
}}
|
||||
>
|
||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
|
||||
{isDevEnv() && (
|
||||
<MainMenu.Item
|
||||
icon={eyeIcon}
|
||||
onClick={() => {
|
||||
onSelect={() => {
|
||||
if (window.visualDebug) {
|
||||
delete window.visualDebug;
|
||||
saveDebugState({ enabled: false });
|
||||
@@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.Preferences />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
theme={props.theme}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.excalidraw {
|
||||
.app-sidebar-promo-container {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-image {
|
||||
margin: 1rem 0;
|
||||
|
||||
height: 16.25rem;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
transparent 60%,
|
||||
var(--sidebar-bg-color) 100%
|
||||
),
|
||||
var(--image-source);
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-text {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
messageCircleIcon,
|
||||
presentationIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
|
||||
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||
|
||||
import "./AppSidebar.scss";
|
||||
|
||||
export const AppSidebar = () => {
|
||||
const { theme, openSidebar } = useUIAppState();
|
||||
|
||||
return (
|
||||
<DefaultSidebar>
|
||||
<DefaultSidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger
|
||||
tab="comments"
|
||||
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
|
||||
>
|
||||
{messageCircleIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger
|
||||
tab="presentation"
|
||||
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
|
||||
>
|
||||
{presentationIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
</DefaultSidebar.TabTriggers>
|
||||
<Sidebar.Tab tab="comments">
|
||||
<div className="app-sidebar-promo-container">
|
||||
<div
|
||||
className="app-sidebar-promo-image"
|
||||
style={{
|
||||
["--image-source" as any]: `url(/oss_promo_comments_${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.jpg)`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<div className="app-sidebar-promo-text">
|
||||
Make comments with Excalidraw+
|
||||
</div>
|
||||
<LinkButton
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
|
||||
>
|
||||
Sign up now
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Sidebar.Tab>
|
||||
<Sidebar.Tab tab="presentation" className="px-3">
|
||||
<div className="app-sidebar-promo-container">
|
||||
<div
|
||||
className="app-sidebar-promo-image"
|
||||
style={{
|
||||
["--image-source" as any]: `url(/oss_promo_presentations_${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.svg)`,
|
||||
backgroundSize: "60%",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<div className="app-sidebar-promo-text">
|
||||
Create presentations with Excalidraw+
|
||||
</div>
|
||||
<LinkButton
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
|
||||
>
|
||||
Sign up now
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Sidebar.Tab>
|
||||
</DefaultSidebar>
|
||||
);
|
||||
};
|
||||
@@ -33,7 +33,15 @@ export const AppWelcomeScreen: React.FC<{
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
headingContent = t("welcomeScreen.app.center_heading");
|
||||
headingContent = (
|
||||
<>
|
||||
{t("welcomeScreen.app.center_heading")}
|
||||
<br />
|
||||
{t("welcomeScreen.app.center_heading_line2")}
|
||||
<br />
|
||||
{t("welcomeScreen.app.center_heading_line3")}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,8 +8,14 @@ import {
|
||||
getNormalizedCanvasDimensions,
|
||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { arrayToMap, throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isLineSegment,
|
||||
@@ -18,9 +24,20 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||
import React from "react";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type {
|
||||
DebugElement,
|
||||
DebugPolygon,
|
||||
} from "@excalidraw/element/visualdebug";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
FixedPointBinding,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
@@ -61,6 +78,44 @@ const renderCubicBezier = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderPolygon = (
|
||||
context: CanvasRenderingContext2D,
|
||||
zoom: number,
|
||||
polygon: DebugPolygon,
|
||||
color: string,
|
||||
) => {
|
||||
const { points, fill, close } = polygon;
|
||||
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
|
||||
}
|
||||
if (close !== false) {
|
||||
context.closePath();
|
||||
}
|
||||
|
||||
if (fill) {
|
||||
context.save();
|
||||
context.globalAlpha = 0.15;
|
||||
context.fillStyle = color;
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
context.strokeStyle = color;
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const isDebugPolygon = (data: DebugElement["data"]): data is DebugPolygon =>
|
||||
(data as DebugPolygon).type === "polygon";
|
||||
|
||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||
context.strokeStyle = "#888";
|
||||
context.save();
|
||||
@@ -73,6 +128,176 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||
context.save();
|
||||
};
|
||||
|
||||
const _renderBinding = (
|
||||
context: CanvasRenderingContext2D,
|
||||
binding: FixedPointBinding,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) => {
|
||||
if (!binding.fixedPoint) {
|
||||
console.warn("Binding must have a fixedPoint");
|
||||
return;
|
||||
}
|
||||
|
||||
const bindable = elementsMap.get(
|
||||
binding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||
binding.fixedPoint,
|
||||
bindable,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(x * zoom, y * zoom);
|
||||
context.bezierCurveTo(
|
||||
x * zoom - width,
|
||||
y * zoom - height,
|
||||
x * zoom - width,
|
||||
y * zoom + height,
|
||||
x * zoom,
|
||||
y * zoom,
|
||||
);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const _renderBindableBinding = (
|
||||
binding: FixedPointBinding,
|
||||
context: CanvasRenderingContext2D,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) => {
|
||||
const bindable = elementsMap.get(
|
||||
binding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
if (!binding.fixedPoint) {
|
||||
console.warn("Binding must have a fixedPoint");
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||
binding.fixedPoint,
|
||||
bindable,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(x * zoom, y * zoom);
|
||||
context.bezierCurveTo(
|
||||
x * zoom + width,
|
||||
y * zoom + height,
|
||||
x * zoom + width,
|
||||
y * zoom - height,
|
||||
x * zoom,
|
||||
y * zoom,
|
||||
);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderBindings = (
|
||||
context: CanvasRenderingContext2D,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
zoom: number,
|
||||
) => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const dim = 16;
|
||||
elements.forEach((element) => {
|
||||
if (element.isDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArrowElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (
|
||||
!elementsMap
|
||||
.get(element.startBinding.elementId)
|
||||
?.boundElements?.find((e) => e.id === element.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
_renderBinding(
|
||||
context,
|
||||
element.startBinding,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
element.startBinding?.mode === "orbit" ? "red" : "black",
|
||||
);
|
||||
}
|
||||
|
||||
if (element.endBinding) {
|
||||
if (
|
||||
!elementsMap
|
||||
.get(element.endBinding.elementId)
|
||||
?.boundElements?.find((e) => e.id === element.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
_renderBinding(
|
||||
context,
|
||||
element.endBinding,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
element.endBinding?.mode === "orbit" ? "red" : "black",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isBindableElement(element) && element.boundElements?.length) {
|
||||
element.boundElements.forEach((boundElement) => {
|
||||
if (boundElement.type !== "arrow") {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrow = elementsMap.get(
|
||||
boundElement.id,
|
||||
) as ExcalidrawArrowElement;
|
||||
|
||||
if (arrow && arrow.startBinding?.elementId === element.id) {
|
||||
_renderBindableBinding(
|
||||
arrow.startBinding,
|
||||
context,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
"green",
|
||||
);
|
||||
}
|
||||
if (arrow && arrow.endBinding?.elementId === element.id) {
|
||||
_renderBindableBinding(
|
||||
arrow.endBinding,
|
||||
context,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
"green",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const render = (
|
||||
frame: DebugElement[],
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -96,6 +321,9 @@ const render = (
|
||||
el.color,
|
||||
);
|
||||
break;
|
||||
case isDebugPolygon(el.data):
|
||||
renderPolygon(context, appState.zoom.value, el.data, el.color);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
||||
}
|
||||
@@ -105,18 +333,14 @@ const render = (
|
||||
const _debugRenderer = (
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||
canvas,
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
@@ -133,6 +357,7 @@ const _debugRenderer = (
|
||||
);
|
||||
|
||||
renderOrigin(context, appState.zoom.value);
|
||||
renderBindings(context, elements, appState.zoom.value);
|
||||
|
||||
if (
|
||||
window.visualDebug?.currentFrame &&
|
||||
@@ -184,12 +409,11 @@ export const debugRenderer = throttleRAF(
|
||||
(
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, scale, refresh);
|
||||
_debugRenderer(canvas, appState, elements, scale);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
export const loadSavedDebugState = () => {
|
||||
@@ -314,35 +538,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
ref?: React.Ref<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||
const { width, height } = appState;
|
||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||
ref,
|
||||
() => canvasRef.current,
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={canvasRef}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={ref}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default DebugCanvas;
|
||||
|
||||
@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
rel="noopener"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
export const ExcalidrawPlusAppLink = () => {
|
||||
if (!isExcalidrawPlusSignedUser) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="plus-button"
|
||||
>
|
||||
Go to Excalidraw+
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export const ExcalidrawPlusPromoBanner = ({
|
||||
isSignedIn,
|
||||
}: {
|
||||
isSignedIn: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={
|
||||
isSignedIn
|
||||
? import.meta.env.VITE_APP_PLUS_APP
|
||||
: `${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="plus-banner"
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
generateEncryptionKey,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { THEME } from "@excalidraw/common";
|
||||
import oc from "open-color";
|
||||
import React from "react";
|
||||
|
||||
import type { Theme } from "@excalidraw/element/types";
|
||||
|
||||
// https://github.com/tholman/github-corners
|
||||
export const GitHubCorner = React.memo(
|
||||
({ theme, dir }: { theme: Theme; dir: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 250 250"
|
||||
className="rtl-mirror"
|
||||
style={{
|
||||
marginTop: "calc(var(--space-factor) * -1)",
|
||||
[dir === "rtl" ? "marginLeft" : "marginRight"]:
|
||||
"calc(var(--space-factor) * -1)",
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub repository"
|
||||
>
|
||||
<path
|
||||
d="M0 0l115 115h15l12 27 108 108V0z"
|
||||
fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
|
||||
/>
|
||||
<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={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
<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={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
|
||||
/>
|
||||
</a>
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
@@ -40,10 +40,12 @@ export class FileManager {
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
private _onFileStatusChange;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
onFileStatusChange,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
@@ -53,9 +55,13 @@ export class FileManager {
|
||||
savedFiles: Map<FileId, BinaryFileData>;
|
||||
erroredFiles: Map<FileId, BinaryFileData>;
|
||||
}>;
|
||||
onFileStatusChange?: (
|
||||
updates: Array<[FileId, "loading" | "loaded" | "error"]>,
|
||||
) => void;
|
||||
}) {
|
||||
this._getFiles = getFiles;
|
||||
this._saveFiles = saveFiles;
|
||||
this._onFileStatusChange = onFileStatusChange;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +152,8 @@ export class FileManager {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
@@ -156,6 +164,13 @@ export class FileManager {
|
||||
this.erroredFiles_fetch.set(fileId, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.([
|
||||
...loadedFiles.map((f) => [f.id, "loaded"] as [FileId, "loaded"]),
|
||||
...[...erroredFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
]);
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
} finally {
|
||||
for (const id of ids) {
|
||||
@@ -195,6 +210,13 @@ export class FileManager {
|
||||
};
|
||||
|
||||
reset() {
|
||||
if (this._onFileStatusChange && this.fetchingFiles.size) {
|
||||
this._onFileStatusChange(
|
||||
[...this.fetchingFiles.keys()].map(
|
||||
(id) => [id, "error"] as [FileId, "error"],
|
||||
),
|
||||
);
|
||||
}
|
||||
this.fetchingFiles.clear();
|
||||
this.savingFiles.clear();
|
||||
this.savedFiles.clear();
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
DEFAULT_SIDEBAR,
|
||||
debounce,
|
||||
} from "@excalidraw/common";
|
||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
||||
import {
|
||||
createStore,
|
||||
entries,
|
||||
@@ -27,6 +26,9 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
|
||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||
@@ -40,11 +42,14 @@ import type { MaybePromise } from "@excalidraw/common/utility-types";
|
||||
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
import { FileManager } from "./FileManager";
|
||||
import { FileStatusStore } from "./fileStatusStore";
|
||||
import { Locker } from "./Locker";
|
||||
import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
export const localStorageQuotaExceededAtom = atom(false);
|
||||
|
||||
class LocalFileManager extends FileManager {
|
||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||
await entries(filesStore).then((entries) => {
|
||||
@@ -69,6 +74,9 @@ const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||
localStorageQuotaExceededAtom,
|
||||
);
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
@@ -81,19 +89,29 @@ const saveDataStateToLocalStorage = (
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
JSON.stringify(getNonDeletedElements(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
if (localStorageQuotaExceeded) {
|
||||
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
|
||||
appJotaiStore.set(localStorageQuotaExceededAtom, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isQuotaExceededError = (error: any) => {
|
||||
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||
};
|
||||
|
||||
type SavingLockTypes = "collaboration";
|
||||
|
||||
export class LocalData {
|
||||
@@ -149,6 +167,7 @@ export class LocalData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createStore, get, set } from "idb-keyval";
|
||||
|
||||
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
/**
|
||||
* IndexedDB adapter for TTD chat storage.
|
||||
* Implements TTDPersistenceAdapter interface.
|
||||
*/
|
||||
export class TTDIndexedDBAdapter {
|
||||
/** IndexedDB database name */
|
||||
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
|
||||
/** Store key for chat data */
|
||||
private static key = "ttdChats";
|
||||
|
||||
private static store = createStore(
|
||||
`${TTDIndexedDBAdapter.idb_name}-db`,
|
||||
`${TTDIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Load saved chats from IndexedDB.
|
||||
* @returns Promise resolving to saved chats array (empty if none found)
|
||||
*/
|
||||
static async loadChats(): Promise<SavedChats> {
|
||||
try {
|
||||
const data = await get<SavedChats>(
|
||||
TTDIndexedDBAdapter.key,
|
||||
TTDIndexedDBAdapter.store,
|
||||
);
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.warn("Failed to load TTD chats from IndexedDB:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save chats to IndexedDB.
|
||||
* @param chats - The chats array to persist
|
||||
*/
|
||||
static async saveChats(chats: SavedChats): Promise<void> {
|
||||
try {
|
||||
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save TTD chats to IndexedDB:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { VersionedSnapshotStore } from "@excalidraw/common";
|
||||
|
||||
import type { FileId } from "@excalidraw/element/types";
|
||||
|
||||
export type FileLoadingStatus = "loading" | "loaded" | "error";
|
||||
|
||||
export class FileStatusStore {
|
||||
private static store = new VersionedSnapshotStore<
|
||||
Map<FileId, FileLoadingStatus>
|
||||
>(new Map());
|
||||
|
||||
static getSnapshot() {
|
||||
return this.store.getSnapshot();
|
||||
}
|
||||
|
||||
static pull(sinceVersion?: number) {
|
||||
return this.store.pull(sinceVersion);
|
||||
}
|
||||
|
||||
static updateStatuses(updates: Array<[FileId, FileLoadingStatus]>) {
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
this.store.update((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [id, status] of updates) {
|
||||
if (next.get(id) !== status) {
|
||||
next.set(id, status);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
static getPendingCount(statuses: Map<FileId, FileLoadingStatus>) {
|
||||
let pending = 0;
|
||||
let total = 0;
|
||||
for (const status of statuses.values()) {
|
||||
total++;
|
||||
if (status === "loading") {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return { pending, total };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { reconcileElements } from "@excalidraw/excalidraw";
|
||||
import { MIME_TYPES } from "@excalidraw/common";
|
||||
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
|
||||
import { decompressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import {
|
||||
encryptData,
|
||||
@@ -105,8 +105,8 @@ const decryptElements = async (
|
||||
data: FirebaseStoredScene,
|
||||
roomKey: string,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const ciphertext = data.ciphertext.toUint8Array();
|
||||
const iv = data.iv.toUint8Array();
|
||||
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
|
||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||
|
||||
return storedElements;
|
||||
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -8,15 +8,14 @@ import {
|
||||
IV_LENGTH_BYTES,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { bytesToHexString } from "@excalidraw/common";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
||||
import type { SceneBounds } from "@excalidraw/element";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
@@ -84,13 +83,13 @@ export type SocketUpdateDataSource = {
|
||||
SCENE_INIT: {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
@@ -200,7 +199,7 @@ const legacy_decodeFromBackend = async ({
|
||||
};
|
||||
};
|
||||
|
||||
const importFromBackend = async (
|
||||
export const importFromBackend = async (
|
||||
id: string,
|
||||
decryptionKey: string,
|
||||
): Promise<ImportedDataState> => {
|
||||
@@ -242,40 +241,6 @@ const importFromBackend = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScene = async (
|
||||
id: string | null,
|
||||
privateKey: string | null,
|
||||
// Supply local state even if importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
||||
localDataState: ImportedDataState | undefined | null,
|
||||
) => {
|
||||
let data;
|
||||
if (id != null && privateKey != null) {
|
||||
// the private key is used to decrypt the content from the server, take
|
||||
// extra care not to leak it
|
||||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
// note: this will always be empty because we're not storing files
|
||||
// in the scene database/localStorage, and instead fetch them async
|
||||
// from a different database
|
||||
files: data.files,
|
||||
};
|
||||
};
|
||||
|
||||
type ExportToBackendResult =
|
||||
| { url: null; errorMessage: string }
|
||||
| { url: string; errorMessage: null };
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "@excalidraw/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -50,7 +49,7 @@ export const importFromLocalStorage = () => {
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
||||
elements = JSON.parse(savedElements);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// Do nothing because elements array is already empty
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<title>Excalidraw Whiteboard</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
+27
-14
@@ -1,3 +1,5 @@
|
||||
@import "../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||
|
||||
@@ -5,12 +7,6 @@
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
@@ -58,7 +54,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.collab-offline-warning {
|
||||
.alert {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
@@ -69,10 +65,18 @@
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
z-index: 6;
|
||||
white-space: pre;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: var(--color-danger-dark);
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,22 +86,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
.plus-button {
|
||||
.plus-banner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--color-primary) !important;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.8333rem;
|
||||
box-sizing: border-box;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--button-color, var(--color-on-surface)) !important;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
@@ -109,7 +122,7 @@
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-button {
|
||||
.plus-banner {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
@@ -36,6 +36,7 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"socket.io-client": "4.7.2",
|
||||
"uqr": "0.1.2",
|
||||
"vite-plugin-html": "3.2.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getFeatureFlag } from "@excalidraw/common";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import callsites from "callsites";
|
||||
|
||||
@@ -33,6 +34,7 @@ Sentry.init({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
Sentry.featureFlagsIntegration(),
|
||||
],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
@@ -79,3 +81,14 @@ Sentry.init({
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
const flagsIntegration =
|
||||
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||
"FeatureFlags",
|
||||
);
|
||||
if (flagsIntegration) {
|
||||
flagsIntegration.addFeatureFlag(
|
||||
"COMPLEX_BINDINGS",
|
||||
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Spinner from "@excalidraw/excalidraw/components/Spinner";
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const QRCode = ({ value }: QRCodeProps) => {
|
||||
const [svgData, setSvgData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
import("./qrcode.chunk")
|
||||
.then(({ generateQRCodeSVG }) => {
|
||||
if (mounted) {
|
||||
try {
|
||||
setSvgData(generateQRCodeSVG(value));
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!svgData) {
|
||||
return (
|
||||
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ShareDialog__active__qrcode"
|
||||
role="img"
|
||||
aria-label="QR code for collaboration link"
|
||||
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -140,6 +140,31 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
$size: 150px;
|
||||
width: $size;
|
||||
height: $size;
|
||||
|
||||
& svg {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
import { QRCode } from "./QRCode";
|
||||
|
||||
import type { CollabAPI } from "../collab/Collab";
|
||||
|
||||
@@ -142,6 +143,7 @@ const ActiveRoomDialog = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<QRCode value={activeRoomLink} />
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { renderSVG } from "uqr";
|
||||
|
||||
export const generateQRCodeSVG = (text: string): string => {
|
||||
return renderSVG(text);
|
||||
};
|
||||
@@ -17,30 +17,15 @@ describe("Test MobileMenu", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
h.app.refreshEditorInterface();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should set device correctly", () => {
|
||||
expect(h.app.device).toMatchInlineSnapshot(`
|
||||
{
|
||||
"editor": {
|
||||
"canFitSidebar": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
it("should set editor interface correctly", () => {
|
||||
expect(h.app.editorInterface.formFactor).toBe("phone");
|
||||
});
|
||||
|
||||
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||
|
||||
@@ -50,7 +50,11 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||
>
|
||||
All your data is saved locally in your browser.
|
||||
Your drawings are saved in your browser's storage.
|
||||
<br />
|
||||
Browser storage can be cleared unexpectedly.
|
||||
<br />
|
||||
Save your work to a file regularly to avoid losing it.
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu"
|
||||
@@ -198,7 +202,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<a
|
||||
class="welcome-screen-menu-item "
|
||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
rel="noreferrer"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -3,11 +3,15 @@ import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
|
||||
import { syncInvalidIndices } from "@excalidraw/element";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { StoreIncrement } from "@excalidraw/element";
|
||||
|
||||
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
||||
|
||||
import ExcalidrawApp from "../App";
|
||||
|
||||
const { h } = window;
|
||||
@@ -65,6 +69,79 @@ vi.mock("socket.io-client", () => {
|
||||
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||
*/
|
||||
describe("collaboration", () => {
|
||||
it("should emit two ephemeral increments even though updates get batched", async () => {
|
||||
const durableIncrements: DurableIncrement[] = [];
|
||||
const ephemeralIncrements: EphemeralIncrement[] = [];
|
||||
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
h.store.onStoreIncrementEmitter.on((increment) => {
|
||||
if (StoreIncrement.isDurable(increment)) {
|
||||
durableIncrements.push(increment);
|
||||
} else {
|
||||
ephemeralIncrements.push(increment);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
expect(durableIncrements.length).toBe(0);
|
||||
expect(ephemeralIncrements.length).toBe(0);
|
||||
|
||||
const rectProps = {
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
height: 200,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
} as const;
|
||||
|
||||
const rect = API.createElement({ ...rectProps });
|
||||
|
||||
API.updateScene({
|
||||
elements: [rect],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
||||
expect(durableIncrements.length).toBe(1);
|
||||
});
|
||||
|
||||
// simulate two batched remote updates
|
||||
act(() => {
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 100 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
h.app.updateScene({
|
||||
elements: [newElementWith(h.elements[0], { x: 200 })],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// we scheduled two micro actions,
|
||||
// which confirms they are going to be executed as part of one batched component update
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// altough the updates get batched,
|
||||
// we expect two ephemeral increments for each update,
|
||||
// and each such update should have the expected change
|
||||
expect(ephemeralIncrements.length).toBe(2);
|
||||
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 100 }),
|
||||
);
|
||||
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
||||
expect.objectContaining({ x: 200 }),
|
||||
);
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
const rect1Props = {
|
||||
@@ -122,12 +199,13 @@ describe("collaboration", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
const undoAction = createUndoAction(h.history);
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||
@@ -154,7 +232,7 @@ describe("collaboration", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
const redoAction = createRedoAction(h.history);
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||
@@ -170,79 +248,5 @@ describe("collaboration", () => {
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// simulate local update
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// we expect to iterate the stack to the first visible change
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// snapshot was correctly updated and marked the element as deleted
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,14 @@ export default defineConfig(({ mode }) => {
|
||||
// Taking the substring after "locales/"
|
||||
return `locales/${id.substring(index + 8)}`;
|
||||
}
|
||||
|
||||
if (id.includes("@excalidraw/mermaid-to-excalidraw")) {
|
||||
return "mermaid-to-excalidraw";
|
||||
}
|
||||
|
||||
if (id.includes("@codemirror/") || id.includes("@lezer/")) {
|
||||
return "codemirror.chunk";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -146,6 +154,11 @@ export default defineConfig(({ mode }) => {
|
||||
"**/locales/**",
|
||||
"service-worker.js",
|
||||
"**/*.chunk-*.js",
|
||||
// CodeMirrorEditor can't be assigned a `.chunk` name via
|
||||
// manualChunks because Rollup would hoist shared deps (React)
|
||||
// via a static import from the main bundle, defeating lazy
|
||||
// loading. So we exclude it by name instead.
|
||||
"**/CodeMirrorEditor-*.js",
|
||||
],
|
||||
runtimeCaching: [
|
||||
{
|
||||
@@ -185,7 +198,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp(".chunk-.+.js"),
|
||||
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "chunk",
|
||||
@@ -196,6 +209,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
|
||||
+15
-9
@@ -33,7 +33,8 @@
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
@@ -43,7 +44,7 @@
|
||||
"vitest-canvas-mock": "0.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"homepage": ".",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
@@ -51,13 +52,17 @@
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:common": "yarn --cwd ./packages/common build:esm",
|
||||
"build:element": "yarn --cwd ./packages/element build:esm",
|
||||
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:math": "yarn --cwd ./packages/math build:esm",
|
||||
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@@ -75,11 +80,12 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
||||
"rm:node_modules": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||
"release": "node scripts/release.js",
|
||||
"release:test": "node scripts/release.js --tag=test",
|
||||
"release:next": "node scripts/release.js --tag=next",
|
||||
"release:latest": "node scripts/release.js --tag=latest",
|
||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
debug: typeof Debug;
|
||||
}
|
||||
}
|
||||
|
||||
const lessPrecise = (num: number, precision = 5) =>
|
||||
parseFloat(num.toPrecision(precision));
|
||||
|
||||
const getAvgFrameTime = (times: number[]) =>
|
||||
lessPrecise(times.reduce((a, b) => a + b) / times.length);
|
||||
|
||||
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
|
||||
|
||||
export class Debug {
|
||||
public static DEBUG_LOG_TIMES = true;
|
||||
|
||||
@@ -24,34 +16,35 @@ export class Debug {
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static LAST_FRAME_TIMESTAMP = 0;
|
||||
private static FRAME_COUNT = 0;
|
||||
private static ANIMATION_FRAME_ID: null | number = null;
|
||||
|
||||
private static scheduleAnimationFrame = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
|
||||
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
|
||||
Debug.LAST_FRAME_TIMESTAMP = timestamp;
|
||||
Debug.FRAME_COUNT++;
|
||||
}
|
||||
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||
Debug.scheduleAnimationFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
Debug.scheduleAnimationFrame();
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
@@ -65,8 +58,18 @@ export class Debug {
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
// const avgFrameTime = getAvgFrameTime(times);
|
||||
const totalTime = times.reduce((a, b) => a + b);
|
||||
const avgFrameTime = lessPrecise(totalTime / Debug.FRAME_COUNT);
|
||||
console.info(
|
||||
name,
|
||||
`- ${times.length} calls - ${avgFrameTime}ms/frame across ${
|
||||
Debug.FRAME_COUNT
|
||||
} frames (${lessPrecise(
|
||||
(avgFrameTime / 16.67) * 100,
|
||||
1,
|
||||
)}% of frame budget)`,
|
||||
);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
@@ -76,6 +79,24 @@ export class Debug {
|
||||
}
|
||||
}
|
||||
}
|
||||
Debug.FRAME_COUNT = 0;
|
||||
|
||||
// Check for stop condition after logging
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
|
||||
Debug.ANIMATION_FRAME_ID = null;
|
||||
Debug.FRAME_COUNT = 0;
|
||||
Debug.LAST_FRAME_TIMESTAMP = 0;
|
||||
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
@@ -109,7 +130,7 @@ export class Debug {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
Debug[type](performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
@@ -130,6 +151,70 @@ export class Debug {
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
|
||||
private static CHANGED_CACHE: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
public static logChanged(name: string, obj: Record<string, unknown>) {
|
||||
const prev = Debug.CHANGED_CACHE[name];
|
||||
|
||||
Debug.CHANGED_CACHE[name] = obj;
|
||||
|
||||
if (!prev) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]);
|
||||
const changed: Record<string, { prev: unknown; next: unknown }> = {};
|
||||
|
||||
for (const key of allKeys) {
|
||||
const prevVal = prev[key];
|
||||
const nextVal = obj[key];
|
||||
if (!deepEqual(prevVal, nextVal)) {
|
||||
changed[key] = { prev: prevVal, next: nextVal };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(changed).length > 0) {
|
||||
console.info(`[${name}] changed:`, changed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
a === null ||
|
||||
b === null ||
|
||||
typeof a !== "object" ||
|
||||
typeof b !== "object"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) !== Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a as Record<string, unknown>);
|
||||
const keysB = Object.keys(b as Record<string, unknown>);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keysA) {
|
||||
if (
|
||||
!deepEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key],
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
//@ts-ignore
|
||||
window.debug = Debug;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.1.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,7 +13,10 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
||||
"types": "./dist/types/common/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -50,7 +53,13 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinycolor2": "1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tinycolor2": "1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
|
||||
{
|
||||
"black": "#1e1e1e",
|
||||
"blue": [
|
||||
"#e7f5ff",
|
||||
"#a5d8ff",
|
||||
"#4dabf7",
|
||||
"#228be6",
|
||||
"#1971c2",
|
||||
],
|
||||
"bronze": [
|
||||
"#f8f1ee",
|
||||
"#eaddd7",
|
||||
"#d2bab0",
|
||||
"#a18072",
|
||||
"#846358",
|
||||
],
|
||||
"cyan": [
|
||||
"#e3fafc",
|
||||
"#99e9f2",
|
||||
"#3bc9db",
|
||||
"#15aabf",
|
||||
"#0c8599",
|
||||
],
|
||||
"grape": [
|
||||
"#f8f0fc",
|
||||
"#eebefa",
|
||||
"#da77f2",
|
||||
"#be4bdb",
|
||||
"#9c36b5",
|
||||
],
|
||||
"gray": [
|
||||
"#f8f9fa",
|
||||
"#e9ecef",
|
||||
"#ced4da",
|
||||
"#868e96",
|
||||
"#343a40",
|
||||
],
|
||||
"green": [
|
||||
"#ebfbee",
|
||||
"#b2f2bb",
|
||||
"#69db7c",
|
||||
"#40c057",
|
||||
"#2f9e44",
|
||||
],
|
||||
"orange": [
|
||||
"#fff4e6",
|
||||
"#ffd8a8",
|
||||
"#ffa94d",
|
||||
"#fd7e14",
|
||||
"#e8590c",
|
||||
],
|
||||
"pink": [
|
||||
"#fff0f6",
|
||||
"#fcc2d7",
|
||||
"#f783ac",
|
||||
"#e64980",
|
||||
"#c2255c",
|
||||
],
|
||||
"red": [
|
||||
"#fff5f5",
|
||||
"#ffc9c9",
|
||||
"#ff8787",
|
||||
"#fa5252",
|
||||
"#e03131",
|
||||
],
|
||||
"teal": [
|
||||
"#e6fcf5",
|
||||
"#96f2d7",
|
||||
"#38d9a9",
|
||||
"#12b886",
|
||||
"#099268",
|
||||
],
|
||||
"transparent": "transparent",
|
||||
"violet": [
|
||||
"#f3f0ff",
|
||||
"#d0bfff",
|
||||
"#9775fa",
|
||||
"#7950f2",
|
||||
"#6741d9",
|
||||
],
|
||||
"white": "#ffffff",
|
||||
"yellow": [
|
||||
"#fff9db",
|
||||
"#ffec99",
|
||||
"#ffd43b",
|
||||
"#fab005",
|
||||
"#f08c00",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
|
||||
{
|
||||
"black": "#d3d3d3",
|
||||
"blue": [
|
||||
"#121e26",
|
||||
"#154162",
|
||||
"#2273b4",
|
||||
"#3791e0",
|
||||
"#56a2e8",
|
||||
],
|
||||
"bronze": [
|
||||
"#221c1a",
|
||||
"#362b26",
|
||||
"#5a463d",
|
||||
"#917569",
|
||||
"#a98d84",
|
||||
],
|
||||
"cyan": [
|
||||
"#0a1e20",
|
||||
"#004149",
|
||||
"#007281",
|
||||
"#0f8fa1",
|
||||
"#3da5b6",
|
||||
],
|
||||
"grape": [
|
||||
"#211a25",
|
||||
"#5b3165",
|
||||
"#a954be",
|
||||
"#d471ed",
|
||||
"#e28af8",
|
||||
],
|
||||
"gray": [
|
||||
"#161718",
|
||||
"#202325",
|
||||
"#33383d",
|
||||
"#6e757c",
|
||||
"#b7bcc1",
|
||||
],
|
||||
"green": [
|
||||
"#0f1d12",
|
||||
"#043b0c",
|
||||
"#056715",
|
||||
"#16842a",
|
||||
"#39994b",
|
||||
],
|
||||
"orange": [
|
||||
"#22190d",
|
||||
"#4c2b01",
|
||||
"#924800",
|
||||
"#cd6005",
|
||||
"#f17634",
|
||||
],
|
||||
"pink": [
|
||||
"#26191e",
|
||||
"#602e40",
|
||||
"#b04d70",
|
||||
"#f56e9d",
|
||||
"#ff8dbc",
|
||||
],
|
||||
"red": [
|
||||
"#1f1717",
|
||||
"#5a2c2c",
|
||||
"#b44d4d",
|
||||
"#fa6969",
|
||||
"#ff8383",
|
||||
],
|
||||
"teal": [
|
||||
"#0a1d17",
|
||||
"#00422b",
|
||||
"#00744b",
|
||||
"#039267",
|
||||
"#32a783",
|
||||
],
|
||||
"transparent": "#ededed00",
|
||||
"violet": [
|
||||
"#1f1c29",
|
||||
"#4a3b72",
|
||||
"#8a6cdf",
|
||||
"#a885ff",
|
||||
"#b595ff",
|
||||
],
|
||||
"white": "#121212",
|
||||
"yellow": [
|
||||
"#1e1900",
|
||||
"#362600",
|
||||
"#5f3a00",
|
||||
"#905000",
|
||||
"#b86200",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AppEventBus } from "./appEventBus";
|
||||
|
||||
type TestEvents = {
|
||||
initialize: [api: number];
|
||||
pointerUp: [pointerId: string];
|
||||
viewState: [zoom: number];
|
||||
};
|
||||
|
||||
const behavior = {
|
||||
initialize: { cardinality: "once", replay: "last" },
|
||||
pointerUp: { cardinality: "many", replay: "none" },
|
||||
viewState: { cardinality: "many", replay: "last" },
|
||||
} as const;
|
||||
|
||||
const flushMicrotasks = async () => Promise.resolve();
|
||||
|
||||
describe("AppEventBus", () => {
|
||||
it("replays once events to late callback and Promise subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 42);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("initialize", (value) => {
|
||||
calls.push(value);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([42]);
|
||||
|
||||
await expect(bus.on("initialize")).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not replay stream events to late subscribers", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("pointerUp", "first");
|
||||
|
||||
const calls: string[] = [];
|
||||
bus.on("pointerUp", (pointerId) => {
|
||||
calls.push(pointerId);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([]);
|
||||
|
||||
bus.emit("pointerUp", "second");
|
||||
expect(calls).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("replays replay-last stream events and stays subscribed", async () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("viewState", 1);
|
||||
|
||||
const calls: number[] = [];
|
||||
bus.on("viewState", (zoom) => {
|
||||
calls.push(zoom);
|
||||
});
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(calls).toEqual([1]);
|
||||
|
||||
bus.emit("viewState", 2);
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("throws when emitting a once event twice", () => {
|
||||
const bus = new AppEventBus<TestEvents, typeof behavior>(behavior);
|
||||
bus.emit("initialize", 1);
|
||||
|
||||
expect(() => {
|
||||
bus.emit("initialize", 2);
|
||||
}).toThrow('Event "initialize" can only be emitted once');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { Emitter } from "./emitter";
|
||||
import { isProdEnv } from "./utils";
|
||||
|
||||
export type AppEventPayloadMap = Record<string, unknown[]>;
|
||||
|
||||
export type AppEventBehavior = {
|
||||
cardinality: "once" | "many";
|
||||
replay: "none" | "last";
|
||||
};
|
||||
|
||||
export type AppEventBehaviorMap<Events extends AppEventPayloadMap> = {
|
||||
[K in keyof Events]: AppEventBehavior;
|
||||
};
|
||||
|
||||
type AwaitableAppEventKeys<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> = {
|
||||
[K in keyof Events]: Behavior[K]["cardinality"] extends "once"
|
||||
? Behavior[K]["replay"] extends "last"
|
||||
? K
|
||||
: never
|
||||
: never;
|
||||
}[keyof Events];
|
||||
|
||||
type AppEventPromiseValue<Args extends any[]> = Args extends [infer Only]
|
||||
? Only
|
||||
: Args;
|
||||
|
||||
export class AppEventBus<
|
||||
Events extends AppEventPayloadMap,
|
||||
Behavior extends AppEventBehaviorMap<Events>,
|
||||
> {
|
||||
private readonly emitters = new Map<keyof Events, Emitter<any>>();
|
||||
private readonly lastPayload = new Map<keyof Events, any[]>();
|
||||
private readonly emittedOnce = new Set<keyof Events>();
|
||||
|
||||
constructor(private readonly behavior: Behavior) {}
|
||||
|
||||
private getEmitter<K extends keyof Events>(name: K): Emitter<Events[K]> {
|
||||
let emitter = this.emitters.get(name);
|
||||
if (!emitter) {
|
||||
emitter = new Emitter<any>();
|
||||
this.emitters.set(name, emitter);
|
||||
}
|
||||
return emitter as Emitter<Events[K]>;
|
||||
}
|
||||
|
||||
private toPromiseValue<Args extends any[]>(
|
||||
args: Args,
|
||||
): AppEventPromiseValue<Args> {
|
||||
return (args.length === 1 ? args[0] : args) as AppEventPromiseValue<Args>;
|
||||
}
|
||||
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback;
|
||||
public on<K extends AwaitableAppEventKeys<Events, Behavior>>(
|
||||
name: K,
|
||||
): Promise<AppEventPromiseValue<Events[K]>>;
|
||||
public on<K extends keyof Events>(
|
||||
name: K,
|
||||
callback?: (...args: Events[K]) => void,
|
||||
): UnsubscribeCallback | Promise<AppEventPromiseValue<Events[K]>> {
|
||||
const eventBehavior = this.behavior[name];
|
||||
const cachedPayload = this.lastPayload.get(name) as Events[K] | undefined;
|
||||
|
||||
if (callback) {
|
||||
if (eventBehavior.replay === "last" && cachedPayload) {
|
||||
queueMicrotask(() => callback(...cachedPayload));
|
||||
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.getEmitter(name).on(callback);
|
||||
}
|
||||
|
||||
if (
|
||||
eventBehavior.cardinality !== "once" ||
|
||||
eventBehavior.replay !== "last"
|
||||
) {
|
||||
throw new Error(`Event "${String(name)}" requires a callback`);
|
||||
}
|
||||
|
||||
if (cachedPayload) {
|
||||
return Promise.resolve(this.toPromiseValue(cachedPayload));
|
||||
}
|
||||
|
||||
return new Promise<AppEventPromiseValue<Events[K]>>((resolve) => {
|
||||
this.getEmitter(name).once((...args: Events[K]) => {
|
||||
resolve(this.toPromiseValue(args));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public emit<K extends keyof Events>(name: K, ...args: Events[K]) {
|
||||
const eventBehavior = this.behavior[name];
|
||||
|
||||
if (!isProdEnv()) {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
if (this.emittedOnce.has(name)) {
|
||||
throw new Error(`Event "${String(name)}" can only be emitted once`);
|
||||
}
|
||||
this.emittedOnce.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventBehavior.replay === "last") {
|
||||
this.lastPayload.set(name, args);
|
||||
}
|
||||
|
||||
try {
|
||||
this.getEmitter(name).trigger(...args);
|
||||
} finally {
|
||||
if (eventBehavior.cardinality === "once") {
|
||||
this.getEmitter(name).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.lastPayload.clear();
|
||||
this.emittedOnce.clear();
|
||||
|
||||
for (const emitter of this.emitters.values()) {
|
||||
emitter.clear();
|
||||
}
|
||||
|
||||
this.emitters.clear();
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
||||
|
||||
sinkDown(idx: number) {
|
||||
const node = this.content[idx];
|
||||
const nodeScore = this.scoreFunction(node);
|
||||
while (idx > 0) {
|
||||
const parentN = ((idx + 1) >> 1) - 1;
|
||||
const parent = this.content[parentN];
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
if (nodeScore < this.scoreFunction(parent)) {
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
const child1N = ((idx + 1) << 1) - 1;
|
||||
const child2N = child1N + 1;
|
||||
let smallestIdx = idx;
|
||||
let smallestScore = score;
|
||||
|
||||
// Check left child
|
||||
if (child1N < length) {
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||
if (child1Score < smallestScore) {
|
||||
smallestIdx = child1N;
|
||||
smallestScore = child1Score;
|
||||
}
|
||||
}
|
||||
|
||||
// Check right child
|
||||
if (child2N < length) {
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||
if (child2Score < smallestScore) {
|
||||
smallestIdx = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
if (smallestIdx === idx) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Move the smaller child up, continue finding position for node
|
||||
this.content[idx] = this.content[smallestIdx];
|
||||
idx = smallestIdx;
|
||||
}
|
||||
|
||||
// Place node in its final position
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
push(node: T) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* x and y position of top left corner, x and y position of bottom right corner
|
||||
*/
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
box.length === 4 &&
|
||||
typeof box[0] === "number" &&
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("COLOR_PALETTE", () => {
|
||||
it("color palette doesn't regress", () => {
|
||||
expect(COLOR_PALETTE).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDarkModeFilter", () => {
|
||||
describe("basic transformations", () => {
|
||||
it("transforms black to near-white", () => {
|
||||
const result = applyDarkModeFilter("#000000");
|
||||
// Black inverted 93% + hue rotate should be near white/light gray
|
||||
expect(result).toBe("#ededed");
|
||||
});
|
||||
|
||||
it("transforms white to near-black", () => {
|
||||
const result = applyDarkModeFilter("#ffffff");
|
||||
// White inverted 93% should be near black/dark gray
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms pure red", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("transforms pure green", () => {
|
||||
const result = applyDarkModeFilter("#00ff00");
|
||||
// Invert 93% + hue rotate 180deg
|
||||
expect(result).toBe("#008f00");
|
||||
});
|
||||
|
||||
it("transforms pure blue", () => {
|
||||
const result = applyDarkModeFilter("#0000ff");
|
||||
// Invert 93% + hue rotate 180deg produces a light purple
|
||||
expect(result).toBe("#cdcdff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("color formats", () => {
|
||||
it("handles hex with hash", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Fully opaque colors return 6-char hex
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("handles named colors", () => {
|
||||
const result = applyDarkModeFilter("red");
|
||||
// "red" = #ff0000, fully opaque
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgb format", () => {
|
||||
const result = applyDarkModeFilter("rgb(255, 0, 0)");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgba format and preserves alpha", () => {
|
||||
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
|
||||
expect(result).toMatch(/^#[0-9a-f]{8}$/);
|
||||
// Alpha 0.5 = 128 in hex = 80
|
||||
expect(result).toBe("#ff909080");
|
||||
});
|
||||
|
||||
it("handles transparent", () => {
|
||||
const result = applyDarkModeFilter("transparent");
|
||||
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
it("handles shorthand hex", () => {
|
||||
const result = applyDarkModeFilter("#f00");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha preservation", () => {
|
||||
it("omits alpha for full opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff0000ff");
|
||||
// Full opacity returns 6-char hex (no alpha suffix)
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("preserves 50% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000080");
|
||||
expect(result.slice(-2)).toBe("80");
|
||||
});
|
||||
|
||||
it("preserves 0% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000000");
|
||||
expect(result.slice(-2)).toBe("00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLOR_PALETTE regression tests", () => {
|
||||
it("transforms black from palette", () => {
|
||||
// COLOR_PALETTE.black is #1e1e1e (not pure black)
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
expect(result).toBe("#d3d3d3");
|
||||
});
|
||||
|
||||
it("transforms white from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms transparent from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
// Test each color family from the palette (all opaque, so 6-char hex)
|
||||
describe("red shades", () => {
|
||||
const redShades = COLOR_PALETTE.red;
|
||||
it.each(redShades.map((color, i) => [color, i]))(
|
||||
"transforms red shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("blue shades", () => {
|
||||
const blueShades = COLOR_PALETTE.blue;
|
||||
it.each(blueShades.map((color, i) => [color, i]))(
|
||||
"transforms blue shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("green shades", () => {
|
||||
const greenShades = COLOR_PALETTE.green;
|
||||
it.each(greenShades.map((color, i) => [color, i]))(
|
||||
"transforms green shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("gray shades", () => {
|
||||
const grayShades = COLOR_PALETTE.gray;
|
||||
it.each(grayShades.map((color, i) => [color, i]))(
|
||||
"transforms gray shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bronze shades", () => {
|
||||
const bronzeShades = COLOR_PALETTE.bronze;
|
||||
it.each(bronzeShades.map((color, i) => [color, i]))(
|
||||
"transforms bronze shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Snapshot test for full palette to catch any regressions
|
||||
it("matches snapshot for all palette colors", () => {
|
||||
const transformedPalette: Record<string, string | string[]> = {};
|
||||
|
||||
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
transformedPalette.transparent = applyDarkModeFilter(
|
||||
COLOR_PALETTE.transparent,
|
||||
);
|
||||
|
||||
// Transform color arrays
|
||||
for (const colorName of [
|
||||
"gray",
|
||||
"red",
|
||||
"pink",
|
||||
"grape",
|
||||
"violet",
|
||||
"blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"green",
|
||||
"yellow",
|
||||
"orange",
|
||||
"bronze",
|
||||
] as const) {
|
||||
const shades = COLOR_PALETTE[colorName];
|
||||
transformedPalette[colorName] = shades.map((shade) =>
|
||||
applyDarkModeFilter(shade),
|
||||
);
|
||||
}
|
||||
|
||||
expect(transformedPalette).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
it("returns same result for same input (cached)", () => {
|
||||
const result1 = applyDarkModeFilter("#ff0000");
|
||||
const result2 = applyDarkModeFilter("#ff0000");
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
expect(rgbToHex(0, 0, 0)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("converts white (255,255,255)", () => {
|
||||
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("converts red (255,0,0)", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("converts green (0,255,0)", () => {
|
||||
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("converts blue (0,0,255)", () => {
|
||||
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
|
||||
});
|
||||
|
||||
it("converts arbitrary color", () => {
|
||||
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leading zeros preservation", () => {
|
||||
it("preserves leading zeros for low values", () => {
|
||||
expect(rgbToHex(0, 0, 1)).toBe("#000001");
|
||||
expect(rgbToHex(0, 1, 0)).toBe("#000100");
|
||||
expect(rgbToHex(1, 0, 0)).toBe("#010000");
|
||||
});
|
||||
|
||||
it("preserves zeros for single-digit hex values", () => {
|
||||
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha handling", () => {
|
||||
it("omits alpha when undefined", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("omits alpha when fully opaque (1)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("includes alpha for semi-transparent (0.5)", () => {
|
||||
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
|
||||
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
|
||||
});
|
||||
|
||||
it("includes alpha for fully transparent (0)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
|
||||
});
|
||||
|
||||
it("includes alpha for near-opaque (0.99)", () => {
|
||||
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
|
||||
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
|
||||
});
|
||||
|
||||
it("pads alpha with leading zero when needed", () => {
|
||||
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
|
||||
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
|
||||
});
|
||||
});
|
||||
});
|
||||
+241
-59
@@ -1,8 +1,117 @@
|
||||
import oc from "open-color";
|
||||
import tinycolor from "tinycolor2";
|
||||
|
||||
import type { Merge } from "./utility-types";
|
||||
import { clamp } from "@excalidraw/math";
|
||||
import { degreesToRadians } from "@excalidraw/math";
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark mode color transformation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Browser-only cache to avoid memory leaks on server
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
degrees: Degrees,
|
||||
): { r: number; g: number; b: number } {
|
||||
// normalize
|
||||
const r = red / 255;
|
||||
const g = green / 255;
|
||||
const b = blue / 255;
|
||||
|
||||
// Convert degrees to radians
|
||||
const a = degreesToRadians(degrees);
|
||||
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
|
||||
// rotation matrix
|
||||
const matrix = [
|
||||
0.213 + c * 0.787 - s * 0.213,
|
||||
0.715 - c * 0.715 - s * 0.715,
|
||||
0.072 - c * 0.072 + s * 0.928,
|
||||
0.213 - c * 0.213 + s * 0.143,
|
||||
0.715 + c * 0.285 + s * 0.14,
|
||||
0.072 - c * 0.072 - s * 0.283,
|
||||
0.213 - c * 0.213 - s * 0.787,
|
||||
0.715 - c * 0.715 + s * 0.715,
|
||||
0.072 + c * 0.928 + s * 0.072,
|
||||
];
|
||||
|
||||
// transform
|
||||
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
|
||||
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
|
||||
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
|
||||
|
||||
// clamp the values to [0, 1] range and convert back to [0, 255]
|
||||
return {
|
||||
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
|
||||
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
|
||||
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
const cssInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
percent: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = clamp(percent, 0, 100) / 100;
|
||||
|
||||
// Function to invert a single color component
|
||||
const invertComponent = (color: number): number => {
|
||||
// Apply the invert formula
|
||||
const inverted = color * (1 - p) + (255 - color) * p;
|
||||
// Round to the nearest integer and clamp to [0, 255]
|
||||
return Math.round(clamp(inverted, 0, 255));
|
||||
};
|
||||
|
||||
// Calculate the inverted RGB components
|
||||
const invertedR = invertComponent(r);
|
||||
const invertedG = invertComponent(g);
|
||||
const invertedB = invertComponent(b);
|
||||
|
||||
return { r: invertedR, g: invertedG, b: invertedB };
|
||||
};
|
||||
|
||||
export const applyDarkModeFilter = (color: string): string => {
|
||||
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
|
||||
if (DARK_MODE_COLORS_CACHE) {
|
||||
DARK_MODE_COLORS_CACHE.set(color, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
@@ -17,15 +126,7 @@ const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||
};
|
||||
|
||||
export type ColorPickerColor =
|
||||
| Exclude<keyof oc, "indigo" | "lime">
|
||||
| "transparent"
|
||||
| "bronze";
|
||||
export type ColorTuple = readonly [string, string, string, string, string];
|
||||
export type ColorPalette = Merge<
|
||||
Record<ColorPickerColor, ColorTuple>,
|
||||
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
|
||||
>;
|
||||
|
||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
|
||||
@@ -38,38 +139,30 @@ export const DEFAULT_CHART_COLOR_INDEX = 4;
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
|
||||
export const ELEMENTS_PALETTE_SHADE_INDEXES = [0, 2, 4, 6, 8] as const;
|
||||
export const CANVAS_PALETTE_SHADE_INDEXES = [0, 1, 2, 3, 4] as const;
|
||||
|
||||
export const getSpecificColorShades = (
|
||||
color: Exclude<
|
||||
ColorPickerColor,
|
||||
"transparent" | "white" | "black" | "bronze"
|
||||
>,
|
||||
indexArr: Readonly<ColorShadesIndexes>,
|
||||
) => {
|
||||
return indexArr.map((index) => oc[color][index]) as any as ColorTuple;
|
||||
};
|
||||
|
||||
export const COLOR_PALETTE = {
|
||||
transparent: "transparent",
|
||||
black: "#1e1e1e",
|
||||
white: "#ffffff",
|
||||
// open-colors
|
||||
gray: getSpecificColorShades("gray", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
red: getSpecificColorShades("red", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
pink: getSpecificColorShades("pink", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
grape: getSpecificColorShades("grape", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
violet: getSpecificColorShades("violet", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
blue: getSpecificColorShades("blue", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
cyan: getSpecificColorShades("cyan", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
teal: getSpecificColorShades("teal", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
green: getSpecificColorShades("green", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
yellow: getSpecificColorShades("yellow", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
orange: getSpecificColorShades("orange", ELEMENTS_PALETTE_SHADE_INDEXES),
|
||||
// radix bronze shades 3,5,7,9,11
|
||||
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js
|
||||
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800)
|
||||
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"],
|
||||
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"],
|
||||
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"],
|
||||
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"],
|
||||
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"],
|
||||
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"],
|
||||
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"],
|
||||
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"],
|
||||
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"],
|
||||
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"],
|
||||
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"],
|
||||
// radix bronze shades [3,5,7,9,11]
|
||||
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
|
||||
} as ColorPalette;
|
||||
} as const;
|
||||
|
||||
export type ColorPalette = typeof COLOR_PALETTE;
|
||||
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
|
||||
|
||||
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"cyan",
|
||||
@@ -84,7 +177,6 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"red",
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -119,7 +211,6 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
"#fdf8f6",
|
||||
] as ColorTuple;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// palette defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -145,29 +236,120 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// helpers
|
||||
// color palette helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
|
||||
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
||||
[
|
||||
// 2nd row
|
||||
COLOR_PALETTE.cyan[index],
|
||||
COLOR_PALETTE.blue[index],
|
||||
COLOR_PALETTE.violet[index],
|
||||
COLOR_PALETTE.grape[index],
|
||||
COLOR_PALETTE.pink[index],
|
||||
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => [
|
||||
// 2nd row
|
||||
COLOR_PALETTE.cyan[index],
|
||||
COLOR_PALETTE.blue[index],
|
||||
COLOR_PALETTE.violet[index],
|
||||
COLOR_PALETTE.grape[index],
|
||||
COLOR_PALETTE.pink[index],
|
||||
|
||||
// 3rd row
|
||||
COLOR_PALETTE.green[index],
|
||||
COLOR_PALETTE.teal[index],
|
||||
COLOR_PALETTE.yellow[index],
|
||||
COLOR_PALETTE.orange[index],
|
||||
COLOR_PALETTE.red[index],
|
||||
] as const;
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
// 3rd row
|
||||
COLOR_PALETTE.green[index],
|
||||
COLOR_PALETTE.teal[index],
|
||||
COLOR_PALETTE.yellow[index],
|
||||
COLOR_PALETTE.orange[index],
|
||||
COLOR_PALETTE.red[index],
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// other helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
|
||||
// then slice(1) removes the leading "1" to get exactly 6 hex digits
|
||||
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
|
||||
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
|
||||
.toString(16)
|
||||
.slice(1)}`;
|
||||
if (a !== undefined && a < 1) {
|
||||
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
|
||||
// e.g. 0.5 -> 128 -> "80"
|
||||
const alphaHex = Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${hex6}${alphaHex}`;
|
||||
}
|
||||
return hex6;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
|
||||
* null if not valid color
|
||||
*/
|
||||
export const colorToHex = (color: string): string | null => {
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
return null;
|
||||
}
|
||||
const { r, g, b, a } = tc.toRgb();
|
||||
return rgbToHex(r, g, b, a);
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 0;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// color contract helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
const calculateContrast = (r: number, g: number, b: number): number => {
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq;
|
||||
};
|
||||
|
||||
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
|
||||
export const isColorDark = (color: string, threshold = 160): boolean => {
|
||||
// no color ("") -> assume it default to black
|
||||
if (!color) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTransparent(color)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
// invalid color -> assume it defaults to black
|
||||
return true;
|
||||
}
|
||||
|
||||
const { r, g, b } = tc.toRgb();
|
||||
return calculateContrast(r, g, b) < threshold;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// normalization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* tries to keep the input color as-is if it's valid, making minimal adjustments
|
||||
* (trimming whitespace or adding `#` to hex colors)
|
||||
*/
|
||||
export const normalizeInputColor = (color: string): string | null => {
|
||||
color = color.trim();
|
||||
if (isTransparent(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
if (tc.isValid()) {
|
||||
// testing for `#` first fixes a bug on Electron (more specfically, an
|
||||
// Obsidian popout window), where a hex color without `#` is considered valid
|
||||
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
|
||||
return `#${color}`;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -6,24 +6,6 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -35,6 +17,7 @@ export const APP_NAME = "Excalidraw";
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
@@ -116,11 +99,23 @@ export const ENV = {
|
||||
};
|
||||
|
||||
export const CLASSES = {
|
||||
SIDEBAR: "sidebar",
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||
FRAME_NAME: "frame-name",
|
||||
DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper",
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 28,
|
||||
xl: 36,
|
||||
} as const;
|
||||
|
||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
@@ -142,21 +137,52 @@ export const FONT_FAMILY = {
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"Liberation Sans": 9,
|
||||
Assistant: 10,
|
||||
};
|
||||
|
||||
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
|
||||
// so we need to have generic font fallback before it
|
||||
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
|
||||
export const MONOSPACE_GENERIC_FONT = "monospace";
|
||||
|
||||
export const FONT_FAMILY_GENERIC_FALLBACKS = {
|
||||
[SANS_SERIF_GENERIC_FONT]: 998,
|
||||
[MONOSPACE_GENERIC_FONT]: 999,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_FALLBACKS = {
|
||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
||||
};
|
||||
|
||||
export function getGenericFontFamilyFallback(
|
||||
fontFamily: number,
|
||||
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Cascadia:
|
||||
case FONT_FAMILY["Comic Shanns"]:
|
||||
return MONOSPACE_GENERIC_FONT;
|
||||
|
||||
default:
|
||||
return SANS_SERIF_GENERIC_FONT;
|
||||
}
|
||||
}
|
||||
|
||||
export const getFontFamilyFallbacks = (
|
||||
fontFamily: number,
|
||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Excalifont:
|
||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
return [
|
||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||
genericFallbackFont,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
];
|
||||
default:
|
||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -165,6 +191,8 @@ export const THEME = {
|
||||
DARK: "dark",
|
||||
} as const;
|
||||
|
||||
export const DARK_THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
strokeWidth: 2 as ExcalidrawElement["strokeWidth"],
|
||||
@@ -218,13 +246,21 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
export const STRING_MIME_TYPES = {
|
||||
text: "text/plain",
|
||||
html: "text/html",
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
|
||||
// LEGACY: fully-qualified library JSON data
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
// list of excalidraw library item ids
|
||||
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||
} as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
...STRING_MIME_TYPES,
|
||||
// image-encoded excalidraw data
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
"excalidraw.png": "image/png",
|
||||
@@ -253,7 +289,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_SOURCE =
|
||||
export const getExportSource = () =>
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
@@ -273,9 +309,6 @@ export const IDLE_THRESHOLD = 60_000;
|
||||
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||
export const ACTIVE_THRESHOLD = 3_000;
|
||||
|
||||
// duplicates --theme-filter, should be removed soon
|
||||
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
export const URL_QUERY_KEYS = {
|
||||
addLibrary: "addLibrary",
|
||||
} as const;
|
||||
@@ -299,16 +332,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
},
|
||||
};
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
@@ -474,3 +497,19 @@ export enum UserIdleState {
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
/**
|
||||
* distance at which we merge points instead of adding a new merge-point
|
||||
* when converting a line to a polygon (merge currently means overlaping
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||
|
||||
export const BIND_MODE_TIMEOUT = 700; // ms
|
||||
|
||||
// glass background for mobile action buttons
|
||||
export const MOBILE_ACTION_BUTTON_BG = {
|
||||
background: "var(--mobile-action-button-bg)",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
export type StylesPanelMode = "compact" | "full" | "mobile";
|
||||
|
||||
export type EditorInterface = Readonly<{
|
||||
formFactor: "phone" | "tablet" | "desktop";
|
||||
desktopUIMode: "compact" | "full";
|
||||
userAgent: Readonly<{
|
||||
isMobileDevice: boolean;
|
||||
platform: "ios" | "android" | "other" | "unknown";
|
||||
}>;
|
||||
isTouchScreen: boolean;
|
||||
canFitSidebar: boolean;
|
||||
isLandscape: boolean;
|
||||
}>;
|
||||
|
||||
// storage key
|
||||
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
|
||||
|
||||
// breakpoints
|
||||
export const MQ_MAX_MOBILE = 599;
|
||||
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1180; // ipad air
|
||||
|
||||
// desktop/laptop (NOTE: not used for form factor detection)
|
||||
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// user agent detections
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
// export const isMobile =
|
||||
// isIOS ||
|
||||
// /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
// navigator.userAgent,
|
||||
// ) ||
|
||||
// /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||
|
||||
// utilities
|
||||
export const isMobileBreakpoint = (width: number, height: number) => {
|
||||
return (
|
||||
width <= MQ_MAX_MOBILE ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||
);
|
||||
};
|
||||
|
||||
export const isTabletBreakpoint = (
|
||||
editorWidth: number,
|
||||
editorHeight: number,
|
||||
) => {
|
||||
const minSide = Math.min(editorWidth, editorHeight);
|
||||
const maxSide = Math.max(editorWidth, editorHeight);
|
||||
|
||||
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||
};
|
||||
|
||||
const isMobileOrTablet = (): boolean => {
|
||||
const ua = navigator.userAgent || "";
|
||||
const platform = navigator.platform || "";
|
||||
const uaData = (navigator as any).userAgentData as
|
||||
| { mobile?: boolean; platform?: string }
|
||||
| undefined;
|
||||
|
||||
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||
if (uaData) {
|
||||
const plat = (uaData.platform || "").toLowerCase();
|
||||
const isDesktopOS =
|
||||
plat === "windows" ||
|
||||
plat === "macos" ||
|
||||
plat === "linux" ||
|
||||
plat === "chrome os";
|
||||
if (uaData.mobile === true) {
|
||||
return true;
|
||||
}
|
||||
if (uaData.mobile === false && plat === "android") {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
if (isDesktopOS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) ios (includes ipad) --------------------------------------------
|
||||
if (isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 3) android legacy ua fallback -------------------------------------
|
||||
if (isAndroid) {
|
||||
const isAndroidPhone = /Mobile/i.test(ua);
|
||||
const isAndroidTablet = !isAndroidPhone;
|
||||
if (isAndroidPhone || isAndroidTablet) {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4) last resort desktop exclusion ----------------------------------
|
||||
const looksDesktopPlatform =
|
||||
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||
if (looksDesktopPlatform) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFormFactor = (
|
||||
editorWidth: number,
|
||||
editorHeight: number,
|
||||
): EditorInterface["formFactor"] => {
|
||||
if (isMobileBreakpoint(editorWidth, editorHeight)) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
if (isTabletBreakpoint(editorWidth, editorHeight)) {
|
||||
return "tablet";
|
||||
}
|
||||
|
||||
return "desktop";
|
||||
};
|
||||
|
||||
export const deriveStylesPanelMode = (
|
||||
editorInterface: EditorInterface,
|
||||
): StylesPanelMode => {
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
if (editorInterface.formFactor === "tablet") {
|
||||
return "compact";
|
||||
}
|
||||
|
||||
return editorInterface.desktopUIMode;
|
||||
};
|
||||
|
||||
export const createUserAgentDescriptor = (
|
||||
userAgentString: string,
|
||||
): EditorInterface["userAgent"] => {
|
||||
const normalizedUA = userAgentString ?? "";
|
||||
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
|
||||
|
||||
if (isIOS) {
|
||||
platform = "ios";
|
||||
} else if (isAndroid) {
|
||||
platform = "android";
|
||||
} else if (normalizedUA) {
|
||||
platform = "other";
|
||||
}
|
||||
|
||||
return {
|
||||
isMobileDevice: isMobileOrTablet(),
|
||||
platform,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const loadDesktopUIModePreference = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
|
||||
if (stored === "compact" || stored === "full") {
|
||||
return stored as EditorInterface["desktopUIMode"];
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore storage access issues (e.g., Safari private mode)
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
|
||||
} catch (error) {
|
||||
// ignore storage access issues (e.g., Safari private mode)
|
||||
}
|
||||
};
|
||||
|
||||
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||
if (mode !== "compact" && mode !== "full") {
|
||||
return;
|
||||
}
|
||||
|
||||
persistDesktopUIMode(mode);
|
||||
|
||||
return mode;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UnsubscribeCallback } from "./types";
|
||||
import type { UnsubscribeCallback } from "@excalidraw/excalidraw/types";
|
||||
|
||||
type Subscriber<T extends any[]> = (...payload: T) => void;
|
||||
|
||||
@@ -22,8 +22,10 @@ export interface FontMetadata {
|
||||
};
|
||||
/** flag to indicate a deprecated font */
|
||||
deprecated?: true;
|
||||
/** flag to indicate a server-side only font */
|
||||
serverSide?: true;
|
||||
/**
|
||||
* whether this is a font that users can use (= shown in font picker)
|
||||
*/
|
||||
private?: true;
|
||||
/** flag to indiccate a local-only font */
|
||||
local?: true;
|
||||
/** flag to indicate a fallback font */
|
||||
@@ -44,7 +46,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 1011,
|
||||
descender: -353,
|
||||
lineHeight: 1.35,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
@@ -98,14 +100,23 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
descender: -434,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
serverSide: true,
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1021,
|
||||
descender: -287,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 880,
|
||||
descender: -144,
|
||||
lineHeight: 1.15,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
fallback: true,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./binary-heap";
|
||||
export * from "./bounds";
|
||||
export * from "./colors";
|
||||
export * from "./constants";
|
||||
export * from "./font-metadata";
|
||||
@@ -9,3 +10,8 @@ export * from "./promise-pool";
|
||||
export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
export * from "./appEventBus";
|
||||
export * from "./editorInterface";
|
||||
export * from "./versionedSnapshotStore";
|
||||
export { Debug } from "../debug";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDarwin } from "./constants";
|
||||
import { isDarwin } from "./editorInterface";
|
||||
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
||||
|
||||
@@ -68,3 +68,12 @@ export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
// get union of all keys from the union of types
|
||||
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
|
||||
|
||||
/** Strip all the methods or functions from a type */
|
||||
export type DTO<T> = {
|
||||
[K in keyof T as T[K] extends Function ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export type MapEntry<M extends Map<any, any>> = M extends Map<infer K, infer V>
|
||||
? [K, V]
|
||||
: never;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
isTransparent,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Import directly to avoid the @excalidraw/common throttleRAF mock from setupTests.ts.
|
||||
import { throttleRAF } from "./utils";
|
||||
|
||||
type RafCallback = FrameRequestCallback;
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
expect(isTransparent("#fff00000")).toEqual(true);
|
||||
expect(isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToCommonValue()", () => {
|
||||
it("should return the common value when all values are the same", () => {
|
||||
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||
expect(reduceToCommonValue([""])).toEqual("");
|
||||
expect(reduceToCommonValue([0])).toEqual(0);
|
||||
|
||||
const o = {};
|
||||
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||
|
||||
expect(
|
||||
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||
).toEqual(1);
|
||||
expect(
|
||||
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return `null` when values are different", () => {
|
||||
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return `null` when some values are nullable", () => {
|
||||
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFind()", () => {
|
||||
it("should return the first mapped non-null element", () => {
|
||||
{
|
||||
let counter = 0;
|
||||
|
||||
const result = mapFind(["a", "b", "c"], (value) => {
|
||||
counter++;
|
||||
return value === "b" ? 42 : null;
|
||||
});
|
||||
expect(result).toEqual(42);
|
||||
expect(counter).toBe(2);
|
||||
}
|
||||
|
||||
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||
expect(mapFind([1, 2], () => "")).toBe("");
|
||||
});
|
||||
|
||||
it("should return undefined if no mapped element is found", () => {
|
||||
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("throttleRAF()", () => {
|
||||
let frameCallbacks: Map<number, RafCallback>;
|
||||
let nextFrameId: number;
|
||||
|
||||
const runScheduledFrame = (timestamp = 16) => {
|
||||
const callbacks = [...frameCallbacks.values()];
|
||||
frameCallbacks.clear();
|
||||
callbacks.forEach((callback) => callback(timestamp));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
frameCallbacks = new Map();
|
||||
nextFrameId = 0;
|
||||
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation(
|
||||
(callback) => {
|
||||
const frameId = ++nextFrameId;
|
||||
frameCallbacks.set(frameId, callback);
|
||||
return frameId;
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((frameId) => {
|
||||
frameCallbacks.delete(frameId);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should invoke the callback with the last args from the same frame", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first", 1);
|
||||
throttled("second", 2);
|
||||
throttled("last", 3);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last", 3);
|
||||
});
|
||||
|
||||
it("should flush the pending callback immediately", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.flush();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith("last");
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cancel the pending callback", () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttleRAF(fn);
|
||||
|
||||
throttled("first");
|
||||
throttled("last");
|
||||
|
||||
throttled.cancel();
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
|
||||
runScheduledFrame();
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+190
-94
@@ -1,11 +1,8 @@
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { average } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { GlobalCoord } from "@excalidraw/math";
|
||||
|
||||
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
ActiveTool,
|
||||
@@ -15,13 +12,11 @@ import type {
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_VERSION,
|
||||
ENV,
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
|
||||
@@ -92,7 +87,9 @@ export const isWritableElement = (
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password"));
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -101,7 +98,6 @@ export const getFontFamilyString = ({
|
||||
}) => {
|
||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||
if (id === fontFamily) {
|
||||
// TODO: we should fallback first to generic family names first
|
||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||
.map((x) => `, ${x}`)
|
||||
.join("")}`;
|
||||
@@ -121,6 +117,11 @@ export const getFontString = ({
|
||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||
};
|
||||
|
||||
/** executes callback in the frame that's after the current one */
|
||||
export const nextAnimationFrame = async (cb: () => any) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||
};
|
||||
|
||||
export const debounce = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
timeout: number,
|
||||
@@ -150,38 +151,27 @@ export const debounce = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
// throttle callback to execute once per animation frame
|
||||
export const throttleRAF = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
opts?: { trailing?: boolean },
|
||||
) => {
|
||||
// throttle callback to execute once per animation frame using the latest args
|
||||
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
let timerId: number | null = null;
|
||||
let lastArgs: T | null = null;
|
||||
let lastArgsTrailing: T | null = null;
|
||||
|
||||
const scheduleFunc = (args: T) => {
|
||||
const scheduleFunc = () => {
|
||||
timerId = window.requestAnimationFrame(() => {
|
||||
timerId = null;
|
||||
fn(...args);
|
||||
const args = lastArgs;
|
||||
lastArgs = null;
|
||||
if (lastArgsTrailing) {
|
||||
lastArgs = lastArgsTrailing;
|
||||
lastArgsTrailing = null;
|
||||
scheduleFunc(lastArgs);
|
||||
|
||||
if (args) {
|
||||
fn(...args);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
lastArgs = args;
|
||||
if (timerId === null) {
|
||||
scheduleFunc(lastArgs);
|
||||
} else if (opts?.trailing) {
|
||||
lastArgsTrailing = args;
|
||||
scheduleFunc();
|
||||
}
|
||||
};
|
||||
ret.flush = () => {
|
||||
@@ -190,12 +180,12 @@ export const throttleRAF = <T extends any[]>(
|
||||
timerId = null;
|
||||
}
|
||||
if (lastArgs) {
|
||||
fn(...(lastArgsTrailing || lastArgs));
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
}
|
||||
};
|
||||
ret.cancel = () => {
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
lastArgs = null;
|
||||
if (timerId !== null) {
|
||||
cancelAnimationFrame(timerId);
|
||||
timerId = null;
|
||||
@@ -379,6 +369,10 @@ export const removeSelection = () => {
|
||||
|
||||
export const distance = (x: number, y: number) => Math.abs(x - y);
|
||||
|
||||
export const isSelectionLikeTool = (type: ToolType | "custom") => {
|
||||
return type === "selection" || type === "lasso";
|
||||
};
|
||||
|
||||
export const updateActiveTool = (
|
||||
appState: Pick<AppState, "activeTool">,
|
||||
data: ((
|
||||
@@ -420,19 +414,6 @@ export const allowFullScreen = () =>
|
||||
|
||||
export const exitFullScreen = () => document.exitFullscreen();
|
||||
|
||||
export const getShortcutKey = (shortcut: string): string => {
|
||||
shortcut = shortcut
|
||||
.replace(/\bAlt\b/i, "Alt")
|
||||
.replace(/\bShift\b/i, "Shift")
|
||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
||||
if (isDarwin) {
|
||||
return shortcut
|
||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
||||
.replace(/\bAlt\b/i, "Option");
|
||||
}
|
||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
||||
};
|
||||
|
||||
export const viewportCoordsToSceneCoords = (
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
{
|
||||
@@ -452,7 +433,7 @@ export const viewportCoordsToSceneCoords = (
|
||||
const x = (clientX - offsetLeft) / zoom.value - scrollX;
|
||||
const y = (clientY - offsetTop) / zoom.value - scrollY;
|
||||
|
||||
return { x, y };
|
||||
return { x, y } as GlobalCoord;
|
||||
};
|
||||
|
||||
export const sceneCoordsToViewportCoords = (
|
||||
@@ -544,19 +525,20 @@ export const findLastIndex = <T>(
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
return (
|
||||
isRGBTransparent ||
|
||||
isRRGGBBTransparent ||
|
||||
color === COLOR_PALETTE.transparent
|
||||
);
|
||||
/** returns the first non-null mapped value */
|
||||
export const mapFind = <T, K>(
|
||||
collection: readonly T[],
|
||||
iteratee: (value: T, index: number) => K | undefined | null,
|
||||
): K | undefined => {
|
||||
for (let idx = 0; idx < collection.length; idx++) {
|
||||
const result = iteratee(collection[idx], idx);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
@@ -680,7 +662,7 @@ export const arrayToMap = <T extends { id: string } | string>(
|
||||
return items.reduce((acc: Map<string, T>, element) => {
|
||||
acc.set(typeof element === "string" ? element : element.id, element);
|
||||
return acc;
|
||||
}, new Map());
|
||||
}, new Map() as Map<string, T>);
|
||||
};
|
||||
|
||||
export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||
@@ -698,8 +680,8 @@ export const arrayToObject = <T>(
|
||||
array: readonly T[],
|
||||
groupBy?: (value: T) => string | number,
|
||||
) =>
|
||||
array.reduce((acc, value) => {
|
||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||
array.reduce((acc, value, idx) => {
|
||||
acc[groupBy ? groupBy(value) : idx] = value;
|
||||
return acc;
|
||||
}, {} as { [key: string]: T });
|
||||
|
||||
@@ -735,6 +717,25 @@ export const arrayToList = <T>(array: readonly T[]): Node<T>[] =>
|
||||
return acc;
|
||||
}, [] as Node<T>[]);
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an iterable.
|
||||
* Useful for avoiding entry allocations when iterating object / map on each iteration.
|
||||
*/
|
||||
export const toIterable = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): Iterable<T> => {
|
||||
return Array.isArray(values) ? values : values.values();
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a readonly array or map into an array.
|
||||
*/
|
||||
export const toArray = <T>(
|
||||
values: readonly T[] | ReadonlyMap<string, T>,
|
||||
): T[] => {
|
||||
return Array.isArray(values) ? values : Array.from(toIterable(values));
|
||||
};
|
||||
|
||||
export const isTestEnv = () => import.meta.env.MODE === ENV.TEST;
|
||||
|
||||
export const isDevEnv = () => import.meta.env.MODE === ENV.DEVELOPMENT;
|
||||
@@ -1137,39 +1138,69 @@ export const normalizeEOL = (str: string) => {
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
type HasBrand<T> = {
|
||||
export type HasBrand<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
|
||||
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
|
||||
}[keyof T];
|
||||
|
||||
type RemoveAllBrands<T> = HasBrand<T> extends true
|
||||
? {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
|
||||
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
|
||||
? never
|
||||
: K]: T[K];
|
||||
}
|
||||
: never;
|
||||
: T;
|
||||
|
||||
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
|
||||
// currently does not cover all types (e.g. tuples, promises...)
|
||||
type Unbrand<T> = T extends Map<infer E, infer F>
|
||||
? Map<E, F>
|
||||
// For accepting values - uses loose matching for branded types
|
||||
// Preserves readonly modifier: mutable array requires mutable input
|
||||
type UnbrandForValue<T> = T extends Map<infer E, infer F>
|
||||
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
|
||||
: T extends Set<infer E>
|
||||
? Set<E>
|
||||
: T extends Array<infer E>
|
||||
? Array<E>
|
||||
? Set<UnbrandForValue<E>>
|
||||
: T extends readonly any[]
|
||||
? T extends any[]
|
||||
? unknown[] // mutable array - require mutable input
|
||||
: readonly unknown[] // readonly array - accept readonly input
|
||||
: RemoveAllBrands<T>;
|
||||
|
||||
// For return types - preserves array element unbranding
|
||||
export type Unbrand<T> = T extends Map<infer E, infer F>
|
||||
? Map<Unbrand<E>, Unbrand<F>>
|
||||
: T extends Set<infer E>
|
||||
? Set<Unbrand<E>>
|
||||
: T extends readonly (infer E)[]
|
||||
? Array<Unbrand<E>>
|
||||
: RemoveAllBrands<T>;
|
||||
|
||||
export type CombineBrands<BrandedType, CurrentType> =
|
||||
BrandedType extends readonly (infer BE)[]
|
||||
? CurrentType extends readonly (infer CE)[]
|
||||
? Array<CE & BE>
|
||||
: CurrentType & BrandedType
|
||||
: CurrentType & BrandedType;
|
||||
|
||||
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
|
||||
? T[]
|
||||
: HasBrand<T> extends true
|
||||
? CombineBrands<T, Required>[]
|
||||
: Required[];
|
||||
|
||||
/**
|
||||
* Makes type into a branded type, ensuring that value is assignable to
|
||||
* the base ubranded type. Optionally you can explicitly supply current value
|
||||
* the base unbranded type. Optionally you can explicitly supply current value
|
||||
* type to combine both (useful for composite branded types. Make sure you
|
||||
* compose branded types which are not composite themselves.)
|
||||
*/
|
||||
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
|
||||
value: Unbrand<BrandedType>,
|
||||
) => {
|
||||
return value as CurrentType & BrandedType;
|
||||
};
|
||||
export function toBrandedType<BrandedType>(
|
||||
value: UnbrandForValue<BrandedType>,
|
||||
): BrandedType;
|
||||
export function toBrandedType<BrandedType, CurrentType>(
|
||||
value: CurrentType,
|
||||
): CombineBrands<BrandedType, CurrentType>;
|
||||
export function toBrandedType(value: unknown) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1205,31 +1236,96 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const { x, y, width, height } = element;
|
||||
|
||||
const centerXPoint = x + width / 2 + xOffset;
|
||||
|
||||
const centerYPoint = y + height / 2 + yOffset;
|
||||
|
||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||
};
|
||||
|
||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
};
|
||||
|
||||
export const sizeOf = (
|
||||
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
|
||||
value:
|
||||
| readonly unknown[]
|
||||
| Readonly<Map<string, unknown>>
|
||||
| Readonly<Record<string, unknown>>
|
||||
| ReadonlySet<unknown>,
|
||||
): number => {
|
||||
return isReadonlyArray(value)
|
||||
? value.length
|
||||
: value instanceof Map
|
||||
: value instanceof Map || value instanceof Set
|
||||
? value.size
|
||||
: Object.keys(value).length;
|
||||
};
|
||||
|
||||
export const reduceToCommonValue = <T, R = T>(
|
||||
collection: readonly T[] | ReadonlySet<T>,
|
||||
getValue?: (item: T) => R,
|
||||
): R | null => {
|
||||
if (sizeOf(collection) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueExtractor = getValue || ((item: T) => item as unknown as R);
|
||||
|
||||
let commonValue: R | null = null;
|
||||
|
||||
for (const item of collection) {
|
||||
const value = valueExtractor(item);
|
||||
if ((commonValue === null || commonValue === value) && value != null) {
|
||||
commonValue = value;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
|
||||
type FEATURE_FLAGS = {
|
||||
COMPLEX_BINDINGS: boolean;
|
||||
};
|
||||
|
||||
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
|
||||
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
|
||||
COMPLEX_BINDINGS: false,
|
||||
};
|
||||
let featureFlags: FEATURE_FLAGS | null = null;
|
||||
|
||||
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
flag: F,
|
||||
): FEATURE_FLAGS[F] => {
|
||||
if (!featureFlags) {
|
||||
try {
|
||||
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
|
||||
if (serializedFlags) {
|
||||
const flags = JSON.parse(serializedFlags);
|
||||
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
|
||||
};
|
||||
|
||||
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
flag: F,
|
||||
value: FEATURE_FLAGS[F],
|
||||
) => {
|
||||
try {
|
||||
featureFlags = {
|
||||
...(featureFlags || DEFAULT_FEATURE_FLAGS),
|
||||
[flag]: value,
|
||||
};
|
||||
localStorage.setItem(
|
||||
FEATURE_FLAGS_STORAGE_KEY,
|
||||
JSON.stringify(featureFlags),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("unable to set feature flag", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const oneOf = <N extends string | number | symbol | null, H extends N>(
|
||||
needle: N,
|
||||
haystack: readonly H[],
|
||||
): needle is H => {
|
||||
return haystack.includes(needle as any);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type VersionedSnapshot<T> = Readonly<{
|
||||
version: number;
|
||||
value: T;
|
||||
}>;
|
||||
|
||||
export class VersionedSnapshotStore<T> {
|
||||
private version = 0;
|
||||
private value: T;
|
||||
private readonly waiters = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
private readonly subscribers = new Set<
|
||||
(snapshot: VersionedSnapshot<T>) => void
|
||||
>();
|
||||
|
||||
constructor(
|
||||
initialValue: T,
|
||||
private readonly isEqual: (prev: T, next: T) => boolean = Object.is,
|
||||
) {
|
||||
this.value = initialValue;
|
||||
}
|
||||
|
||||
public getSnapshot(): VersionedSnapshot<T> {
|
||||
return { version: this.version, value: this.value };
|
||||
}
|
||||
|
||||
public set(nextValue: T): boolean {
|
||||
if (this.isEqual(this.value, nextValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.value = nextValue;
|
||||
this.version += 1;
|
||||
|
||||
const snapshot = this.getSnapshot();
|
||||
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(snapshot);
|
||||
}
|
||||
for (const waiter of this.waiters) {
|
||||
waiter(snapshot);
|
||||
}
|
||||
this.waiters.clear();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public update(updater: (prev: T) => T): boolean {
|
||||
return this.set(updater(this.value));
|
||||
}
|
||||
|
||||
public subscribe(
|
||||
subscriber: (snapshot: VersionedSnapshot<T>) => void,
|
||||
): () => void {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
}
|
||||
|
||||
public pull(sinceVersion = -1): Promise<VersionedSnapshot<T>> {
|
||||
if (this.version !== sinceVersion) {
|
||||
return Promise.resolve(this.getSnapshot());
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.add(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.1.0",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,7 +13,16 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
||||
"types": "./dist/types/element/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./visualdebug": {
|
||||
"types": "./dist/types/element/src/visualdebug.d.ts",
|
||||
"development": "./dist/dev/visualdebug.js",
|
||||
"production": "./dist/prod/visualdebug.js",
|
||||
"default": "./dist/prod/visualdebug.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -50,7 +59,11 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,22 @@ import {
|
||||
toBrandedType,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
isReadonlyArray,
|
||||
toArray,
|
||||
} from "@excalidraw/common";
|
||||
import { isNonDeletedElement } from "@excalidraw/element";
|
||||
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
|
||||
import { getElementsInGroup } from "@excalidraw/element/groups";
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
import { getElementsInGroup } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
syncInvalidIndices,
|
||||
syncMovedIndices,
|
||||
validateFractionalIndices,
|
||||
} from "@excalidraw/element/fractionalIndex";
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElements } from "@excalidraw/element/selection";
|
||||
import { getSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
|
||||
|
||||
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
@@ -33,12 +34,13 @@ import type {
|
||||
Ordered,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type { Assert, SameType } from "@excalidraw/common/utility-types";
|
||||
import type {
|
||||
Assert,
|
||||
Mutable,
|
||||
SameType,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { AppState } from "../types";
|
||||
|
||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||
import type { AppState } from "../../excalidraw/types";
|
||||
|
||||
type SceneStateCallback = () => void;
|
||||
type SceneStateCallbackRemover = () => void;
|
||||
@@ -103,44 +105,7 @@ const hashSelectionOpts = (
|
||||
// in our codebase
|
||||
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||
|
||||
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
|
||||
if (typeof elementKey === "string") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
class Scene {
|
||||
// ---------------------------------------------------------------------------
|
||||
// static methods/props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
|
||||
private static sceneMapById = new Map<string, Scene>();
|
||||
|
||||
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
|
||||
if (isIdKey(elementKey)) {
|
||||
// for cases where we don't have access to the element object
|
||||
// (e.g. restore serialized appState with id references)
|
||||
this.sceneMapById.set(elementKey, scene);
|
||||
} else {
|
||||
this.sceneMapByElement.set(elementKey, scene);
|
||||
// if mapping element objects, also cache the id string when later
|
||||
// looking up by id alone
|
||||
this.sceneMapById.set(elementKey.id, scene);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated pass down `app.scene` and use it directly
|
||||
*/
|
||||
static getScene(elementKey: ElementKey): Scene | null {
|
||||
if (isIdKey(elementKey)) {
|
||||
return this.sceneMapById.get(elementKey) || null;
|
||||
}
|
||||
return this.sceneMapByElement.get(elementKey) || null;
|
||||
}
|
||||
|
||||
export class Scene {
|
||||
// ---------------------------------------------------------------------------
|
||||
// instance methods/props
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,6 +164,17 @@ class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements, options);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedElements(opts: {
|
||||
// NOTE can be ommitted by making Scene constructor require App instance
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
@@ -292,13 +268,19 @@ class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
const _nextElements = isReadonlyArray(nextElements)
|
||||
? nextElements
|
||||
: Array.from(nextElements.values());
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
@@ -307,7 +289,6 @@ class Scene {
|
||||
nextFrameLikes.push(element);
|
||||
}
|
||||
this.elementsMap.set(element.id, element);
|
||||
Scene.mapElementToScene(element, this);
|
||||
});
|
||||
const nonDeletedElements = getNonDeletedElements(this.elements);
|
||||
this.nonDeletedElements = nonDeletedElements.elements;
|
||||
@@ -352,12 +333,6 @@ class Scene {
|
||||
this.selectedElementsCache.elements = null;
|
||||
this.selectedElementsCache.cache.clear();
|
||||
|
||||
Scene.sceneMapById.forEach((scene, elementKey) => {
|
||||
if (scene === this) {
|
||||
Scene.sceneMapById.delete(elementKey);
|
||||
}
|
||||
});
|
||||
|
||||
// done not for memory leaks, but to guard against possible late fires
|
||||
// (I guess?)
|
||||
this.callbacks.clear();
|
||||
@@ -454,6 +429,42 @@ class Scene {
|
||||
// then, check if the id is a group
|
||||
return getElementsInGroup(elementsMap, id);
|
||||
};
|
||||
}
|
||||
|
||||
export default Scene;
|
||||
// Mutate an element with passed updates and trigger the component to update. Make sure you
|
||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||
mutateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
options: {
|
||||
informMutation: boolean;
|
||||
isDragging: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
} = {
|
||||
informMutation: true,
|
||||
isDragging: false,
|
||||
},
|
||||
) {
|
||||
const elementsMap = this.getNonDeletedElementsMap();
|
||||
|
||||
const { version: prevVersion } = element;
|
||||
const { version: nextVersion } = mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
updates,
|
||||
options,
|
||||
);
|
||||
|
||||
if (
|
||||
// skip if the element is not in the scene (i.e. selection)
|
||||
this.elementsMap.has(element.id) &&
|
||||
// skip if the element's version hasn't changed, as mutateElement returned the same element
|
||||
prevVersion !== nextVersion &&
|
||||
options.informMutation
|
||||
) {
|
||||
this.triggerUpdate();
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { _generateElementShape } from "./Shape";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
||||
|
||||
import type { Drawable } from "roughjs/bin/core";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
||||
+73
-44
@@ -88,8 +88,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "ellipse-1",
|
||||
"focus": -0.007519379844961235,
|
||||
"gap": 11.562288374879595,
|
||||
"fixedPoint": [
|
||||
0.04,
|
||||
0.4633333333333333,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -98,7 +101,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -118,8 +120,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id49",
|
||||
"focus": -0.0813953488372095,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
1,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1864ab",
|
||||
"strokeStyle": "solid",
|
||||
@@ -144,8 +149,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "ellipse-1",
|
||||
"focus": 0.10666666666666667,
|
||||
"gap": 3.8343264684446097,
|
||||
"fixedPoint": [
|
||||
-0.01,
|
||||
0.44666666666666666,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -154,7 +162,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -174,8 +181,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "diamond-1",
|
||||
"focus": 0,
|
||||
"gap": 4.545343408287929,
|
||||
"fixedPoint": [
|
||||
0.9357142857142857,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#e67700",
|
||||
"strokeStyle": "solid",
|
||||
@@ -334,8 +344,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "text-2",
|
||||
"focus": 0,
|
||||
"gap": 14,
|
||||
"fixedPoint": [
|
||||
-2.05,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -344,7 +357,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -364,8 +376,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "text-1",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
1,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -436,8 +451,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id42",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
0,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -446,7 +464,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -466,8 +483,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id41",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
1,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -612,8 +632,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id46",
|
||||
"focus": -0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
0,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -622,7 +645,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -642,8 +664,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id45",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
1,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -839,7 +864,6 @@ exports[`Test Transform > should transform linear elements 1`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -887,7 +911,6 @@ exports[`Test Transform > should transform linear elements 2`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -934,7 +957,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -948,6 +970,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
0,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
@@ -981,7 +1004,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -995,6 +1017,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
0,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
@@ -1474,8 +1497,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "Alice",
|
||||
"focus": -0,
|
||||
"gap": 5.299874999999986,
|
||||
"fixedPoint": [
|
||||
-0.07542628418945944,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1484,7 +1510,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a4",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -1506,8 +1531,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "Bob",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
1.000004978564514,
|
||||
0.5001,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -1537,8 +1565,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "B",
|
||||
"focus": 0,
|
||||
"gap": 14,
|
||||
"fixedPoint": [
|
||||
0.46387050630528887,
|
||||
0.48466257668711654,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
@@ -1547,7 +1578,6 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"id": Any<String>,
|
||||
"index": "a5",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -1565,8 +1595,11 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "Bob",
|
||||
"focus": 0,
|
||||
"gap": 1,
|
||||
"fixedPoint": [
|
||||
0.39381496335223337,
|
||||
1,
|
||||
],
|
||||
"mode": "orbit",
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
@@ -1789,7 +1822,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
||||
"versionNonce": Any<Number>,
|
||||
"verticalAlign": "middle",
|
||||
"width": 120,
|
||||
"x": 187.7545,
|
||||
"x": 187.75450000000004,
|
||||
"y": 44.5,
|
||||
}
|
||||
`;
|
||||
@@ -1856,7 +1889,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -1909,7 +1941,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a1",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -1962,7 +1993,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -2015,7 +2045,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
||||
"id": Any<String>,
|
||||
"index": "a3",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
+7
-12
@@ -1,11 +1,12 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type { ExcalidrawArrowElement } from "@excalidraw/element/types";
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
type ExcalidrawElementSkeleton,
|
||||
} from "../transform";
|
||||
|
||||
import { convertToExcalidrawElements } from "./transform";
|
||||
|
||||
import type { ExcalidrawElementSkeleton } from "./transform";
|
||||
import type { ExcalidrawArrowElement } from "../types";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
@@ -432,12 +433,9 @@ describe("Test Transform", () => {
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
focus: -0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -517,12 +515,9 @@ describe("Test Transform", () => {
|
||||
boundElements: [{ id: text1.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
focus: 0,
|
||||
gap: 1,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
focus: -0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -780,8 +775,8 @@ describe("Test Transform", () => {
|
||||
const [arrow, rect] = excalidrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
focus: -0,
|
||||
gap: 14,
|
||||
fixedPoint: [-2.05, 0.5001],
|
||||
mode: "orbit",
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
@@ -1,12 +1,13 @@
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@@ -15,13 +16,14 @@ export interface Alignment {
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
@@ -33,12 +35,13 @@ export const alignElements = (
|
||||
);
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = mutateElement(element, {
|
||||
const updatedEle = scene.mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
});
|
||||
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { invariant } from "@excalidraw/common";
|
||||
|
||||
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindBindingElement,
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
FOCUS_POINT_SIZE,
|
||||
getBindingGap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
unbindBindingElement,
|
||||
updateBoundPoint,
|
||||
} from "../binding";
|
||||
import {
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
} from "../typeChecks";
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
|
||||
import { moveArrowAboveBindable } from "../zindex";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
PointsPositionUpdates,
|
||||
} from "../types";
|
||||
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export const isFocusPointVisible = (
|
||||
focusPoint: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: {
|
||||
isBindingEnabled: AppState["isBindingEnabled"];
|
||||
zoom: AppState["zoom"];
|
||||
},
|
||||
startOrEnd: "start" | "end",
|
||||
ignoreOverlap = false,
|
||||
): boolean => {
|
||||
// No focus point management for elbow arrows, because elbow arrows
|
||||
// always have their focus point at the arrow point itself
|
||||
if (
|
||||
isElbowArrow(arrow) ||
|
||||
!isBindingEnabled(appState) ||
|
||||
arrow.points.length !== 2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Avoid showing the focus point indicator if the focus point is essentially
|
||||
// on top of the arrow point it belongs to itself, if not ignoring specifically
|
||||
if (!ignoreOverlap) {
|
||||
const associatedPointIdx =
|
||||
arrow.startBinding?.elementId === bindableElement.id
|
||||
? 0
|
||||
: arrow.points.length - 1;
|
||||
const associatedArrowPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
associatedPointIdx,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(focusPoint, associatedArrowPoint) <
|
||||
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "end" ? arrow.points.length - 1 : 0,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Check if the focus point is within the element's shape bounds
|
||||
// Endpoint dragging takes precedence
|
||||
return (
|
||||
pointDistance(focusPoint, arrowPoint) >=
|
||||
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
|
||||
hitElementItself({
|
||||
element: bindableElement,
|
||||
elementsMap,
|
||||
point: focusPoint,
|
||||
threshold: getBindingGap(bindableElement, arrow),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Updates the arrow endpoints in "orbit" configuration
|
||||
const focusPointUpdate = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement | null,
|
||||
isStartBinding: boolean,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
switchToInsideBinding: boolean,
|
||||
) => {
|
||||
const pointUpdates = new Map();
|
||||
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
|
||||
let currentBinding = arrow[bindingField];
|
||||
let adjacentBinding = arrow[adjacentBindingField];
|
||||
|
||||
// Update the dragged focus point related end
|
||||
if (currentBinding && bindableElement) {
|
||||
// Update the targeted bindings
|
||||
const boundToSameElement =
|
||||
bindableElement &&
|
||||
adjacentBinding &&
|
||||
currentBinding.elementId === adjacentBinding.elementId;
|
||||
if (switchToInsideBinding || boundToSameElement) {
|
||||
currentBinding = {
|
||||
...currentBinding,
|
||||
mode: "inside",
|
||||
};
|
||||
} else {
|
||||
currentBinding = {
|
||||
...currentBinding,
|
||||
mode: "orbit",
|
||||
};
|
||||
}
|
||||
|
||||
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
|
||||
const newPoint = updateBoundPoint(
|
||||
arrow,
|
||||
bindingField as "startBinding" | "endBinding",
|
||||
currentBinding,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
if (newPoint) {
|
||||
pointUpdates.set(pointIndex, { point: newPoint });
|
||||
}
|
||||
}
|
||||
|
||||
// Also update the adjacent end if it has a binding
|
||||
if (adjacentBinding && adjacentBinding.mode === "orbit") {
|
||||
const adjacentBindableElement = elementsMap.get(
|
||||
adjacentBinding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
|
||||
if (
|
||||
adjacentBindableElement &&
|
||||
isBindableElement(adjacentBindableElement) &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
// Same shape bound on both ends
|
||||
const boundToSameElementAfterUpdate =
|
||||
bindableElement && adjacentBinding.elementId === bindableElement.id;
|
||||
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "inside",
|
||||
};
|
||||
} else {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "orbit",
|
||||
};
|
||||
}
|
||||
|
||||
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
|
||||
const adjacentNewPoint = updateBoundPoint(
|
||||
arrow,
|
||||
adjacentBindingField,
|
||||
adjacentBinding,
|
||||
adjacentBindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (adjacentNewPoint) {
|
||||
pointUpdates.set(adjacentPointIndex, {
|
||||
point: adjacentNewPoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pointUpdates.size > 0) {
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
|
||||
[bindingField]: currentBinding,
|
||||
[adjacentBindingField]: adjacentBinding,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointDrag = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
pointerCoords: { x: number; y: number },
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
gridSize: NullableGridSize,
|
||||
switchToInsideBinding: boolean,
|
||||
) => {
|
||||
const arrow = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
) as any;
|
||||
|
||||
// Sanity checks
|
||||
if (
|
||||
!arrow ||
|
||||
!isBindingElement(arrow) ||
|
||||
isElbowArrow(arrow) ||
|
||||
!linearElementEditor.hoveredFocusPointBinding ||
|
||||
!linearElementEditor.draggedFocusPointBinding
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isStartBinding =
|
||||
linearElementEditor.draggedFocusPointBinding === "start";
|
||||
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
|
||||
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
|
||||
const point = pointFrom<GlobalPoint>(
|
||||
pointerCoords.x - offsetX,
|
||||
pointerCoords.y - offsetY,
|
||||
);
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const hit = getHoveredElementForFocusPoint(
|
||||
point,
|
||||
arrow,
|
||||
scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
);
|
||||
|
||||
// Hovering a bindable element
|
||||
if (hit && isBindingEnabled(appState)) {
|
||||
// Break existing binding if bound to another shape or if binding is disabled
|
||||
if (arrow[bindingField] && hit.id !== binding?.elementId) {
|
||||
unbindBindingElement(
|
||||
arrow,
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle binding mode switch
|
||||
const newMode =
|
||||
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
|
||||
? "inside"
|
||||
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
|
||||
? "orbit"
|
||||
: null;
|
||||
|
||||
// If no existing binding, create it
|
||||
if (!arrow[bindingField] || newMode) {
|
||||
// Create a new binding if none exists
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
hit,
|
||||
newMode || "orbit",
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
point,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the binding's fixed point
|
||||
scene.mutateElement(arrow, {
|
||||
[bindingField]: {
|
||||
...arrow[bindingField],
|
||||
elementId: hit.id,
|
||||
mode: newMode || arrow[bindingField]?.mode || "orbit",
|
||||
...calculateFixedPointForNonElbowArrowBinding(
|
||||
arrow,
|
||||
hit,
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
elementsMap,
|
||||
point,
|
||||
),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Not hovering any bindable element, move the arrow endpoint
|
||||
const pointUpdates: PointsPositionUpdates = new Map();
|
||||
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
|
||||
pointUpdates.set(pointIndex, {
|
||||
point: LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
point[0],
|
||||
point[1],
|
||||
gridSize,
|
||||
),
|
||||
});
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
|
||||
if (arrow[bindingField]) {
|
||||
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the arrow endpoints
|
||||
focusPointUpdate(
|
||||
arrow,
|
||||
hit,
|
||||
isStartBinding,
|
||||
elementsMap,
|
||||
scene,
|
||||
appState,
|
||||
switchToInsideBinding,
|
||||
);
|
||||
|
||||
if (hit && isBindingEnabled(appState)) {
|
||||
moveArrowAboveBindable(
|
||||
point,
|
||||
arrow,
|
||||
scene.getElementsIncludingDeleted(),
|
||||
elementsMap,
|
||||
scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointPointerDown = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
pointerDownState: { origin: { x: number; y: number } },
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
): {
|
||||
hitFocusPoint: "start" | "end" | null;
|
||||
pointerOffset: { x: number; y: number };
|
||||
} => {
|
||||
const pointerPos = pointFrom(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
|
||||
|
||||
// Check start binding focus point
|
||||
if (arrow.startBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"start",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return {
|
||||
hitFocusPoint: "start",
|
||||
pointerOffset: {
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check end binding focus point (only if start not already hit)
|
||||
if (arrow.endBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"end",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return {
|
||||
hitFocusPoint: "end",
|
||||
pointerOffset: {
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hitFocusPoint: null,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
};
|
||||
};
|
||||
|
||||
export const handleFocusPointPointerUp = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
) => {
|
||||
invariant(
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
"Must have a dragged focus point at pointer release",
|
||||
);
|
||||
|
||||
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
|
||||
linearElementEditor.elementId,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
invariant(arrow, "Arrow must be in the scene");
|
||||
|
||||
// Clean up
|
||||
const bindingKey =
|
||||
linearElementEditor.draggedFocusPointBinding === "start"
|
||||
? "startBinding"
|
||||
: "endBinding";
|
||||
const otherBindingKey =
|
||||
linearElementEditor.draggedFocusPointBinding === "start"
|
||||
? "endBinding"
|
||||
: "startBinding";
|
||||
const boundElementId = arrow[bindingKey]?.elementId;
|
||||
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
|
||||
const oldBoundElement =
|
||||
boundElementId &&
|
||||
scene
|
||||
.getNonDeletedElements()
|
||||
.find(
|
||||
(element) =>
|
||||
element.id !== boundElementId &&
|
||||
element.id !== otherBoundElementId &&
|
||||
isBindableElement(element) &&
|
||||
element.boundElements?.find(({ id }) => id === arrow.id),
|
||||
);
|
||||
if (oldBoundElement) {
|
||||
scene.mutateElement(oldBoundElement, {
|
||||
boundElements: oldBoundElement.boundElements?.filter(
|
||||
({ id }) => id !== arrow.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Record the new bound element
|
||||
const boundElement =
|
||||
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
|
||||
if (boundElement) {
|
||||
scene.mutateElement(boundElement, {
|
||||
boundElements: [
|
||||
...(boundElement.boundElements || [])?.filter(
|
||||
({ id }) => id !== arrow.id,
|
||||
),
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointHover = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
): "start" | "end" | null => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const pointerPos = pointFrom(scenePointerX, scenePointerY);
|
||||
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
|
||||
|
||||
// Check start binding focus point
|
||||
if (arrow.startBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"start",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return "start";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check end binding focus point (only if start not already hovered)
|
||||
if (arrow.endBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"end",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return "end";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { App } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
|
||||
import { handleFocusPointDrag } from "./focus";
|
||||
|
||||
export const maybeHandleArrowPointlikeDrag = ({
|
||||
app,
|
||||
event,
|
||||
}: {
|
||||
app: App;
|
||||
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
|
||||
}): boolean => {
|
||||
const appState = app.state;
|
||||
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
|
||||
// Update focus point status if the binding mode is changing
|
||||
if (appState.selectedLinearElement.draggedFocusPointBinding) {
|
||||
handleFocusPointDrag(
|
||||
appState.selectedLinearElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.lastPointerMoveCoords,
|
||||
app.scene,
|
||||
appState,
|
||||
app.getEffectiveGridSize(),
|
||||
event.altKey,
|
||||
);
|
||||
return true;
|
||||
} else if (
|
||||
appState.selectedLinearElement.hoverPointIndex !== null &&
|
||||
app.lastPointerMoveEvent &&
|
||||
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
|
||||
appState.selectedLinearElement.isDragging
|
||||
) {
|
||||
LinearElementEditor.handlePointDragging(
|
||||
app.lastPointerMoveEvent,
|
||||
app,
|
||||
app.lastPointerMoveCoords.x,
|
||||
app.lastPointerMoveCoords.y,
|
||||
appState.selectedLinearElement,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
+2035
-1295
File diff suppressed because it is too large
Load Diff
+178
-45
@@ -1,17 +1,18 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
rescalePoints,
|
||||
arrayToMap,
|
||||
type Bounds,
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
@@ -33,8 +34,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@@ -42,30 +43,31 @@ import {
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shapes";
|
||||
import { getElementShape } from "./shape";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ElementsMap,
|
||||
ExcalidrawRectanguloidElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ElementsMapOrArray,
|
||||
} from "./types";
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import type {
|
||||
Arrowhead,
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
@@ -77,16 +79,6 @@ export type RectangleBox = {
|
||||
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
/**
|
||||
* x and y position of top left corner, x and y position of bottom right corner
|
||||
*/
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export type SceneBounds = readonly [
|
||||
sceneX: number,
|
||||
sceneY: number,
|
||||
@@ -102,9 +94,23 @@ export class ElementBounds {
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
private static nonRotatedBoundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
static getBounds(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
) {
|
||||
const cachedBounds =
|
||||
nonRotated && element.angle !== 0
|
||||
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||
: ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (
|
||||
cachedBounds?.version &&
|
||||
@@ -115,6 +121,23 @@ export class ElementBounds {
|
||||
) {
|
||||
return cachedBounds.bounds;
|
||||
}
|
||||
|
||||
if (nonRotated && element.angle !== 0) {
|
||||
const nonRotatedBounds = ElementBounds.calculateBounds(
|
||||
{
|
||||
...element,
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
ElementBounds.nonRotatedBoundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds: nonRotatedBounds,
|
||||
});
|
||||
|
||||
return nonRotatedBounds;
|
||||
}
|
||||
|
||||
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
@@ -290,19 +313,42 @@ export const getElementLineSegments = (
|
||||
|
||||
if (shape.type === "polycurve") {
|
||||
const curves = shape.data;
|
||||
const points = curves
|
||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
||||
.flat();
|
||||
let i = 0;
|
||||
const pointsOnCurves = curves.map((curve) =>
|
||||
pointsOnBezierCurves(curve, 10),
|
||||
);
|
||||
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
|
||||
if (
|
||||
(isLineElement(element) && !element.polygon) ||
|
||||
isArrowElement(element)
|
||||
) {
|
||||
for (const points of pointsOnCurves) {
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const points = pointsOnCurves.flat();
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
@@ -553,7 +599,7 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
export const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
@@ -851,6 +897,7 @@ export const getArrowheadPoints = (
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
// TODO reuse shape.ts
|
||||
const generateLinearElementShape = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Drawable => {
|
||||
@@ -908,7 +955,7 @@ const getLinearElementRotatedBounds = (
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const cachedShape = ShapeCache.get(element, null)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
@@ -939,8 +986,9 @@ const getLinearElementRotatedBounds = (
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
): Bounds => {
|
||||
return ElementBounds.getBounds(element, elementsMap);
|
||||
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
@@ -1094,7 +1142,9 @@ export interface BoundingBox {
|
||||
}
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements:
|
||||
| readonly ExcalidrawElement[]
|
||||
| readonly NonDeleted<ExcalidrawElement>[],
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
@@ -1133,6 +1183,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box for a given element
|
||||
*/
|
||||
export const aabbForElement = (
|
||||
element: Readonly<ExcalidrawElement>,
|
||||
elementsMap: ElementsMap,
|
||||
offset?: [number, number, number, number],
|
||||
) => {
|
||||
const bbox = {
|
||||
minX: element.x,
|
||||
minY: element.y,
|
||||
maxX: element.x + element.width,
|
||||
maxY: element.y + element.height,
|
||||
midX: element.x + element.width / 2,
|
||||
midY: element.y + element.height / 2,
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const [topLeftX, topLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [topRightX, topRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.minY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||
pointFrom(bbox.maxX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||
pointFrom(bbox.minX, bbox.maxY),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
const bounds = [
|
||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||
] as Bounds;
|
||||
|
||||
if (offset) {
|
||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||
return [
|
||||
bounds[0] - leftOffset,
|
||||
bounds[1] - topOffset,
|
||||
bounds[2] + rightOffset,
|
||||
bounds[3] + downOffset,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1146,3 +1261,21 @@ export const doBoundsIntersect = (
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
if (isLinearElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
}
|
||||
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
};
|
||||
|
||||
+644
-130
@@ -1,53 +1,80 @@
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseLineIntersectionPoints,
|
||||
ellipseSegmentInterceptPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Polygon,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
@@ -72,45 +99,101 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
export type HitTestArgs = {
|
||||
point: GlobalPoint;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
overrideShouldTestInside?: boolean;
|
||||
};
|
||||
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
let cachedPoint: GlobalPoint | null = null;
|
||||
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
|
||||
let cachedThreshold: number = Infinity;
|
||||
let cachedHit: boolean = false;
|
||||
let cachedOverrideShouldTestInside = false;
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
element,
|
||||
threshold,
|
||||
elementsMap,
|
||||
frameNameBound = null,
|
||||
overrideShouldTestInside = false,
|
||||
}: HitTestArgs) => {
|
||||
// Return cached result if the same point and element version is tested again
|
||||
if (
|
||||
cachedPoint &&
|
||||
pointsEqual(point, cachedPoint) &&
|
||||
cachedThreshold <= threshold &&
|
||||
overrideShouldTestInside === cachedOverrideShouldTestInside
|
||||
) {
|
||||
const derefElement = cachedElement?.deref();
|
||||
if (
|
||||
derefElement &&
|
||||
derefElement.id === element.id &&
|
||||
derefElement.version === element.version &&
|
||||
derefElement.versionNonce === element.versionNonce
|
||||
) {
|
||||
return cachedHit;
|
||||
}
|
||||
}
|
||||
|
||||
return hit;
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||
point,
|
||||
pointFrom(
|
||||
frameNameBound.x + frameNameBound.width + threshold,
|
||||
frameNameBound.y + frameNameBound.height + threshold,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||
if (!hitBounds && !hitFrameName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = (
|
||||
overrideShouldTestInside ? true : shouldTestInside(element)
|
||||
)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInElement(point, element, elementsMap) ||
|
||||
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||
|
||||
const result = hitElement || hitFrameName;
|
||||
|
||||
// Cache end result
|
||||
cachedPoint = point;
|
||||
cachedElement = new WeakRef(element);
|
||||
cachedThreshold = threshold;
|
||||
cachedOverrideShouldTestInside = overrideShouldTestInside;
|
||||
cachedHit = result;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
x: number,
|
||||
y: number,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
@@ -120,37 +203,202 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
hitArgs: HitTestArgs,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(
|
||||
hitArgs.x,
|
||||
hitArgs.y,
|
||||
getBoundTextShape(hitArgs.element, elementsMap),
|
||||
) &&
|
||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
||||
);
|
||||
) =>
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||
|
||||
export const hitElementBoundText = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
if (element.frameId) {
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
const enclosingFrameBounds = getElementBounds(
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateElements;
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
tolerance,
|
||||
);
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
export const getHoveredElementForFocusPoint = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): ExcalidrawBindableElement | null => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
const distanceFilteredCandidateElements = candidateElements
|
||||
// Resolve by distance
|
||||
.filter(
|
||||
(el) =>
|
||||
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
|
||||
isPointInElement(point, el, elementsMap),
|
||||
);
|
||||
|
||||
if (distanceFilteredCandidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,9 +411,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
*/
|
||||
export const intersectElementWithLineSegment = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
line: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// First check if the line intersects the element's axis-aligned bounding box
|
||||
// as it is much faster than checking intersection against the element's shape
|
||||
const intersectorBounds = [
|
||||
Math.min(line[0][0] - offset, line[1][0] - offset),
|
||||
Math.min(line[0][1] - offset, line[1][1] - offset),
|
||||
Math.max(line[0][0] + offset, line[1][0] + offset),
|
||||
Math.max(line[0][1] + offset, line[1][1] + offset),
|
||||
] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
@@ -173,67 +438,196 @@ export const intersectElementWithLineSegment = (
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "selection":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
return intersectRectanguloidWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
return intersectDiamondWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
return intersectEllipseWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
);
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(intersection);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
l[0],
|
||||
segment[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
l[1],
|
||||
segment[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
return (
|
||||
// Test intersection against the sides, keep only the valid
|
||||
// intersection points and rotate them back to scene space
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.filter((x) => x != null)
|
||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
|
||||
// Test intersection against the corners which are cubic bezier curves,
|
||||
// keep only the valid intersection points and rotate them back to scene
|
||||
// space
|
||||
.concat(
|
||||
corners
|
||||
.flatMap((t) =>
|
||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
||||
)
|
||||
.filter((i) => i != null)
|
||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
||||
)
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
||||
)
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -245,43 +639,45 @@ const intersectRectanguloidWithLineSegment = (
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
return (
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.filter((p): p is GlobalPoint => p != null)
|
||||
// Rotate back intersection points
|
||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
|
||||
.concat(
|
||||
curves
|
||||
.flatMap((p) =>
|
||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
||||
)
|
||||
.filter((p) => p != null)
|
||||
// Rotate back intersection points
|
||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
||||
)
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
||||
)
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
if (onlyFirst && intersections.length > 0) {
|
||||
return intersections;
|
||||
}
|
||||
|
||||
curveIntersections(
|
||||
corners,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
);
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -293,16 +689,134 @@ const intersectDiamondWithLineSegment = (
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseLineIntersectionPoints(
|
||||
return ellipseSegmentInterceptPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
line(rotatedA, rotatedB),
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given point is considered on the given shape's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @param tolerance
|
||||
* @returns
|
||||
*/
|
||||
const isPointOnElementOutline = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 1,
|
||||
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
||||
|
||||
/**
|
||||
* Check if the given point is considered inside the element's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
export const isPointInElement = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (
|
||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||
!isPathALoop(element.points)
|
||||
) {
|
||||
// There isn't any "inside" for a non-looping path
|
||||
return false;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const otherPoint = pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
||||
Math.max(element.width, element.height) * 2,
|
||||
),
|
||||
center,
|
||||
);
|
||||
const intersector = lineSegment(point, otherPoint);
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
intersector,
|
||||
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
||||
export const isBindableElementInsideOtherBindable = (
|
||||
innerElement: ExcalidrawBindableElement,
|
||||
outerElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
// Get corner points of the inner element based on its type
|
||||
const getCornerPoints = (
|
||||
element: ExcalidrawElement,
|
||||
offset: number,
|
||||
): GlobalPoint[] => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
// Diamond has 4 corner points at the middle of each side
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x + topX, y + topY - offset), // top
|
||||
pointFrom(x + rightX + offset, y + rightY), // right
|
||||
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||
pointFrom(x + leftX - offset, y + leftY), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
if (element.type === "ellipse") {
|
||||
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const rx = width / 2;
|
||||
const ry = height / 2;
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(cx, cy - ry - offset), // top
|
||||
pointFrom(cx + rx + offset, cy), // right
|
||||
pointFrom(cx, cy + ry + offset), // bottom
|
||||
pointFrom(cx - rx - offset, cy), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x - offset, y - offset), // top-left
|
||||
pointFrom(x + width + offset, y - offset), // top-right
|
||||
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
};
|
||||
|
||||
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||
const innerCorners = getCornerPoints(innerElement, offset);
|
||||
|
||||
// Check if all corner points of the inner element are inside the outer element
|
||||
return innerCorners.every((corner) =>
|
||||
isPointInElement(corner, outerElement, elementsMap),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,14 @@ export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
type === "rectangle" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "text" ||
|
||||
type === "embeddable";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
|
||||
|
||||
export const cropElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandle: TransformHandleType,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
@@ -63,7 +63,7 @@ export const cropElement = (
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
elementCenterPoint(element),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user