Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3125b8a98 | |||
| 733e45f3a9 |
+2
-4
@@ -1,5 +1,3 @@
|
||||
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/
|
||||
|
||||
@@ -12,7 +10,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:3016
|
||||
VITE_APP_AI_BACKEND=http://localhost:3015
|
||||
|
||||
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"}'
|
||||
|
||||
@@ -27,7 +25,7 @@ VITE_APP_ENABLE_TRACKING=true
|
||||
FAST_REFRESH=false
|
||||
|
||||
# The port the run the dev server
|
||||
VITE_APP_PORT=3001
|
||||
VITE_APP_PORT=3000
|
||||
|
||||
#Debug flags
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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/
|
||||
|
||||
|
||||
+1
-28
@@ -32,33 +32,6 @@
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.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 release --tag=next --non-interactive
|
||||
yarn autorelease
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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 }}"
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0
|
||||
- uses: styfle/cancel-workflow-action@0.6.0
|
||||
with:
|
||||
workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604
|
||||
access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Update description with coverage
|
||||
uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1
|
||||
uses: kt3k/update-pr-description@v1.0.1
|
||||
with:
|
||||
pr_body: ${{ steps.getCommentBody.outputs.body }}
|
||||
pr_title: "chore: Update translations from Crowdin"
|
||||
|
||||
@@ -13,18 +13,13 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
semantic:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.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 sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --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,16 +11,16 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install in packages/excalidraw
|
||||
run: yarn
|
||||
working-directory: packages/excalidraw
|
||||
env:
|
||||
CI: true
|
||||
- uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1
|
||||
- uses: andresz1/size-limit-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_script: build:esm
|
||||
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "18.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn install
|
||||
- name: "Test Coverage"
|
||||
run: yarn test:coverage
|
||||
- name: "Report Coverage"
|
||||
if: always() # Also generate the report if tests are failing
|
||||
uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
+6
-1
@@ -26,4 +26,9 @@ coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,14 +6,13 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
RUN yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -23,17 +23,20 @@
|
||||
<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&widget=false"/></a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></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://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">
|
||||
@@ -60,7 +63,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...
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -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 [`useEditorInterface`](#useEditorInterface) 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 [`useDevice`](#useDevice) 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 editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
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
|
||||
|
||||
@@ -172,7 +172,7 @@ convertToExcalidrawElements([
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "circle",
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
|
||||
@@ -363,7 +363,13 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
```ts
|
||||
(
|
||||
tool: (
|
||||
| { type: ToolType }
|
||||
| (
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| {
|
||||
type: Extract<ToolType, "image">;
|
||||
insertOnCanvasDirectly?: boolean;
|
||||
}
|
||||
)
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean },
|
||||
) => {};
|
||||
@@ -371,7 +377,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 |
|
||||
| `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` |
|
||||
| `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>
|
||||
|
||||
### useEditorInterface
|
||||
### useDevice
|
||||
|
||||
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 editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@@ -336,20 +336,12 @@ render(<App />);
|
||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
|
||||
### i18n
|
||||
|
||||
|
||||
@@ -28,12 +28,32 @@ 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 release --tag=latest --version=0.19.0
|
||||
yarn prerelease:excalidraw
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -38,8 +38,6 @@ 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,
|
||||
{
|
||||
|
||||
@@ -97,8 +97,8 @@ const config = {
|
||||
href: "https://discord.gg/UexuTaE",
|
||||
},
|
||||
{
|
||||
label: "𝕏",
|
||||
href: "https://x.com/excalidraw",
|
||||
label: "Twitter",
|
||||
href: "https://twitter.com/excalidraw",
|
||||
},
|
||||
{
|
||||
label: "Linkedin",
|
||||
|
||||
@@ -33,7 +33,6 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
excalidraw:
|
||||
build:
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:packages": "yarn --cwd ../../ build:packages",
|
||||
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && 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 .selected-shape-actions {
|
||||
.excalidraw .panelColumn {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ const MobileFooter = ({
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}) => {
|
||||
const { useEditorInterface, Footer } = excalidrawLib;
|
||||
const { useDevice, Footer } = excalidrawLib;
|
||||
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<CustomFooter
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/element/transform";
|
||||
import type { ExcalidrawElementSkeleton } from "@excalidraw/excalidraw/data/transform";
|
||||
import type { FileId } from "@excalidraw/excalidraw/element/types";
|
||||
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react-dom": "19.0.0",
|
||||
"@excalidraw/excalidraw": "*",
|
||||
"browser-fs-access": "0.29.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vite": "5.0.12"
|
||||
"vite": "5.0.12",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"build:preview": "yarn build && yarn preview",
|
||||
"build:packages": "yarn --cwd ../../ build:packages"
|
||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ 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;
|
||||
@@ -52,6 +54,40 @@ 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:packages && yarn build"
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
}
|
||||
|
||||
+41
-162
@@ -4,9 +4,6 @@ import {
|
||||
TTDDialogTrigger,
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
useEditorInterface,
|
||||
ExcalidrawAPIProvider,
|
||||
useExcalidrawAPI,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -23,6 +20,7 @@ import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -36,6 +34,7 @@ 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 {
|
||||
@@ -48,14 +47,10 @@ import {
|
||||
share,
|
||||
youtubeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
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 { 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 clsx from "clsx";
|
||||
import {
|
||||
parseLibraryTokensFromUrl,
|
||||
@@ -75,7 +70,6 @@ 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";
|
||||
@@ -111,12 +105,11 @@ 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,
|
||||
@@ -127,7 +120,6 @@ import {
|
||||
LibraryIndexedDBAdapter,
|
||||
LibraryLocalStorageMigrationAdapter,
|
||||
LocalData,
|
||||
localStorageQuotaExceededAtom,
|
||||
} from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
@@ -145,9 +137,6 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
|
||||
import { AppSidebar } from "./components/AppSidebar";
|
||||
|
||||
import type { CollabAPI } from "./collab/Collab";
|
||||
|
||||
polyfill();
|
||||
@@ -231,20 +220,9 @@ const initializeScene = async (opts: {
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: Omit<
|
||||
RestoredDataState,
|
||||
// we're not storing files in the scene database/localStorage, and instead
|
||||
// fetch them async from a different store
|
||||
"files"
|
||||
> & {
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
} = {
|
||||
elements: restoreElements(localDataState?.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
appState: restoreAppState(localDataState?.appState, null),
|
||||
};
|
||||
} = await loadScene(null, null, localDataState);
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
@@ -258,26 +236,11 @@ const initializeScene = async (opts: {
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
const imported = await importFromBackend(
|
||||
scene = await loadScene(
|
||||
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) {
|
||||
@@ -372,8 +335,6 @@ const initializeScene = async (opts: {
|
||||
};
|
||||
|
||||
const ExcalidrawWrapper = () => {
|
||||
const excalidrawAPI = useExcalidrawAPI();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
@@ -381,8 +342,6 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
const editorInterface = useEditorInterface();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -404,6 +363,9 @@ const ExcalidrawWrapper = () => {
|
||||
}, VERSION_TIMEOUT);
|
||||
}, []);
|
||||
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@@ -435,15 +397,18 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}, [excalidrawAPI]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoisted loadImages
|
||||
// ---------------------------------------------------------------------------
|
||||
const loadImages = useCallback(
|
||||
(data: ResolutionType<typeof initializeScene>, isInitialLoad = false) => {
|
||||
if (!data.scene || !excalidrawAPI) {
|
||||
useEffect(() => {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadImages = (
|
||||
data: ResolutionType<typeof initializeScene>,
|
||||
isInitialLoad = false,
|
||||
) => {
|
||||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
@@ -470,12 +435,6 @@ 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,
|
||||
@@ -487,18 +446,12 @@ 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(async ({ loadedFiles, erroredFiles }) => {
|
||||
.then(({ loadedFiles, erroredFiles }) => {
|
||||
if (loadedFiles.length) {
|
||||
excalidrawAPI.addFiles(loadedFiles);
|
||||
}
|
||||
@@ -511,19 +464,10 @@ 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);
|
||||
@@ -546,10 +490,8 @@ const ExcalidrawWrapper = () => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements: restoreElements(data.scene.elements, null, {
|
||||
repairBindings: true,
|
||||
}),
|
||||
appState: restoreAppState(data.scene.appState, null),
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
}
|
||||
@@ -557,6 +499,11 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
@@ -647,8 +594,9 @@ const ExcalidrawWrapper = () => {
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
@@ -721,8 +669,8 @@ const ExcalidrawWrapper = () => {
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
elements,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -786,63 +734,11 @@ 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
|
||||
@@ -909,8 +805,8 @@ const ExcalidrawWrapper = () => {
|
||||
})}
|
||||
>
|
||||
<Excalidraw
|
||||
excalidrawAPI={excalidrawRefCallback}
|
||||
onChange={onChange}
|
||||
onExport={onExport}
|
||||
initialData={initialStatePromiseRef.current.promise}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={collabAPI?.onPointerUpdate}
|
||||
@@ -956,22 +852,14 @@ const ExcalidrawWrapper = () => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="excalidraw-ui-top-right">
|
||||
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
||||
<ExcalidrawPlusPromoBanner
|
||||
isSignedIn={isExcalidrawPlusSignedUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="top-right-ui">
|
||||
{collabError.message && <CollabError collabError={collabError} />}
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
editorInterface={editorInterface}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1020,15 +908,10 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="alertalert--warning">
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{localStorageQuotaExceeded && (
|
||||
<div className="alert alert--danger">
|
||||
{t("alerts.localStorageQuotaExceeded")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
@@ -1057,8 +940,6 @@ const ExcalidrawWrapper = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppSidebar />
|
||||
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
@@ -1276,9 +1157,7 @@ const ExcalidrawApp = () => {
|
||||
return (
|
||||
<TopErrorBoundary>
|
||||
<Provider store={appJotaiStore}>
|
||||
<ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</ExcalidrawAPIProvider>
|
||||
<ExcalidrawWrapper />
|
||||
</Provider>
|
||||
</TopErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,7 @@ 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
|
||||
|
||||
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
@@ -46,7 +45,6 @@ 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, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
|
||||
import { APP_NAME, EVENT } from "@excalidraw/common";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
@@ -19,9 +19,12 @@ import {
|
||||
throttleRAF,
|
||||
} from "@excalidraw/common";
|
||||
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isImageElement, isInitializedImageElement } from "@excalidraw/element";
|
||||
import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import {
|
||||
isImageElement,
|
||||
isInitializedImageElement,
|
||||
} from "@excalidraw/element/typeChecks";
|
||||
import { AbortError } from "@excalidraw/excalidraw/errors";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
@@ -29,8 +32,6 @@ 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,
|
||||
@@ -72,7 +73,6 @@ import {
|
||||
FileManager,
|
||||
updateStaleImageStatuses,
|
||||
} from "../data/FileManager";
|
||||
import { FileStatusStore } from "../data/fileStatusStore";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
@@ -150,7 +150,6 @@ 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) {
|
||||
@@ -315,7 +314,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
syncableElements = cloneJSON(syncableElements);
|
||||
try {
|
||||
const storedElements = await saveToFirebase(
|
||||
this.portal,
|
||||
@@ -446,7 +444,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array<ArrayBuffer>,
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
@@ -535,10 +533,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingRoomLinkData) {
|
||||
// when joining existing room, don't merge it with current scene data
|
||||
this.excalidrawAPI.resetScene();
|
||||
} else {
|
||||
if (!existingRoomLinkData) {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
@@ -567,7 +562,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<ArrayBuffer>) => {
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
@@ -584,9 +579,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = toBrandedType<
|
||||
readonly RemoteExcalidrawElement[]
|
||||
>(decryptedData.payload.elements);
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements =
|
||||
this._reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements);
|
||||
@@ -600,11 +593,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this._reconcileElements(
|
||||
toBrandedType<readonly RemoteExcalidrawElement[]>(
|
||||
decryptedData.payload.elements,
|
||||
),
|
||||
),
|
||||
this._reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
@@ -753,28 +742,17 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private _reconcileElements = (
|
||||
remoteElements: readonly RemoteExcalidrawElement[],
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
): ReconciledExcalidrawElement[] => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
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,
|
||||
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||
const reconciledElements = reconcileElements(
|
||||
localElements,
|
||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||
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";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import type { UserIdleState } from "@excalidraw/common";
|
||||
|
||||
@@ -4,15 +4,12 @@ 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,
|
||||
}: {
|
||||
@@ -76,7 +73,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="noopener">Excalidraw+</a> to get more requests.</div>
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
@@ -102,23 +99,61 @@ export const AIComponents = ({
|
||||
/>
|
||||
|
||||
<TTDDialog
|
||||
onTextSubmit={async (props) => {
|
||||
const { onChunk, onStreamCreated, signal, messages } = props;
|
||||
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 }),
|
||||
},
|
||||
);
|
||||
|
||||
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 rateLimit = response.headers.has("X-Ratelimit-Limit")
|
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
|
||||
: undefined;
|
||||
|
||||
return result;
|
||||
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");
|
||||
}
|
||||
}}
|
||||
persistenceAdapter={TTDIndexedDBAdapter}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 }) => {
|
||||
@@ -18,7 +19,11 @@ export const AppFooter = React.memo(
|
||||
}}
|
||||
>
|
||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{
|
||||
{isDevEnv() && (
|
||||
<MainMenu.Item
|
||||
icon={eyeIcon}
|
||||
onSelect={() => {
|
||||
onClick={() => {
|
||||
if (window.visualDebug) {
|
||||
delete window.visualDebug;
|
||||
saveDebugState({ enabled: false });
|
||||
@@ -77,7 +77,6 @@ export const AppMainMenu: React.FC<{
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.Preferences />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
theme={props.theme}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
.excalidraw {
|
||||
.app-sidebar-promo-container {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
flex: 1 1 auto;
|
||||
|
||||
.link-button {
|
||||
margin: 0 auto !important;
|
||||
width: 100%;
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar-promo-image {
|
||||
margin: 1rem 0;
|
||||
|
||||
height: 295px;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: calc(
|
||||
var(--right-sidebar-width, 100%) - var(--space-factor) * 2
|
||||
);
|
||||
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
transparent 0%,
|
||||
var(--sidebar-bg-color) 100%
|
||||
),
|
||||
var(--image-source);
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-text {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-text {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-illustration {
|
||||
position: relative;
|
||||
min-height: 7rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 0.125rem;
|
||||
|
||||
.app-sidebar-promo-heart {
|
||||
width: 46px;
|
||||
transform: translateX(-14px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-trial-note {
|
||||
position: absolute;
|
||||
top: 2.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-56%) rotate(-7deg);
|
||||
font-size: 1rem;
|
||||
line-height: 1.15;
|
||||
color: var(--color-primary);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-trial-arrow {
|
||||
position: absolute;
|
||||
top: 3.35rem;
|
||||
left: 55%;
|
||||
width: 4.5rem;
|
||||
height: 3rem;
|
||||
transform: translateX(1.75rem) rotate(10deg);
|
||||
color: var(--color-primary);
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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";
|
||||
|
||||
type SidebarPromoCopyProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const SidebarPromoCopy = (props: SidebarPromoCopyProps) => {
|
||||
return (
|
||||
<div className="app-sidebar-promo-copy">
|
||||
<div className="app-sidebar-promo-illustration" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 300 250"
|
||||
className="app-sidebar-promo-heart"
|
||||
>
|
||||
<path
|
||||
d="M 145 75
|
||||
C 110 35, 60 55, 65 120
|
||||
C 70 180, 140 190, 215 200
|
||||
C 225 180, 260 110, 235 55
|
||||
C 210 -5, 140 20, 160 105"
|
||||
fill="none"
|
||||
stroke="#D06B64"
|
||||
strokeWidth="16"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="app-sidebar-promo-trial-note excalifont">
|
||||
14 days of
|
||||
<br />
|
||||
free trial
|
||||
</div>
|
||||
<svg
|
||||
className="app-sidebar-promo-trial-arrow"
|
||||
viewBox="0 0 72 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 6C23 1 50 8 48 32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M42 26L48 32L54 26"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="app-sidebar-promo-text">{props.text}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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(/sidebar-comments-promo-${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.jpg)`,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
<SidebarPromoCopy text="Make comments with Excalidraw+" />
|
||||
<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(/sidebar-presentation-promo-${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.jpg)`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<SidebarPromoCopy text="Create presentation with Excalidraw+" />
|
||||
<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,15 +33,7 @@ export const AppWelcomeScreen: React.FC<{
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
headingContent = (
|
||||
<>
|
||||
{t("welcomeScreen.app.center_heading")}
|
||||
<br />
|
||||
{t("welcomeScreen.app.center_heading_line2")}
|
||||
<br />
|
||||
{t("welcomeScreen.app.center_heading_line3")}
|
||||
</>
|
||||
);
|
||||
headingContent = t("welcomeScreen.app.center_heading");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,14 +8,8 @@ import {
|
||||
getNormalizedCanvasDimensions,
|
||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||
import { arrayToMap, throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
} from "@excalidraw/element";
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import {
|
||||
isLineSegment,
|
||||
@@ -24,20 +18,9 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import React from "react";
|
||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||
|
||||
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";
|
||||
|
||||
@@ -78,44 +61,6 @@ 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();
|
||||
@@ -128,176 +73,6 @@ 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,
|
||||
@@ -321,9 +96,6 @@ 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)}`);
|
||||
}
|
||||
@@ -333,14 +105,18 @@ 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,
|
||||
@@ -357,7 +133,6 @@ const _debugRenderer = (
|
||||
);
|
||||
|
||||
renderOrigin(context, appState.zoom.value);
|
||||
renderBindings(context, elements, appState.zoom.value);
|
||||
|
||||
if (
|
||||
window.visualDebug?.currentFrame &&
|
||||
@@ -409,11 +184,12 @@ export const debugRenderer = throttleRAF(
|
||||
(
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, elements, scale);
|
||||
_debugRenderer(canvas, appState, scale, refresh);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
export const loadSavedDebugState = () => {
|
||||
@@ -538,29 +314,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
ref?: React.Ref<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={ref}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t("encrypted.link")}
|
||||
>
|
||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
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";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { newElementWith } from "@excalidraw/element/mutateElement";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
|
||||
import type {
|
||||
@@ -40,12 +40,10 @@ export class FileManager {
|
||||
|
||||
private _getFiles;
|
||||
private _saveFiles;
|
||||
private _onFileStatusChange;
|
||||
|
||||
constructor({
|
||||
getFiles,
|
||||
saveFiles,
|
||||
onFileStatusChange,
|
||||
}: {
|
||||
getFiles: (fileIds: FileId[]) => Promise<{
|
||||
loadedFiles: BinaryFileData[];
|
||||
@@ -55,13 +53,9 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,8 +146,6 @@ export class FileManager {
|
||||
this.fetchingFiles.set(id, true);
|
||||
}
|
||||
|
||||
this._onFileStatusChange?.(ids.map((id) => [id, "loading"]));
|
||||
|
||||
try {
|
||||
const { loadedFiles, erroredFiles } = await this._getFiles(ids);
|
||||
|
||||
@@ -164,13 +156,6 @@ 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) {
|
||||
@@ -210,13 +195,6 @@ 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,6 +16,7 @@ import {
|
||||
DEFAULT_SIDEBAR,
|
||||
debounce,
|
||||
} from "@excalidraw/common";
|
||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
||||
import {
|
||||
createStore,
|
||||
entries,
|
||||
@@ -26,9 +27,6 @@ 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";
|
||||
@@ -42,14 +40,11 @@ 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) => {
|
||||
@@ -74,9 +69,6 @@ const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||
localStorageQuotaExceededAtom,
|
||||
);
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
@@ -89,29 +81,19 @@ const saveDataStateToLocalStorage = (
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(getNonDeletedElements(elements)),
|
||||
JSON.stringify(clearElementsForLocalStorage(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 {
|
||||
@@ -167,7 +149,6 @@ export class LocalData {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static fileStorage = new LocalFileManager({
|
||||
onFileStatusChange: FileStatusStore.updateStatuses.bind(FileStatusStore),
|
||||
getFiles(ids) {
|
||||
return getMany(ids, filesStore).then(
|
||||
async (filesData: (BinaryFileData | undefined)[]) => {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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, toBrandedType } from "@excalidraw/common";
|
||||
import { MIME_TYPES } 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() as Uint8Array<ArrayBuffer>;
|
||||
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
const ciphertext = data.ciphertext.toUint8Array();
|
||||
const iv = data.iv.toUint8Array();
|
||||
|
||||
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 toBrandedType<RemoteExcalidrawElement[]>(storedElements);
|
||||
return storedElements;
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
@@ -259,9 +259,7 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -8,14 +8,15 @@ import {
|
||||
IV_LENGTH_BYTES,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element/sizeHelpers";
|
||||
import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
|
||||
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";
|
||||
import type { SceneBounds } from "@excalidraw/element/bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
@@ -83,13 +84,13 @@ export type SocketUpdateDataSource = {
|
||||
SCENE_INIT: {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
@@ -199,7 +200,7 @@ const legacy_decodeFromBackend = async ({
|
||||
};
|
||||
};
|
||||
|
||||
export const importFromBackend = async (
|
||||
const importFromBackend = async (
|
||||
id: string,
|
||||
decryptionKey: string,
|
||||
): Promise<ImportedDataState> => {
|
||||
@@ -241,6 +242,40 @@ export 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,6 +2,7 @@ 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";
|
||||
@@ -49,7 +50,7 @@ export const importFromLocalStorage = () => {
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = JSON.parse(savedElements);
|
||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// Do nothing because elements array is already empty
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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;
|
||||
|
||||
@@ -16,35 +24,34 @@ 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) {
|
||||
@@ -58,18 +65,8 @@ export class Debug {
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
// 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)`,
|
||||
);
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
@@ -79,24 +76,6 @@ 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") => {
|
||||
@@ -130,7 +109,7 @@ export class Debug {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug[type](performance.now() - t0, name);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
@@ -151,70 +130,6 @@ 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;
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw Whiteboard</title>
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</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="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
+14
-27
@@ -1,5 +1,3 @@
|
||||
@import "../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||
|
||||
@@ -7,6 +5,12 @@
|
||||
--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;
|
||||
@@ -54,7 +58,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
.collab-offline-warning {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
@@ -65,18 +69,10 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,31 +82,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.plus-banner {
|
||||
.plus-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--color-primary) !important;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.8333rem;
|
||||
font-size: 0.75rem;
|
||||
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;
|
||||
@@ -122,7 +109,7 @@
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-banner {
|
||||
.plus-button {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
@@ -36,7 +36,6 @@
|
||||
"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,4 +1,3 @@
|
||||
import { getFeatureFlag } from "@excalidraw/common";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import callsites from "callsites";
|
||||
|
||||
@@ -34,7 +33,6 @@ Sentry.init({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
Sentry.featureFlagsIntegration(),
|
||||
],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
@@ -81,14 +79,3 @@ Sentry.init({
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
const flagsIntegration =
|
||||
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||
"FeatureFlags",
|
||||
);
|
||||
if (flagsIntegration) {
|
||||
flagsIntegration.addFeatureFlag(
|
||||
"COMPLEX_BINDINGS",
|
||||
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,31 +140,6 @@
|
||||
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,7 +22,6 @@ 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";
|
||||
|
||||
@@ -143,7 +142,6 @@ const ActiveRoomDialog = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<QRCode value={activeRoomLink} />
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { renderSVG } from "uqr";
|
||||
|
||||
export const generateQRCodeSVG = (text: string): string => {
|
||||
return renderSVG(text);
|
||||
};
|
||||
@@ -17,15 +17,30 @@ describe("Test MobileMenu", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
h.app.refreshEditorInterface();
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should set editor interface correctly", () => {
|
||||
expect(h.app.editorInterface.formFactor).toBe("phone");
|
||||
it("should set device correctly", () => {
|
||||
expect(h.app.device).toMatchInlineSnapshot(`
|
||||
{
|
||||
"editor": {
|
||||
"canFitSidebar": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||
|
||||
@@ -50,11 +50,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
||||
<div
|
||||
class="welcome-screen-center__heading welcome-screen-decor excalifont"
|
||||
>
|
||||
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.
|
||||
All your data is saved locally in your browser.
|
||||
</div>
|
||||
<div
|
||||
class="welcome-screen-menu"
|
||||
@@ -202,7 +198,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="noopener"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -3,15 +3,11 @@ import {
|
||||
createRedoAction,
|
||||
createUndoAction,
|
||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||
import { syncInvalidIndices } from "@excalidraw/element";
|
||||
import { syncInvalidIndices } from "@excalidraw/element/fractionalIndex";
|
||||
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;
|
||||
@@ -69,79 +65,6 @@ 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 = {
|
||||
@@ -199,13 +122,12 @@ describe("collaboration", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
const undoAction = createUndoAction(h.history);
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
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 }),
|
||||
@@ -232,7 +154,7 @@ describe("collaboration", () => {
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
const redoAction = createRedoAction(h.history);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||
@@ -248,5 +170,79 @@ 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,14 +102,6 @@ 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";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -154,11 +146,6 @@ 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: [
|
||||
{
|
||||
@@ -198,7 +185,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
|
||||
urlPattern: new RegExp(".chunk-.+.js"),
|
||||
handler: "CacheFirst",
|
||||
options: {
|
||||
cacheName: "chunk",
|
||||
@@ -209,7 +196,6 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
|
||||
+13
-16
@@ -11,9 +11,11 @@
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@excalidraw/eslint-config": "1.0.3",
|
||||
"@excalidraw/prettier-config": "1.0.2",
|
||||
"@playwright/experimental-ct-react": "1.52.0",
|
||||
"@types/chai": "4.3.0",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/lodash.throttle": "4.1.7",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/socket.io-client": "3.0.0",
|
||||
@@ -33,8 +35,7 @@
|
||||
"pepjs": "0.5.3",
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
@@ -44,7 +45,7 @@
|
||||
"vitest-canvas-mock": "0.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"homepage": ".",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
@@ -52,17 +53,13 @@
|
||||
"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: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:package": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"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:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"start:example": "yarn build:package && 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 .",
|
||||
@@ -80,13 +77,13 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"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"
|
||||
"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",
|
||||
"clean-install": "yarn rm:node_modules && yarn install",
|
||||
"test-ct": "playwright test -c playwright-ct.config.ts"
|
||||
},
|
||||
"resolutions": {
|
||||
"strip-ansi": "6.0.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.18.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,10 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/common/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
"types": "./../common/dist/types/common/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -53,13 +50,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"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"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
// 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",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,74 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
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,18 +5,17 @@ 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 (nodeScore < this.scoreFunction(parent)) {
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
@@ -25,39 +24,35 @@ export class BinaryHeap<T> {
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child1N = ((idx + 1) << 1) - 1;
|
||||
const child2N = child1N + 1;
|
||||
let smallestIdx = idx;
|
||||
let smallestScore = score;
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
|
||||
// Check left child
|
||||
if (child1N < length) {
|
||||
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||
if (child1Score < smallestScore) {
|
||||
smallestIdx = child1N;
|
||||
smallestScore = child1Score;
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
}
|
||||
}
|
||||
|
||||
// Check right child
|
||||
if (child2N < length) {
|
||||
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||
if (child2Score < smallestScore) {
|
||||
smallestIdx = child2N;
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (smallestIdx === idx) {
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
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) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,286 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
-241
@@ -1,117 +1,8 @@
|
||||
import tinycolor from "tinycolor2";
|
||||
import oc from "open-color";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
import { degreesToRadians } from "@excalidraw/math";
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
@@ -126,7 +17,15 @@ 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 };
|
||||
@@ -139,30 +38,38 @@ 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-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]
|
||||
// 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
|
||||
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
|
||||
} as const;
|
||||
|
||||
export type ColorPalette = typeof COLOR_PALETTE;
|
||||
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
|
||||
} as ColorPalette;
|
||||
|
||||
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"cyan",
|
||||
@@ -177,6 +84,7 @@ const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"red",
|
||||
]);
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -211,6 +119,7 @@ export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
"#fdf8f6",
|
||||
] as ColorTuple;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// palette defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -236,120 +145,29 @@ export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// color palette helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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],
|
||||
];
|
||||
// 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)}`;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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,6 +6,24 @@ 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;
|
||||
|
||||
@@ -17,7 +35,6 @@ 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;
|
||||
@@ -99,23 +116,11 @@ 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";
|
||||
|
||||
@@ -137,52 +142,21 @@ 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,
|
||||
genericFallbackFont,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
];
|
||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
default:
|
||||
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,8 +165,6 @@ 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"],
|
||||
@@ -246,21 +218,13 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const STRING_MIME_TYPES = {
|
||||
export const 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",
|
||||
@@ -289,7 +253,7 @@ export const EXPORT_DATA_TYPES = {
|
||||
excalidrawClipboardWithAPI: "excalidraw-api/clipboard",
|
||||
} as const;
|
||||
|
||||
export const getExportSource = () =>
|
||||
export const EXPORT_SOURCE =
|
||||
window.EXCALIDRAW_EXPORT_SOURCE || window.location.origin;
|
||||
|
||||
// time in milliseconds
|
||||
@@ -309,6 +273,9 @@ 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;
|
||||
@@ -332,6 +299,16 @@ 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];
|
||||
@@ -497,19 +474,3 @@ 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;
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -22,10 +22,8 @@ export interface FontMetadata {
|
||||
};
|
||||
/** flag to indicate a deprecated font */
|
||||
deprecated?: true;
|
||||
/**
|
||||
* whether this is a font that users can use (= shown in font picker)
|
||||
*/
|
||||
private?: true;
|
||||
/** flag to indicate a server-side only font */
|
||||
serverSide?: true;
|
||||
/** flag to indiccate a local-only font */
|
||||
local?: true;
|
||||
/** flag to indicate a fallback font */
|
||||
@@ -46,7 +44,7 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 1011,
|
||||
descender: -353,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.35,
|
||||
},
|
||||
},
|
||||
[FONT_FAMILY["Lilita One"]]: {
|
||||
@@ -100,23 +98,14 @@ export const FONT_METADATA: Record<number, FontMetadata> = {
|
||||
descender: -434,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
private: true,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
metrics: {
|
||||
unitsPerEm: 2048,
|
||||
ascender: 1021,
|
||||
descender: -287,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
private: true,
|
||||
serverSide: true,
|
||||
},
|
||||
[FONT_FAMILY_FALLBACKS.Xiaolai]: {
|
||||
metrics: {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 880,
|
||||
descender: -144,
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
fallback: true,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./binary-heap";
|
||||
export * from "./bounds";
|
||||
export * from "./colors";
|
||||
export * from "./constants";
|
||||
export * from "./font-metadata";
|
||||
@@ -10,8 +9,3 @@ 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 "./editorInterface";
|
||||
import { isDarwin } from "./constants";
|
||||
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
||||
|
||||
@@ -68,12 +68,3 @@ 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;
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+94
-190
@@ -1,8 +1,11 @@
|
||||
import { average } from "@excalidraw/math";
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { GlobalCoord } from "@excalidraw/math";
|
||||
|
||||
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
ActiveTool,
|
||||
@@ -12,11 +15,13 @@ 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";
|
||||
|
||||
@@ -87,9 +92,7 @@ export const isWritableElement = (
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search")) ||
|
||||
(target instanceof HTMLElement && target.closest(".cm-editor") !== null);
|
||||
target.type === "password"));
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -98,6 +101,7 @@ 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("")}`;
|
||||
@@ -117,11 +121,6 @@ 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,
|
||||
@@ -151,27 +150,38 @@ export const debounce = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
// throttle callback to execute once per animation frame using the latest args
|
||||
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
// throttle callback to execute once per animation frame
|
||||
export const throttleRAF = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
opts?: { trailing?: boolean },
|
||||
) => {
|
||||
let timerId: number | null = null;
|
||||
let lastArgs: T | null = null;
|
||||
let lastArgsTrailing: T | null = null;
|
||||
|
||||
const scheduleFunc = () => {
|
||||
const scheduleFunc = (args: T) => {
|
||||
timerId = window.requestAnimationFrame(() => {
|
||||
timerId = null;
|
||||
const args = lastArgs;
|
||||
fn(...args);
|
||||
lastArgs = null;
|
||||
|
||||
if (args) {
|
||||
fn(...args);
|
||||
if (lastArgsTrailing) {
|
||||
lastArgs = lastArgsTrailing;
|
||||
lastArgsTrailing = null;
|
||||
scheduleFunc(lastArgs);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ret = (...args: T) => {
|
||||
if (isTestEnv()) {
|
||||
fn(...args);
|
||||
return;
|
||||
}
|
||||
lastArgs = args;
|
||||
if (timerId === null) {
|
||||
scheduleFunc();
|
||||
scheduleFunc(lastArgs);
|
||||
} else if (opts?.trailing) {
|
||||
lastArgsTrailing = args;
|
||||
}
|
||||
};
|
||||
ret.flush = () => {
|
||||
@@ -180,12 +190,12 @@ export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
|
||||
timerId = null;
|
||||
}
|
||||
if (lastArgs) {
|
||||
fn(...lastArgs);
|
||||
lastArgs = null;
|
||||
fn(...(lastArgsTrailing || lastArgs));
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
}
|
||||
};
|
||||
ret.cancel = () => {
|
||||
lastArgs = null;
|
||||
lastArgs = lastArgsTrailing = null;
|
||||
if (timerId !== null) {
|
||||
cancelAnimationFrame(timerId);
|
||||
timerId = null;
|
||||
@@ -369,10 +379,6 @@ 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: ((
|
||||
@@ -414,6 +420,19 @@ 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 },
|
||||
{
|
||||
@@ -433,7 +452,7 @@ export const viewportCoordsToSceneCoords = (
|
||||
const x = (clientX - offsetLeft) / zoom.value - scrollX;
|
||||
const y = (clientY - offsetTop) / zoom.value - scrollY;
|
||||
|
||||
return { x, y } as GlobalCoord;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const sceneCoordsToViewportCoords = (
|
||||
@@ -525,20 +544,19 @@ export const findLastIndex = <T>(
|
||||
return -1;
|
||||
};
|
||||
|
||||
/** 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 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
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
@@ -662,7 +680,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() as Map<string, T>);
|
||||
}, new Map());
|
||||
};
|
||||
|
||||
export const arrayToMapWithIndex = <T extends { id: string }>(
|
||||
@@ -680,8 +698,8 @@ export const arrayToObject = <T>(
|
||||
array: readonly T[],
|
||||
groupBy?: (value: T) => string | number,
|
||||
) =>
|
||||
array.reduce((acc, value, idx) => {
|
||||
acc[groupBy ? groupBy(value) : idx] = value;
|
||||
array.reduce((acc, value) => {
|
||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||
return acc;
|
||||
}, {} as { [key: string]: T });
|
||||
|
||||
@@ -717,25 +735,6 @@ 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;
|
||||
@@ -1138,69 +1137,39 @@ export const normalizeEOL = (str: string) => {
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
export type HasBrand<T> = {
|
||||
type HasBrand<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
|
||||
[K in keyof T]: K extends `~brand${infer _}` ? 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 _}` | "_brand"
|
||||
? never
|
||||
: K]: T[K];
|
||||
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
|
||||
}
|
||||
: T;
|
||||
: never;
|
||||
|
||||
// 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>>
|
||||
// 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>
|
||||
: T extends Set<infer E>
|
||||
? Set<UnbrandForValue<E>>
|
||||
: T extends readonly any[]
|
||||
? T extends any[]
|
||||
? unknown[] // mutable array - require mutable input
|
||||
: readonly unknown[] // readonly array - accept readonly input
|
||||
? Set<E>
|
||||
: T extends Array<infer E>
|
||||
? Array<E>
|
||||
: 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 unbranded type. Optionally you can explicitly supply current value
|
||||
* the base ubranded 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 function toBrandedType<BrandedType>(
|
||||
value: UnbrandForValue<BrandedType>,
|
||||
): BrandedType;
|
||||
export function toBrandedType<BrandedType, CurrentType>(
|
||||
value: CurrentType,
|
||||
): CombineBrands<BrandedType, CurrentType>;
|
||||
export function toBrandedType(value: unknown) {
|
||||
return value;
|
||||
}
|
||||
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
|
||||
value: Unbrand<BrandedType>,
|
||||
) => {
|
||||
return value as CurrentType & BrandedType;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1236,96 +1205,31 @@ 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 unknown[]
|
||||
| Readonly<Map<string, unknown>>
|
||||
| Readonly<Record<string, unknown>>
|
||||
| ReadonlySet<unknown>,
|
||||
value: readonly number[] | Readonly<Map<any, any>> | Record<any, any>,
|
||||
): number => {
|
||||
return isReadonlyArray(value)
|
||||
? value.length
|
||||
: value instanceof Map || value instanceof Set
|
||||
: value instanceof Map
|
||||
? 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);
|
||||
};
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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.18.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,16 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"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"
|
||||
"types": "./../element/dist/types/element/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -59,11 +50,7 @@
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"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"
|
||||
"gen:types": "rm -rf types && tsc",
|
||||
"build:esm": "rm -rf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import {
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
|
||||
function adjustRoughness(element: ExcalidrawElement): number {
|
||||
const roughness = element.roughness;
|
||||
|
||||
const maxSize = Math.max(element.width, element.height);
|
||||
const minSize = Math.min(element.width, element.height);
|
||||
|
||||
// don't reduce roughness if
|
||||
if (
|
||||
// both sides relatively big
|
||||
(minSize >= 20 && maxSize >= 50) ||
|
||||
// is round & both sides above 15px
|
||||
(minSize >= 15 &&
|
||||
!!element.roundness &&
|
||||
canChangeRoundness(element.type)) ||
|
||||
// relatively long linear element
|
||||
(isLinearElement(element) && maxSize >= 50)
|
||||
) {
|
||||
return roughness;
|
||||
}
|
||||
|
||||
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
|
||||
}
|
||||
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
strokeLineDash:
|
||||
element.strokeStyle === "dashed"
|
||||
? getDashArrayDashed(element.strokeWidth)
|
||||
: element.strokeStyle === "dotted"
|
||||
? getDashArrayDotted(element.strokeWidth)
|
||||
: undefined,
|
||||
// for non-solid strokes, disable multiStroke because it tends to make
|
||||
// dashes/dots overlay each other
|
||||
disableMultiStroke: element.strokeStyle !== "solid",
|
||||
// for non-solid strokes, increase the width a bit to make it visually
|
||||
// similar to solid strokes, because we're also disabling multiStroke
|
||||
strokeWidth:
|
||||
element.strokeStyle !== "solid"
|
||||
? element.strokeWidth + 0.5
|
||||
: element.strokeWidth,
|
||||
// when increasing strokeWidth, we must explicitly set fillWeight and
|
||||
// hachureGap because if not specified, roughjs uses strokeWidth to
|
||||
// calculate them (and we don't want the fills to be modified)
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "diamond":
|
||||
case "ellipse": {
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
case "line":
|
||||
case "freedraw": {
|
||||
if (isPathALoop(element.points)) {
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
case "arrow":
|
||||
return options;
|
||||
default: {
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const modifyIframeLikeForRoughOptions = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
isExporting: boolean,
|
||||
embedsValidationStatus: EmbedsValidationStatus | null,
|
||||
) => {
|
||||
if (
|
||||
isIframeLikeElement(element) &&
|
||||
(isExporting ||
|
||||
(isEmbeddableElement(element) &&
|
||||
embedsValidationStatus?.get(element.id) !== true)) &&
|
||||
isTransparent(element.backgroundColor) &&
|
||||
isTransparent(element.strokeColor)
|
||||
) {
|
||||
return {
|
||||
...element,
|
||||
roughness: 0,
|
||||
backgroundColor: "#d3d3d3",
|
||||
fillStyle: "solid",
|
||||
} as const;
|
||||
} else if (isIframeElement(element)) {
|
||||
return {
|
||||
...element,
|
||||
strokeColor: isTransparent(element.strokeColor)
|
||||
? "#000000"
|
||||
: element.strokeColor,
|
||||
backgroundColor: isTransparent(element.backgroundColor)
|
||||
? "#f4f4f6"
|
||||
: element.backgroundColor,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
canvasBackgroundColor: string,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const generateCrowfootOne = (
|
||||
arrowheadPoints: number[] | null,
|
||||
options: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, options)];
|
||||
};
|
||||
|
||||
switch (arrowhead) {
|
||||
case "dot":
|
||||
case "circle":
|
||||
case "circle_outline": {
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, diameter, {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: element.strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
}
|
||||
case "triangle":
|
||||
case "triangle_outline": {
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
[
|
||||
[x, y],
|
||||
[x2, y2],
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
case "diamond":
|
||||
case "diamond_outline": {
|
||||
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
[
|
||||
[x, y],
|
||||
[x2, y2],
|
||||
[x3, y3],
|
||||
[x4, y4],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
case "crowfoot_one":
|
||||
return generateCrowfootOne(arrowheadPoints, options);
|
||||
case "bar":
|
||||
case "arrow":
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one_or_many":
|
||||
default: {
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
options.roughness = Math.min(1, options.roughness || 0);
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
...(arrowhead === "crowfoot_one_or_many"
|
||||
? generateCrowfootOne(
|
||||
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||
options,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the roughjs shape for given element.
|
||||
*
|
||||
* Low-level. Use `ShapeCache.generateElementShape` instead.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
isExporting,
|
||||
canvasBackgroundColor,
|
||||
embedsValidationStatus,
|
||||
}: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: string;
|
||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||
},
|
||||
): Drawable | Drawable[] | null => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
// this is for rendering the stroke/bg of the embeddable, especially
|
||||
// when the src url is not set
|
||||
|
||||
if (element.roundness) {
|
||||
const w = element.width;
|
||||
const h = element.height;
|
||||
const r = getCornerRadius(Math.min(w, h), element);
|
||||
shape = generator.path(
|
||||
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
|
||||
h - r
|
||||
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
|
||||
h - r
|
||||
} L 0 ${r} Q 0 0, ${r} 0`,
|
||||
generateRoughOptions(
|
||||
modifyIframeLikeForRoughOptions(
|
||||
element,
|
||||
isExporting,
|
||||
embedsValidationStatus,
|
||||
),
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
shape = generator.rectangle(
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(
|
||||
modifyIframeLikeForRoughOptions(
|
||||
element,
|
||||
isExporting,
|
||||
embedsValidationStatus,
|
||||
),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "diamond": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
if (element.roundness) {
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
|
||||
const horizontalRadius = getCornerRadius(
|
||||
Math.abs(rightY - topY),
|
||||
element,
|
||||
);
|
||||
|
||||
shape = generator.path(
|
||||
`M ${topX + verticalRadius} ${topY + horizontalRadius} L ${
|
||||
rightX - verticalRadius
|
||||
} ${rightY - horizontalRadius}
|
||||
C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
|
||||
rightX - verticalRadius
|
||||
} ${rightY + horizontalRadius}
|
||||
L ${bottomX + verticalRadius} ${bottomY - horizontalRadius}
|
||||
C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
|
||||
bottomX - verticalRadius
|
||||
} ${bottomY - horizontalRadius}
|
||||
L ${leftX + verticalRadius} ${leftY + horizontalRadius}
|
||||
C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${
|
||||
leftY - horizontalRadius
|
||||
}
|
||||
L ${topX - verticalRadius} ${topY + horizontalRadius}
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "ellipse": {
|
||||
const shape: ElementShapes[typeof element.type] = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
return shape;
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
const options = generateRoughOptions(element);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
const points = element.points.length
|
||||
? element.points
|
||||
: [pointFrom<LocalPoint>(0, 0)];
|
||||
|
||||
if (isElbowArrow(element)) {
|
||||
// NOTE (mtolmacs): Temporary fix for extremely big arrow shapes
|
||||
if (
|
||||
!points.every(
|
||||
(point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6,
|
||||
)
|
||||
) {
|
||||
console.error(
|
||||
`Elbow arrow with extreme point positions detected. Arrow not rendered.`,
|
||||
element.id,
|
||||
JSON.stringify(points),
|
||||
);
|
||||
shape = [];
|
||||
} else {
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true),
|
||||
),
|
||||
];
|
||||
}
|
||||
} else if (!element.roundness) {
|
||||
// curve is always the first element
|
||||
// this simplifies finding the curve for an element
|
||||
if (options.fill) {
|
||||
shape = [
|
||||
generator.polygon(points as unknown as RoughPoint[], options),
|
||||
];
|
||||
} else {
|
||||
shape = [
|
||||
generator.linearPath(points as unknown as RoughPoint[], options),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
shape = [generator.curve(points as unknown as RoughPoint[], options)];
|
||||
}
|
||||
|
||||
// add lines only in arrow
|
||||
if (element.type === "arrow") {
|
||||
const { startArrowhead = null, endArrowhead = "arrow" } = element;
|
||||
|
||||
if (startArrowhead !== null) {
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"start",
|
||||
startArrowhead,
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
|
||||
if (endArrowhead !== null) {
|
||||
if (endArrowhead === undefined) {
|
||||
// Hey, we have an old arrow here!
|
||||
}
|
||||
|
||||
const shapes = getArrowheadShapes(
|
||||
element,
|
||||
shape,
|
||||
"end",
|
||||
endArrowhead,
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "freedraw": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
generateFreeDrawShape(element);
|
||||
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "text":
|
||||
case "image": {
|
||||
const shape: ElementShapes[typeof element.type] = null;
|
||||
// we return (and cache) `null` to make sure we don't regenerate
|
||||
// `element.canvas` on rerenders
|
||||
return shape;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
element,
|
||||
`generateElementShape(): Unimplemented type ${(element as any)?.type}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateElbowArrowShape = (
|
||||
points: readonly LocalPoint[],
|
||||
radius: number,
|
||||
) => {
|
||||
const subpoints = [] as [number, number][];
|
||||
for (let i = 1; i < points.length - 1; i += 1) {
|
||||
const prev = points[i - 1];
|
||||
const next = points[i + 1];
|
||||
const point = points[i];
|
||||
const prevIsHorizontal = headingForPointIsHorizontal(point, prev);
|
||||
const nextIsHorizontal = headingForPointIsHorizontal(next, point);
|
||||
const corner = Math.min(
|
||||
radius,
|
||||
pointDistance(points[i], next) / 2,
|
||||
pointDistance(points[i], prev) / 2,
|
||||
);
|
||||
|
||||
if (prevIsHorizontal) {
|
||||
if (prev[0] < point[0]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
}
|
||||
} else if (prev[1] < point[1]) {
|
||||
// UP
|
||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||
} else {
|
||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||
}
|
||||
|
||||
subpoints.push(points[i] as [number, number]);
|
||||
|
||||
if (nextIsHorizontal) {
|
||||
if (next[0] < point[0]) {
|
||||
// LEFT
|
||||
subpoints.push([points[i][0] - corner, points[i][1]]);
|
||||
} else {
|
||||
// RIGHT
|
||||
subpoints.push([points[i][0] + corner, points[i][1]]);
|
||||
}
|
||||
} else if (next[1] < point[1]) {
|
||||
// UP
|
||||
subpoints.push([points[i][0], points[i][1] - corner]);
|
||||
} else {
|
||||
// DOWN
|
||||
subpoints.push([points[i][0], points[i][1] + corner]);
|
||||
}
|
||||
}
|
||||
|
||||
const d = [`M ${points[0][0]} ${points[0][1]}`];
|
||||
for (let i = 0; i < subpoints.length; i += 3) {
|
||||
d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`);
|
||||
d.push(
|
||||
`Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${
|
||||
subpoints[i + 2][0]
|
||||
} ${subpoints[i + 2][1]}`,
|
||||
);
|
||||
}
|
||||
d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`);
|
||||
|
||||
return d.join(" ");
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import type { BoundingBox } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@@ -16,14 +15,13 @@ export interface Alignment {
|
||||
|
||||
export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
elementsMap,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
@@ -35,13 +33,12 @@ export const alignElements = (
|
||||
);
|
||||
return group.map((element) => {
|
||||
// update element
|
||||
const updatedEle = scene.mutateElement(element, {
|
||||
const updatedEle = mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
});
|
||||
|
||||
// update bound elements
|
||||
updateBoundElements(element, scene, {
|
||||
updateBoundElements(element, scene.getNonDeletedElementsMap(), {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedEle;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { Arrowhead, AnyArrowhead } from "./types";
|
||||
|
||||
export const normalizeArrowhead = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
switch (arrowhead) {
|
||||
case undefined:
|
||||
case null:
|
||||
return null;
|
||||
case "dot":
|
||||
return "circle";
|
||||
case "crowfoot_one":
|
||||
return "cardinality_one";
|
||||
case "crowfoot_many":
|
||||
return "cardinality_many";
|
||||
case "crowfoot_one_or_many":
|
||||
return "cardinality_one_or_many";
|
||||
default:
|
||||
return arrowhead;
|
||||
}
|
||||
};
|
||||
|
||||
export const getArrowheadForPicker = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
const normalizedArrowhead = normalizeArrowhead(arrowhead);
|
||||
if (normalizedArrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedArrowhead;
|
||||
};
|
||||
@@ -1,558 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
};
|
||||
+1303
-2043
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