Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle a2ec2889ba fix: backport mermaid xss fix to 0.18.1 2026-04-20 22:30:38 +02:00
822 changed files with 52775 additions and 109458 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-bullseye
FROM node:18-bullseye
# Vite wants to open the browser using `open`, so we
# need to install those utils.
+2 -7
View File
@@ -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
@@ -50,6 +48,3 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
HQIDAQAB'
# set to true in .env.development.local to disable the prevent unload dialog
VITE_APP_DISABLE_PREVENT_UNLOAD=
-2
View File
@@ -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/
-7
View File
@@ -1,7 +0,0 @@
# VITE_DEBUG_DOM
# When "true", testing-library failures (waitFor / getBy*) include the full
# serialized DOM in the error message. It's off by default because it's noisy.
#
# Flip it to "true" (or use `VITE_DEBUG_DOM=true yarn test`) when you need to
# inspect the DOM of a failing test.
VITE_DEBUG_DOM=false
+1 -43
View File
@@ -1,21 +1,6 @@
{
"extends": ["@excalidraw/eslint-config", "react-app"],
"rules": {
"import/order": [
"warn",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"],
"pathGroups": [
{
"pattern": "@excalidraw/**",
"group": "external",
"position": "after"
}
],
"newlines-between": "always-and-inside-groups",
"warnOnUnassignedImports": true
}
],
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": [
@@ -32,33 +17,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"]
}
]
}
}
]
}
}
-45
View File
@@ -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}
+5 -5
View File
@@ -9,13 +9,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
with:
fetch-depth: 2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- 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
+55
View File
@@ -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 }}"
+1 -1
View File
@@ -9,5 +9,5 @@ jobs:
build-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- run: docker build -t excalidraw .
+1 -1
View File
@@ -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 }}
+4 -4
View File
@@ -7,12 +7,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- 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: |
+5 -5
View File
@@ -10,14 +10,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v4
with:
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- 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"
+3 -8
View File
@@ -11,20 +11,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
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
+1 -87
View File
@@ -6,97 +6,11 @@ on:
- opened
- edited
- synchronize
- labeled
- unlabeled
jobs:
semantic:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+5 -5
View File
@@ -9,11 +9,11 @@ jobs:
sentry:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@v2
- 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:
+5 -5
View File
@@ -10,17 +10,17 @@ jobs:
CI_JOB_NUMBER: 1
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: actions/checkout@v3
- 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
+4 -4
View File
@@ -10,17 +10,17 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@v2
- name: "Install Node"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
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 }}
+4 -4
View File
@@ -8,11 +8,11 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- uses: actions/checkout@v4
- 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
+1 -2
View File
@@ -25,5 +25,4 @@ packages/excalidraw/types
coverage
dev-dist
html
meta*.json
.claude
meta*.json
+1
View File
@@ -0,0 +1 @@
18
-34
View File
@@ -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
View File
@@ -1,4 +1,4 @@
FROM --platform=${BUILDPLATFORM} node:24@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63 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 --frozen-lockfile --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 nginx:stable-alpine-slim@sha256:2c605dbeab79a6b2a63340474fe58119d0ef95bdc4b1f41df0aa689659b3d13b
FROM nginx:1.27-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
+11 -8
View File
@@ -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:
- 🏗️&nbsp;Customizable.
- 📷&nbsp;Image support.
- 😀&nbsp;Shape libraries support.
- 🌐&nbsp;Localization (i18n) support.
- 👅&nbsp;Localization (i18n) support.
- 🖼️&nbsp;Export to PNG, SVG & clipboard.
- 💾&nbsp;Open format - export drawings as an `.excalidraw` json file.
- ⚒️&nbsp;Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
@@ -2,14 +2,14 @@
Earlier we were using `renderFooter` prop to render custom footer which was removed in [#5970](https://github.com/excalidraw/excalidraw/pull/5970). Now you can pass a `Footer` component instead to render the custom UI for footer.
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should be a valid React Node.
You will need to import the `Footer` component from the package and wrap your component with the Footer component. The `Footer` should a valid React Node.
**Usage**
```jsx live
function App() {
return (
<div style={{ height: "500px" }}>
<div style={{ height: "500px"}}>
<Excalidraw>
<Footer>
<button
@@ -25,21 +25,21 @@ function App() {
}
```
This will only work for `Desktop` devices.
This will only 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
@@ -65,4 +65,4 @@ const App = () => (
// Need to render when code is span across multiple components
// in Live Code blocks editor
render(<App />);
```
```
@@ -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
@@ -3,7 +3,7 @@
All `props` are _optional_.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| [`initialData`](/docs/@excalidraw/excalidraw/api/props/initialdata) | `object` &#124; `null` &#124; <code>Promise<object &#124; null></code> | `null` | The initial data with which app loads. |
| [`excalidrawAPI`](/docs/@excalidraw/excalidraw/api/props/excalidraw-api) | `function` | \_ | Callback triggered with the excalidraw api once rendered |
| [`isCollaborating`](#iscollaborating) | `boolean` | \_ | This indicates if the app is in `collaboration` mode |
@@ -13,7 +13,7 @@ All `props` are _optional_.
| [`onScrollChange`](#onscrollchange) | `function` | \_ | This prop if passed gets triggered when scrolling the canvas. |
| [`onPaste`](#onpaste) | `function` | \_ | Callback to be triggered if passed when something is pasted into the scene |
| [`onLibraryChange`](#onlibrarychange) | `function` | \_ | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`generateLinkForSelection`](#generatelinkforselection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`generateLinkForSelection`](#generateLinkForSelection) | `function` | \_ | Allows you to override `url` generation when linking to Excalidraw elements. |
| [`onLinkOpen`](#onlinkopen) | `function` | \_ | The callback if supplied is triggered when any link is opened. |
| [`langCode`](#langcode) | `string` | `en` | Language code string to be used in Excalidraw |
| [`renderTopRightUI`](/docs/@excalidraw/excalidraw/api/props/render-props#rendertoprightui) | `function` | \_ | Render function that renders custom UI in top right corner |
@@ -29,9 +29,8 @@ All `props` are _optional_.
| [`handleKeyboardGlobally`](#handlekeyboardglobally) | `boolean` | `false` | Indicates whether to bind the keyboard events to document. |
| [`autoFocus`](#autofocus) | `boolean` | `false` | Indicates whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateidforfile) | `function` | \_ | Allows you to override `id` generation for files added on canvas |
| [`validateEmbeddable`](#validateembeddable) | `string[]` \| `boolean` \| `RegExp` \| `RegExp[]` \| <code>((link: string) => boolean &#124; undefined)</code> | \_ | use for custom src url validation |
| [`validateEmbeddable`](#validateEmbeddable) | string[] | `boolean | RegExp | RegExp[] | ((link: string) => boolean | undefined)` | \_ | use for custom src url validation |
| [`renderEmbeddable`](/docs/@excalidraw/excalidraw/api/props/render-props#renderEmbeddable) | `function` | \_ | Render function that can override the built-in `<iframe>` |
| [`renderScrollbars`] | `boolean`| | `false` | Indicates whether scrollbars will be shown
### Storing custom data on Excalidraw elements
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords(&#123; clientX: number, clientY: number },<br/>&nbsp
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): &#123;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,
{
+2 -2
View File
@@ -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",
+1 -2
View File
@@ -1,6 +1,5 @@
import clsx from "clsx";
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
const FeatureList = [
+1 -2
View File
@@ -1,6 +1,5 @@
import clsx from "clsx";
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
type FeatureItem = {
+4 -5
View File
@@ -1,11 +1,10 @@
import React from "react";
import clsx from "clsx";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import HomepageFeatures from "@site/src/components/Homepage";
import Layout from "@theme/Layout";
import clsx from "clsx";
import React from "react";
import styles from "./index.module.css";
import HomepageFeatures from "@site/src/components/Homepage";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
+1 -1
View File
@@ -1,6 +1,6 @@
// Import the original mapper
import Highlight from "@site/src/components/Highlight";
import MDXComponents from "@theme-original/MDXComponents";
import Highlight from "@site/src/components/Highlight";
export default {
// Re-use the default mapping
@@ -33,7 +33,6 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;
+2
View File
@@ -1,3 +1,5 @@
version: "3.8"
services:
excalidraw:
build:
+1 -2
View File
@@ -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",
-1
View File
@@ -1,6 +1,5 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -1,11 +1,10 @@
"use client";
import * as excalidrawLib from "@excalidraw/excalidraw";
import { Excalidraw } from "@excalidraw/excalidraw";
import App from "../../with-script-in-browser/components/ExampleApp";
import "@excalidraw/excalidraw/index.css";
import App from "../../with-script-in-browser/components/ExampleApp";
const ExcalidrawWrapper: React.FC = () => {
return (
<>
@@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@@ -1,5 +1,4 @@
import React from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
@@ -52,7 +52,7 @@
transform: none;
}
.excalidraw .selected-shape-actions {
.excalidraw .panelColumn {
text-align: left;
}
@@ -1,4 +1,3 @@
import { nanoid } from "nanoid";
import React, {
useEffect,
useState,
@@ -7,24 +6,13 @@ import React, {
Children,
cloneElement,
} from "react";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import initialData from "../initialData";
import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import {
resolvablePromise,
distance2d,
@@ -35,12 +23,25 @@ import {
import CustomFooter from "./CustomFooter";
import MobileFooter from "./MobileFooter";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import initialData from "../initialData";
import type {
AppState,
BinaryFileData,
ExcalidrawImperativeAPI,
ExcalidrawInitialDataState,
Gesture,
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "@excalidraw/excalidraw/types";
import type {
NonDeletedExcalidrawElement,
Theme,
} from "@excalidraw/excalidraw/element/types";
import type { ImportedLibraryData } from "@excalidraw/excalidraw/data/types";
import "./ExampleApp.scss";
import type { ResolvablePromise } from "../utils";
type Comment = {
x: number;
y: number;
@@ -104,7 +105,6 @@ export default function ExampleApp({
const [viewModeEnabled, setViewModeEnabled] = useState(false);
const [zenModeEnabled, setZenModeEnabled] = useState(false);
const [gridModeEnabled, setGridModeEnabled] = useState(false);
const [renderScrollbars, setRenderScrollbars] = useState(false);
const [blobUrl, setBlobUrl] = useState<string>("");
const [canvasUrl, setCanvasUrl] = useState<string>("");
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
@@ -193,7 +193,6 @@ export default function ExampleApp({
}) => setPointerData(payload),
viewModeEnabled,
zenModeEnabled,
renderScrollbars,
gridModeEnabled,
theme,
name: "Custom name of drawing",
@@ -712,14 +711,6 @@ export default function ExampleApp({
/>
Grid mode
</label>
<label>
<input
type="checkbox"
checked={renderScrollbars}
onChange={() => setRenderScrollbars(!renderScrollbars)}
/>
Render scrollbars
</label>
<label>
<input
type="checkbox"
@@ -1,9 +1,7 @@
import React from "react";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";
const MobileFooter = ({
excalidrawAPI,
@@ -12,10 +10,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,5 +1,4 @@
import React, { useState } from "react";
import "./ExampleSidebar.scss";
export default function Sidebar({ children }: { children: React.ReactNode }) {
+2 -3
View File
@@ -1,11 +1,10 @@
import App from "./components/ExampleApp";
import React, { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@excalidraw/excalidraw/index.css";
import type * as TExcalidraw from "@excalidraw/excalidraw";
import App from "./components/ExampleApp";
import "@excalidraw/excalidraw/index.css";
declare global {
interface Window {
@@ -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[] = [
+7 -8
View File
@@ -3,20 +3,19 @@
"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:preview": "yarn build && vite preview --port 5002",
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
}
}
+38 -2
View File
@@ -1,9 +1,11 @@
import { MIME_TYPES } from "@excalidraw/excalidraw";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/excalidraw";
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 -1
View File
@@ -1,5 +1,5 @@
{
"outputDirectory": "dist",
"installCommand": "yarn install",
"buildCommand": "yarn build:packages && yarn build"
"buildCommand": "yarn build:package && yarn build"
}
+143 -267
View File
@@ -1,28 +1,40 @@
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "@excalidraw/excalidraw/constants";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
import { t } from "@excalidraw/excalidraw/i18n";
import {
Excalidraw,
LiveCollaborationTrigger,
TTDDialogTrigger,
CaptureUpdateAction,
reconcileElements,
useEditorInterface,
ExcalidrawAPIProvider,
useExcalidrawAPI,
} from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "@excalidraw/excalidraw/types";
import type { ResolvablePromise } from "@excalidraw/excalidraw/utils";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
import Trans from "@excalidraw/excalidraw/components/Trans";
import {
APP_NAME,
EVENT,
VERSION_TIMEOUT,
debounce,
getVersion,
getFrame,
@@ -30,13 +42,75 @@ import {
preventUnload,
resolvablePromise,
isRunningInIframe,
isDevEnv,
} from "@excalidraw/common";
import polyfill from "@excalidraw/excalidraw/polyfill";
import { useCallback, useEffect, useRef, useState } from "react";
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
import { t } from "@excalidraw/excalidraw/i18n";
} from "@excalidraw/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import "./index.scss";
import type { ResolutionType } from "@excalidraw/excalidraw/utility-types";
import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "@excalidraw/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
import {
GithubIcon,
XBrandIcon,
@@ -47,90 +121,6 @@ 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 clsx from "clsx";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "@excalidraw/excalidraw/data/library";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
import type {
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
ExcalidrawProps,
} from "@excalidraw/excalidraw/types";
import type { ResolutionType } from "@excalidraw/common/utility-types";
import type { ResolvablePromise } from "@excalidraw/common/utils";
import CustomStats from "./CustomStats";
import {
Provider,
useAtom,
useAtomValue,
useAtomWithInitialValue,
appJotaiStore,
} from "./app-jotai";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import Collab, {
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import { AppFooter } from "./components/AppFooter";
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
} from "./components/ExportToExcalidrawPlus";
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import {
exportToBackend,
getCollaborationLinkData,
importFromBackend,
isCollaborationLink,
} from "./data";
import { updateStaleImageStatuses } from "./data/FileManager";
import { FileStatusStore } from "./data/fileStatusStore";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import { useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
@@ -141,13 +131,7 @@ import DebugCanvas, {
} from "./components/DebugCanvas";
import { AIComponents } from "./components/AI";
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
import "./index.scss";
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
import { AppSidebar } from "./components/AppSidebar";
import type { CollabAPI } from "./collab/Collab";
import { isElementLink } from "@excalidraw/excalidraw/element/elementLink";
polyfill();
@@ -230,20 +214,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);
@@ -257,26 +230,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) {
@@ -371,8 +329,6 @@ const initializeScene = async (opts: {
};
const ExcalidrawWrapper = () => {
const excalidrawAPI = useExcalidrawAPI();
const [errorMessage, setErrorMessage] = useState("");
const isCollabDisabled = isRunningInIframe();
@@ -380,8 +336,6 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode();
const editorInterface = useEditorInterface();
// initial state
// ---------------------------------------------------------------------------
@@ -403,6 +357,9 @@ const ExcalidrawWrapper = () => {
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
@@ -420,7 +377,7 @@ const ExcalidrawWrapper = () => {
const [, forceRefresh] = useState(false);
useEffect(() => {
if (isDevEnv()) {
if (import.meta.env.DEV) {
const debugState = loadSavedDebugState();
if (debugState.enabled && !window.visualDebug) {
@@ -434,15 +391,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
@@ -469,12 +429,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,
@@ -486,18 +440,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);
}
@@ -510,19 +458,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);
@@ -545,10 +484,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,
});
}
@@ -556,6 +493,11 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@@ -646,8 +588,9 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode, loadImages]);
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
useEffect(() => {
const unloadHandler = (event: BeforeUnloadEvent) => {
@@ -659,13 +602,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
)
) {
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
@@ -720,8 +657,8 @@ const ExcalidrawWrapper = () => {
debugRenderer(
debugCanvasRef.current,
appState,
elements,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
}
};
@@ -785,63 +722,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
@@ -908,8 +793,8 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
onExport={onExport}
initialData={initialStatePromiseRef.current.promise}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
@@ -951,27 +836,18 @@ const ExcalidrawWrapper = () => {
handleKeyboardGlobally={true}
autoFocus={true}
theme={editorTheme}
onThemeChange={setAppTheme}
renderTopRightUI={(isMobile) => {
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>
);
@@ -988,6 +864,7 @@ const ExcalidrawWrapper = () => {
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)}
/>
<AppWelcomeScreen
@@ -1019,15 +896,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}
@@ -1056,8 +928,6 @@ const ExcalidrawWrapper = () => {
}}
/>
<AppSidebar />
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
@@ -1228,6 +1098,14 @@ const ExcalidrawWrapper = () => {
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
@@ -1267,9 +1145,7 @@ const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider store={appJotaiStore}>
<ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</ExcalidrawAPIProvider>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
);
+7 -13
View File
@@ -1,21 +1,15 @@
import { Stats } from "@excalidraw/excalidraw";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import {
DEFAULT_VERSION,
debounce,
getVersion,
nFormatter,
} from "@excalidraw/common";
import { t } from "@excalidraw/excalidraw/i18n";
import { useEffect, useState } from "react";
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils";
import {
getElementsStorageSize,
getTotalStorageSize,
} from "./data/localStorage";
import { DEFAULT_VERSION } from "@excalidraw/excalidraw/constants";
import { t } from "@excalidraw/excalidraw/i18n";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { UIAppState } from "@excalidraw/excalidraw/types";
import { Stats } from "@excalidraw/excalidraw";
type StorageSizes = { scene: number; total: number };
@@ -1,15 +1,13 @@
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { useLayoutEffect, useRef } from "react";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import type {
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
import { STORAGE_KEYS } from "./app_constants";
import { LocalData } from "./data/LocalData";
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
+1 -3
View File
@@ -1,8 +1,6 @@
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
import React from "react";
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
import { useSetAtom } from "../app-jotai";
import { appLangCodeAtom } from "./language-state";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
@@ -1,5 +1,5 @@
import { defaultLang, languages } from "@excalidraw/excalidraw";
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "@excalidraw/excalidraw";
export const languageDetector = new LanguageDetector();
@@ -1,7 +1,5 @@
import { useEffect } from "react";
import { atom, useAtom } from "../app-jotai";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
+1 -3
View File
@@ -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",
+68 -101
View File
@@ -1,47 +1,5 @@
import {
CaptureUpdateAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
reconcileElements,
} from "@excalidraw/excalidraw";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
assertNever,
isDevEnv,
isTestEnv,
preventUnload,
resolvablePromise,
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 { AbortError } from "@excalidraw/excalidraw/errors";
import { t } from "@excalidraw/excalidraw/i18n";
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,
} from "@excalidraw/excalidraw/data/reconcile";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
BinaryFileData,
ExcalidrawImperativeAPI,
@@ -49,9 +7,28 @@ import type {
Collaborator,
Gesture,
} from "@excalidraw/excalidraw/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type {
ExcalidrawElement,
FileId,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import {
CaptureUpdateAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
reconcileElements,
} from "@excalidraw/excalidraw";
import {
assertNever,
preventUnload,
resolvablePromise,
throttleRAF,
} from "@excalidraw/excalidraw/utils";
import {
CURSOR_SYNC_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
@@ -62,18 +39,15 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
} from "../data";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { FileStatusStore } from "../data/fileStatusStore";
import { LocalData } from "../data/LocalData";
import {
isSavedToFirebase,
loadFilesFromFirebase,
@@ -85,15 +59,36 @@ import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
} from "../data/localStorage";
import { resetBrowserStateVersions } from "../data/tabSync";
import { collabErrorIndicatorAtom } from "./CollabError";
import Portal from "./Portal";
import { t } from "@excalidraw/excalidraw/i18n";
import {
IDLE_THRESHOLD,
ACTIVE_THRESHOLD,
UserIdleState,
} from "@excalidraw/excalidraw/constants";
import {
encodeFilesForUpload,
FileManager,
updateStaleImageStatuses,
} from "../data/FileManager";
import { AbortError } from "@excalidraw/excalidraw/errors";
import {
isImageElement,
isInitializedImageElement,
} from "@excalidraw/excalidraw/element/typeChecks";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { decryptData } from "@excalidraw/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { appJotaiStore, atom } from "../app-jotai";
import type { Mutable, ValueOf } from "@excalidraw/excalidraw/utility-types";
import { getVisibleSceneBounds } from "@excalidraw/excalidraw/element/bounds";
import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "@excalidraw/excalidraw/data/reconcile";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
@@ -150,7 +145,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) {
@@ -242,7 +236,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
appJotaiStore.set(collabAPIAtom, collabAPI);
if (isTestEnv() || isDevEnv()) {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.collab = window.collab || ({} as Window["collab"]);
Object.defineProperties(window, {
collab: {
@@ -302,20 +296,13 @@ class Collab extends PureComponent<CollabProps, CollabState> {
// the purpose is to run in immediately after user decides to stay
this.saveCollabRoomToFirebase(syncableElements);
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
preventUnload(event);
} else {
console.warn(
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
);
}
preventUnload(event);
}
});
saveCollabRoomToFirebase = async (
syncableElements: readonly SyncableExcalidrawElement[],
) => {
syncableElements = cloneJSON(syncableElements);
try {
const storedElements = await saveToFirebase(
this.portal,
@@ -446,7 +433,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
};
private decryptPayload = async (
iv: Uint8Array<ArrayBuffer>,
iv: Uint8Array,
encryptedData: ArrayBuffer,
decryptionKey: string,
): Promise<ValueOf<SocketUpdateDataSource>> => {
@@ -535,10 +522,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 +551,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 +568,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 +582,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 +731,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
@@ -1042,7 +1009,7 @@ declare global {
}
}
if (isTestEnv() || isDevEnv()) {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.collab = window.collab || ({} as Window["collab"]);
}
-1
View File
@@ -2,7 +2,6 @@ import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { warning } from "@excalidraw/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { atom } from "../app-jotai";
import "./CollabError.scss";
+15 -16
View File
@@ -1,26 +1,25 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import { newElementWith } from "@excalidraw/element";
import throttle from "lodash.throttle";
import type { UserIdleState } from "@excalidraw/common";
import type { OrderedExcalidrawElement } from "@excalidraw/element/types";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import { isSyncableElement } from "../data";
import type {
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
import type { TCollabClass } from "./Collab";
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import type {
OnUserFollowedPayload,
SocketId,
} from "@excalidraw/excalidraw/types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { encryptData } from "@excalidraw/excalidraw/data/encryption";
import type { Socket } from "socket.io-client";
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
class Portal {
collab: TCollabClass;
+55 -21
View File
@@ -1,17 +1,13 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import {
DiagramToCodePlugin,
exportToBlob,
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";
import { safelyParseJSON } from "@excalidraw/excalidraw/utils";
export const AIComponents = ({
excalidrawAPI,
@@ -76,7 +72,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 +98,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}
/>
</>
);
+9 -6
View File
@@ -1,10 +1,9 @@
import { Footer } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
import { Footer } from "@excalidraw/excalidraw/index";
import { EncryptedIcon } from "./EncryptedIcon";
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
export const AppFooter = React.memo(
({ onChange }: { onChange: () => void }) => {
@@ -18,7 +17,11 @@ export const AppFooter = React.memo(
}}
>
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
{isExcalidrawPlusSignedUser ? (
<ExcalidrawPlusAppLink />
) : (
<EncryptedIcon />
)}
</div>
</Footer>
);
+11 -12
View File
@@ -1,18 +1,13 @@
import React from "react";
import {
loginIcon,
ExcalLogo,
eyeIcon,
} from "@excalidraw/excalidraw/components/icons";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { MainMenu } from "@excalidraw/excalidraw/index";
import React from "react";
import { isDevEnv } from "@excalidraw/common";
import type { Theme } from "@excalidraw/element/types";
import { LanguageList } from "../app-language/LanguageList";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "../app-language/LanguageList";
import { saveDebugState } from "./DebugCanvas";
export const AppMainMenu: React.FC<{
@@ -20,6 +15,7 @@ export const AppMainMenu: React.FC<{
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
refresh: () => void;
}> = React.memo((props) => {
return (
@@ -58,10 +54,10 @@ export const AppMainMenu: React.FC<{
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
{isDevEnv() && (
{import.meta.env.DEV && (
<MainMenu.Item
icon={eyeIcon}
onSelect={() => {
onClick={() => {
if (window.visualDebug) {
delete window.visualDebug;
saveDebugState({ enabled: false });
@@ -76,8 +72,11 @@ export const AppMainMenu: React.FC<{
</MainMenu.Item>
)}
<MainMenu.Separator />
<MainMenu.DefaultItems.Preferences />
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
-36
View File
@@ -1,36 +0,0 @@
.excalidraw {
.app-sidebar-promo-container {
padding: 0.75rem;
display: flex;
flex-direction: column;
text-align: center;
gap: 1rem;
flex: 1 1 auto;
}
.app-sidebar-promo-image {
margin: 1rem 0;
height: 16.25rem;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-image: radial-gradient(
circle,
transparent 60%,
var(--sidebar-bg-color) 100%
),
var(--image-source);
display: flex;
}
.app-sidebar-promo-text {
padding: 0 2rem;
}
.link-button {
margin: 0 auto;
}
}
-79
View File
@@ -1,79 +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";
export const AppSidebar = () => {
const { theme, openSidebar } = useUIAppState();
return (
<DefaultSidebar>
<DefaultSidebar.TabTriggers>
<Sidebar.TabTrigger
tab="comments"
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
>
{messageCircleIcon}
</Sidebar.TabTrigger>
<Sidebar.TabTrigger
tab="presentation"
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
>
{presentationIcon}
</Sidebar.TabTrigger>
</DefaultSidebar.TabTriggers>
<Sidebar.Tab tab="comments">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_comments_${
theme === THEME.DARK ? "dark" : "light"
}.jpg)`,
opacity: 0.7,
}}
/>
<div className="app-sidebar-promo-text">
Make comments with Excalidraw+
</div>
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
<Sidebar.Tab tab="presentation" className="px-3">
<div className="app-sidebar-promo-container">
<div
className="app-sidebar-promo-image"
style={{
["--image-source" as any]: `url(/oss_promo_presentations_${
theme === THEME.DARK ? "dark" : "light"
}.svg)`,
backgroundSize: "60%",
opacity: 0.4,
}}
/>
<div className="app-sidebar-promo-text">
Create presentations with Excalidraw+
</div>
<LinkButton
href={`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
>
Sign up now
</LinkButton>
</div>
</Sidebar.Tab>
</DefaultSidebar>
);
};
+3 -12
View File
@@ -1,10 +1,9 @@
import React from "react";
import { loginIcon } from "@excalidraw/excalidraw/components/icons";
import { POINTER_EVENTS } from "@excalidraw/common";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { WelcomeScreen } from "@excalidraw/excalidraw/index";
import React from "react";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
export const AppWelcomeScreen: React.FC<{
onCollabDialogOpen: () => any;
@@ -33,15 +32,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 (
+47 -269
View File
@@ -1,45 +1,24 @@
import { useCallback, useImperativeHandle, useRef } from "react";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/excalidraw/utils";
import {
bootstrapCanvas,
getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers";
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
import {
ArrowheadArrowIcon,
CloseIcon,
TrashIcon,
} from "@excalidraw/excalidraw/components/icons";
import {
bootstrapCanvas,
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 { STORAGE_KEYS } from "../app_constants";
import type { Curve } from "../../packages/math";
import {
isLineSegment,
type GlobalPoint,
type LineSegment,
} from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve";
import React from "react";
import type { Curve } from "@excalidraw/math";
import type {
DebugElement,
DebugPolygon,
} from "@excalidraw/element/visualdebug";
import type {
ElementsMap,
ExcalidrawArrowElement,
ExcalidrawBindableElement,
FixedPointBinding,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import { STORAGE_KEYS } from "../app_constants";
} from "../../packages/math";
import { isCurve } from "../../packages/math/curve";
const renderLine = (
context: CanvasRenderingContext2D,
@@ -78,44 +57,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 +69,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 +92,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 +101,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 +129,6 @@ const _debugRenderer = (
);
renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value);
if (
window.visualDebug?.currentFrame &&
@@ -409,11 +180,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 +310,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;
+2 -2
View File
@@ -1,5 +1,5 @@
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { shield } from "@excalidraw/excalidraw/components/icons";
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
import { useI18n } from "@excalidraw/excalidraw/i18n";
export const EncryptedIcon = () => {
@@ -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>
);
};
@@ -1,33 +1,31 @@
import React from "react";
import { uploadBytes, ref } from "firebase/storage";
import { nanoid } from "nanoid";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { Card } from "@excalidraw/excalidraw/components/Card";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
import { MIME_TYPES, getFrame } from "@excalidraw/common";
import {
encryptData,
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
import { isInitializedImageElement } from "@excalidraw/element";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import type {
FileId,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import { nanoid } from "nanoid";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import {
encryptData,
generateEncryptionKey,
} from "@excalidraw/excalidraw/data/encryption";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
import { encodeFilesForUpload } from "../data/FileManager";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import { uploadBytes, ref } from "firebase/storage";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils";
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
@@ -0,0 +1,45 @@
import oc from "open-color";
import React from "react";
import { THEME } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/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 Trans from "@excalidraw/excalidraw/components/Trans";
import { t } from "@excalidraw/excalidraw/i18n";
import * as Sentry from "@sentry/browser";
import React from "react";
import * as Sentry from "@sentry/browser";
import { t } from "@excalidraw/excalidraw/i18n";
import Trans from "@excalidraw/excalidraw/components/Trans";
interface TopErrorBoundaryState {
hasError: boolean;
+4 -27
View File
@@ -1,15 +1,14 @@
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
import { compressData } from "@excalidraw/excalidraw/data/encode";
import { newElementWith } from "@excalidraw/element";
import { isInitializedImageElement } from "@excalidraw/element";
import { t } from "@excalidraw/excalidraw/i18n";
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import type {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
import type {
BinaryFileData,
BinaryFileMetadata,
@@ -40,12 +39,10 @@ export class FileManager {
private _getFiles;
private _saveFiles;
private _onFileStatusChange;
constructor({
getFiles,
saveFiles,
onFileStatusChange,
}: {
getFiles: (fileIds: FileId[]) => Promise<{
loadedFiles: BinaryFileData[];
@@ -55,13 +52,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 +145,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 +155,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 +194,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();
+13 -32
View File
@@ -10,12 +10,6 @@
* (localStorage, indexedDB).
*/
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
debounce,
} from "@excalidraw/common";
import {
createStore,
entries,
@@ -25,31 +19,32 @@ import {
setMany,
get,
} from "idb-keyval";
import { getNonDeletedElements } from "@excalidraw/element";
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
import {
CANVAS_SEARCH_TAB,
DEFAULT_SIDEBAR,
} from "@excalidraw/excalidraw/constants";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
import type {
ExcalidrawElement,
FileId,
} from "@excalidraw/excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFiles,
} from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
import { appJotaiStore, atom } from "../app-jotai";
import type { MaybePromise } from "@excalidraw/excalidraw/utility-types";
import { debounce } from "@excalidraw/excalidraw/utils";
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)[]) => {
-51
View File
@@ -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;
}
}
}
-48
View File
@@ -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 };
}
}
+23 -29
View File
@@ -1,12 +1,27 @@
import { reconcileElements } from "@excalidraw/excalidraw";
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/excalidraw/element/types";
import { getSceneVersion } from "@excalidraw/excalidraw/element";
import type Portal from "../collab/Portal";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "@excalidraw/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { decompressData } from "@excalidraw/excalidraw/data/encode";
import {
encryptData,
decryptData,
} from "@excalidraw/excalidraw/data/encryption";
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
import { getSceneVersion } from "@excalidraw/element";
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import { initializeApp } from "firebase/app";
import {
getFirestore,
@@ -16,27 +31,8 @@ import {
Bytes,
} from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
import type {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "@excalidraw/excalidraw/types";
import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
import { getSyncableElements } from ".";
import type { SyncableExcalidrawElement } from ".";
import type Portal from "../collab/Portal";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
// private
// -----------------------------------------------------------------------------
@@ -105,8 +101,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 +239,7 @@ export const saveToFirebase = async (
FirebaseSceneVersionCache.set(socket, storedElements);
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
return storedElements;
};
export const loadFromFirebase = async (
@@ -259,9 +255,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) {
+47 -16
View File
@@ -8,38 +8,35 @@ 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 { t } from "@excalidraw/excalidraw/i18n";
import { bytesToHexString } from "@excalidraw/common";
import type { UserIdleState } from "@excalidraw/common";
import { restore } from "@excalidraw/excalidraw/data/restore";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { SceneBounds } from "@excalidraw/element";
import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
import type {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "@excalidraw/element/types";
} from "@excalidraw/excalidraw/element/types";
import { t } from "@excalidraw/excalidraw/i18n";
import type {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
} from "@excalidraw/excalidraw/types";
import type { MakeBrand } from "@excalidraw/common/utility-types";
import type { UserIdleState } from "@excalidraw/excalidraw/constants";
import type { MakeBrand } from "@excalidraw/excalidraw/utility-types";
import { bytesToHexString } from "@excalidraw/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
import type { WS_SUBTYPES } from "../app_constants";
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;
@@ -83,13 +80,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 +196,7 @@ const legacy_decodeFromBackend = async ({
};
};
export const importFromBackend = async (
const importFromBackend = async (
id: string,
decryptionKey: string,
): Promise<ImportedDataState> => {
@@ -241,6 +238,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 };
+4 -5
View File
@@ -1,11 +1,10 @@
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "@excalidraw/excalidraw/appState";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants";
export const saveUsernameToLocalStorage = (username: string) => {
@@ -49,7 +48,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 -2
View File
@@ -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
View File
@@ -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;
}
+1 -3
View File
@@ -1,11 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import ExcalidrawApp from "./App";
import { registerSW } from "virtual:pwa-register";
import "../excalidraw-app/sentry";
import ExcalidrawApp from "./App";
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
const rootElement = document.getElementById("root")!;
const root = createRoot(rootElement);
+1 -2
View File
@@ -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",
-13
View File
@@ -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"),
);
}
-56
View File
@@ -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 }}
/>
);
};
-25
View File
@@ -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);
+9 -12
View File
@@ -1,8 +1,10 @@
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { useEffect, useRef, useState } from "react";
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getFrame } from "@excalidraw/excalidraw/utils";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS } from "@excalidraw/excalidraw/keys";
import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import {
copyIcon,
LinkIcon,
@@ -12,19 +14,15 @@ import {
shareIOS,
shareWindows,
} from "@excalidraw/excalidraw/components/icons";
import { TextField } from "@excalidraw/excalidraw/components/TextField";
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
import { useI18n } from "@excalidraw/excalidraw/i18n";
import { KEYS, getFrame } from "@excalidraw/common";
import { useEffect, useRef, useState } from "react";
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";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
@@ -143,7 +141,6 @@ const ActiveRoomDialog = ({
}}
/>
</div>
<QRCode value={activeRoomLink} />
<div className="ShareDialog__active__description">
<p>
<span
-5
View File
@@ -1,5 +0,0 @@
import { renderSVG } from "uqr";
export const generateQRCodeSVG = (text: string): string => {
return renderSVG(text);
};
+20 -5
View File
@@ -1,11 +1,11 @@
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import ExcalidrawApp from "../App";
import {
mockBoundingClientRect,
render,
restoreOriginalGetBoundingClientRect,
} from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
describe("Test MobileMenu", () => {
const { h } = window;
@@ -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
+82 -87
View File
@@ -1,18 +1,13 @@
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
import { vi } from "vitest";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { syncInvalidIndices } from "@excalidraw/excalidraw/fractionalIndex";
import {
createRedoAction,
createUndoAction,
} from "@excalidraw/excalidraw/actions/actionHistory";
import { syncInvalidIndices } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
import { vi } from "vitest";
import { StoreIncrement } from "@excalidraw/element";
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
import ExcalidrawApp from "../App";
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
const { h } = window;
@@ -69,79 +64,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 +121,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 +153,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 +169,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 }),
]);
});
});
});
+23 -5
View File
@@ -1,8 +1,8 @@
import { THEME } from "@excalidraw/excalidraw";
import { useEffect, useLayoutEffect, useState } from "react";
import type { Theme } from "@excalidraw/element/types";
import { THEME } from "@excalidraw/excalidraw";
import { EVENT } from "@excalidraw/excalidraw/constants";
import type { Theme } from "@excalidraw/excalidraw/element/types";
import { CODES, KEYS } from "@excalidraw/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
@@ -30,10 +30,28 @@ export const useHandleAppTheme = () => {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme]);
}, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+9 -65
View File
@@ -23,71 +23,29 @@ export default defineConfig(({ mode }) => {
envDir: "../",
resolve: {
alias: [
{
find: /^@excalidraw\/common$/,
replacement: path.resolve(
__dirname,
"../packages/common/src/index.ts",
),
},
{
find: /^@excalidraw\/common\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/common/src/$1"),
},
{
find: /^@excalidraw\/element$/,
replacement: path.resolve(
__dirname,
"../packages/element/src/index.ts",
),
},
{
find: /^@excalidraw\/element\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/element/src/$1"),
},
{
find: /^@excalidraw\/excalidraw$/,
replacement: path.resolve(
__dirname,
"../packages/excalidraw/index.tsx",
),
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
},
{
find: /^@excalidraw\/excalidraw\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/excalidraw/$1"),
},
{
find: /^@excalidraw\/math$/,
replacement: path.resolve(__dirname, "../packages/math/src/index.ts"),
},
{
find: /^@excalidraw\/math\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/math/src/$1"),
},
{
find: /^@excalidraw\/utils$/,
replacement: path.resolve(
__dirname,
"../packages/utils/src/index.ts",
),
replacement: path.resolve(__dirname, "../packages/utils/index.ts"),
},
{
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
replacement: path.resolve(__dirname, "../packages/utils/$1"),
},
{
find: /^@excalidraw\/fractional-indexing$/,
replacement: path.resolve(
__dirname,
"../packages/fractional-indexing/src/index.ts",
),
find: /^@excalidraw\/math$/,
replacement: path.resolve(__dirname, "../packages/math/index.ts"),
},
{
find: /^@excalidraw\/laser-pointer$/,
replacement: path.resolve(
__dirname,
"../packages/laser-pointer/src/index.ts",
),
find: /^@excalidraw\/math\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/math/$1"),
},
],
},
@@ -116,14 +74,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";
}
},
},
},
@@ -168,11 +118,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: [
{
@@ -212,7 +157,7 @@ export default defineConfig(({ mode }) => {
},
},
{
urlPattern: new RegExp("(.chunk-.+|CodeMirrorEditor-.+)\\.js"),
urlPattern: new RegExp(".chunk-.+.js"),
handler: "CacheFirst",
options: {
cacheName: "chunk",
@@ -223,7 +168,6 @@ export default defineConfig(({ mode }) => {
},
},
],
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
},
manifest: {
short_name: "Excalidraw",
@@ -253,7 +197,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id: "excalidraw",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",
+13 -19
View File
@@ -4,7 +4,9 @@
"packageManager": "yarn@1.22.22",
"workspaces": [
"excalidraw-app",
"packages/*",
"packages/excalidraw",
"packages/utils",
"packages/math",
"examples/*"
],
"devDependencies": {
@@ -24,7 +26,6 @@
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",
"husky": "7.0.4",
@@ -33,8 +34,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 +44,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,19 +52,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:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
"build:laser-pointer": "yarn --cwd ./packages/laser-pointer build:esm",
"build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:laser-pointer && 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 .",
@@ -82,15 +76,15 @@
"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",
"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"
},
"resolutions": {
"@types/d3-dispatch": "3.0.6",
"strip-ansi": "6.0.1"
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["../eslintrc.base.json"]
}
-19
View File
@@ -1,19 +0,0 @@
# @excalidraw/common
## Install
```bash
npm install @excalidraw/common
```
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
```bash
yarn add @excalidraw/common
```
With PNPM, similarly install the package with this command:
```bash
pnpm add @excalidraw/common
```
-3
View File
@@ -1,3 +0,0 @@
/// <reference types="vite/client" />
import "@excalidraw/excalidraw/global";
import "@excalidraw/excalidraw/css";

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