Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ec2889ba |
+2
-7
@@ -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=
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
MODE="production"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
|
||||
@@ -1,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,12 +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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Project coding standards
|
||||
|
||||
## Generic Communication Guidelines
|
||||
|
||||
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||
- Stop apologising if corrected, just provide the correct information or code
|
||||
- Prefer code unless asked for explanation
|
||||
- Stop summarizing what you've changed after modifications unless asked for
|
||||
|
||||
## TypeScript Guidelines
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Where possible, prefer implementations without allocation
|
||||
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||
- Prefer immutable data (const, readonly)
|
||||
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||
|
||||
## React Guidelines
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the React hooks rules (no conditional hooks)
|
||||
- Keep components small and focused
|
||||
- Use CSS modules for component styling
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Use PascalCase for component names, interfaces, and type aliases
|
||||
- Use camelCase for variables, functions, and methods
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use try/catch blocks for async operations
|
||||
- Implement proper error boundaries in React components
|
||||
- Always log errors with contextual information
|
||||
|
||||
## Testing
|
||||
|
||||
- Always attempt to fix #problems
|
||||
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||
|
||||
## Types
|
||||
|
||||
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn release --tag=next --non-interactive
|
||||
yarn autorelease
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Auto release excalidraw preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: "+1"
|
||||
- name: Get PR SHA
|
||||
id: sha
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const pr = await github.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
});
|
||||
return pr.data.head.sha
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ steps.sha.outputs.result }}
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release preview
|
||||
id: "autorelease"
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease preview ${{ github.event.issue.number }}
|
||||
- name: Post comment post release
|
||||
if: always()
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
|
||||
@@ -17,14 +17,9 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
export SENTRY_RELEASE=$(sentry-cli releases propose-version)
|
||||
sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT
|
||||
sentry-cli releases set-commits --auto $SENTRY_RELEASE
|
||||
sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js"
|
||||
sentry-cli releases finalize $SENTRY_RELEASE
|
||||
sentry-cli releases deploys $SENTRY_RELEASE new -e production
|
||||
env:
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install in packages/excalidraw
|
||||
run: yarn
|
||||
working-directory: packages/excalidraw
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
- name: "Install Node"
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "18.x"
|
||||
- name: "Install Deps"
|
||||
run: yarn install
|
||||
- name: "Test Coverage"
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install and test
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
+1
-2
@@ -25,5 +25,4 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
meta*.json
|
||||
@@ -1,34 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Structure
|
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features
|
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||
3. **Testing**: Always run `yarn test:update` before committing
|
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn test:typecheck # TypeScript type checking
|
||||
yarn test:update # Run all tests (with snapshot updates)
|
||||
yarn fix # Auto-fix formatting and linting issues
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Package System
|
||||
|
||||
- Uses Yarn workspaces for monorepo management
|
||||
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||
- Build system uses esbuild for packages, Vite for the app
|
||||
- TypeScript throughout with strict configuration
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,14 +6,13 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
RUN yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -23,17 +23,20 @@
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
|
||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
|
||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
|
||||
</a>
|
||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
|
||||
</a>
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
||||
</a>
|
||||
<a href="https://twitter.com/excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -60,7 +63,7 @@ The Excalidraw editor (npm package) supports:
|
||||
- 🏗️ Customizable.
|
||||
- 📷 Image support.
|
||||
- 😀 Shape libraries support.
|
||||
- 🌐 Localization (i18n) support.
|
||||
- 👅 Localization (i18n) support.
|
||||
- 🖼️ Export to PNG, SVG & clipboard.
|
||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
||||
|
||||
@@ -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 />);
|
||||
```
|
||||
```
|
||||
@@ -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` | `null` | <code>Promise<object | 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 | 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({ clientX: number, clientY: number },<br/> 
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||
</pre>
|
||||
|
||||
### useEditorInterface
|
||||
### useDevice
|
||||
|
||||
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
||||
|
||||
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
|
||||
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@@ -336,20 +336,12 @@ render(<App />);
|
||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
|
||||
The `EditorInterface` object has the following properties:
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
|
||||
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
|
||||
| `userAgent.raw` | `string` | Raw user agent string |
|
||||
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
|
||||
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
|
||||
| `isTouchScreen` | `boolean` | True if touch events are detected |
|
||||
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
|
||||
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
|
||||
| --- | --- | --- |
|
||||
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
|
||||
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
|
||||
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
|
||||
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
|
||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
|
||||
|
||||
### i18n
|
||||
|
||||
|
||||
@@ -28,12 +28,32 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
||||
|
||||
## Releasing
|
||||
|
||||
### Create a test release
|
||||
|
||||
You can create a test release by posting the below comment in your pull request:
|
||||
|
||||
```bash
|
||||
@excalibot trigger release
|
||||
```
|
||||
|
||||
Once the version is released `@excalibot` will post a comment with the release version.
|
||||
|
||||
### Creating a production release
|
||||
|
||||
To release the next stable version follow the below steps:
|
||||
|
||||
```bash
|
||||
yarn release --tag=latest --version=0.19.0
|
||||
yarn prerelease:excalidraw
|
||||
```
|
||||
|
||||
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
|
||||
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
|
||||
|
||||
The next step is to run the `release` script:
|
||||
|
||||
```bash
|
||||
yarn release:excalidraw
|
||||
```
|
||||
|
||||
This will publish the package.
|
||||
|
||||
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
|
||||
|
||||
@@ -38,8 +38,6 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
||||
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||
{
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
type FeatureItem = {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
excalidraw:
|
||||
build:
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:packages": "yarn --cwd ../../ build:packages",
|
||||
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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">;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
}
|
||||
|
||||
+116
-181
@@ -1,27 +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,
|
||||
} 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,
|
||||
THEME,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
getFrame,
|
||||
@@ -29,14 +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 { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
||||
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,88 +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,
|
||||
} 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 {
|
||||
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";
|
||||
@@ -139,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();
|
||||
|
||||
@@ -228,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);
|
||||
@@ -255,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,
|
||||
}),
|
||||
scene.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) {
|
||||
@@ -376,8 +336,6 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
const editorInterface = useEditorInterface();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -419,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) {
|
||||
@@ -526,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,
|
||||
});
|
||||
}
|
||||
@@ -537,6 +493,11 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
@@ -551,11 +512,7 @@ const ExcalidrawWrapper = () => {
|
||||
const username = importUsernameFromLocalStorage();
|
||||
setLangCode(getPreferredLanguage());
|
||||
excalidrawAPI.updateScene({
|
||||
elements: restoreElements(localDataState?.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
appState: restoreAppState(localDataState?.appState, null),
|
||||
...localDataState,
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
LibraryIndexedDBAdapter.load().then((data) => {
|
||||
@@ -631,6 +588,7 @@ const ExcalidrawWrapper = () => {
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
@@ -644,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);
|
||||
@@ -705,8 +657,8 @@ const ExcalidrawWrapper = () => {
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
elements,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -770,8 +722,6 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||
|
||||
const onCollabDialogOpen = useCallback(
|
||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||
[setShareDialogState],
|
||||
@@ -890,22 +840,14 @@ const ExcalidrawWrapper = () => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="excalidraw-ui-top-right">
|
||||
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
||||
<ExcalidrawPlusPromoBanner
|
||||
isSignedIn={isExcalidrawPlusSignedUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="top-right-ui">
|
||||
{collabError.message && <CollabError collabError={collabError} />}
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
editorInterface={editorInterface}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -954,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}
|
||||
@@ -991,8 +928,6 @@ const ExcalidrawWrapper = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppSidebar />
|
||||
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,17 +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 { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
@@ -84,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);
|
||||
@@ -240,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: {
|
||||
@@ -300,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,
|
||||
@@ -444,7 +433,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array<ArrayBuffer>,
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
@@ -533,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" });
|
||||
@@ -565,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;
|
||||
}
|
||||
@@ -582,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);
|
||||
@@ -598,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: {
|
||||
@@ -751,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
|
||||
@@ -1040,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"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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<{
|
||||
@@ -59,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 });
|
||||
@@ -77,7 +72,6 @@ export const AppMainMenu: React.FC<{
|
||||
</MainMenu.Item>
|
||||
)}
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.Preferences />
|
||||
<MainMenu.DefaultItems.ToggleTheme
|
||||
allowSystemTheme
|
||||
theme={props.theme}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,10 +180,10 @@ 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 },
|
||||
);
|
||||
@@ -539,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,30 +19,32 @@ import {
|
||||
setMany,
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
|
||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||
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 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 { 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) => {
|
||||
@@ -73,9 +69,6 @@ const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||
localStorageQuotaExceededAtom,
|
||||
);
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
@@ -86,33 +79,21 @@ const saveDataStateToLocalStorage = (
|
||||
_appState.openSidebar = null;
|
||||
}
|
||||
|
||||
const persistedElements = getNonDeletedElements(elements);
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(persistedElements),
|
||||
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 {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createStore, get, set } from "idb-keyval";
|
||||
|
||||
import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
/**
|
||||
* IndexedDB adapter for TTD chat storage.
|
||||
* Implements TTDPersistenceAdapter interface.
|
||||
*/
|
||||
export class TTDIndexedDBAdapter {
|
||||
/** IndexedDB database name */
|
||||
private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS;
|
||||
/** Store key for chat data */
|
||||
private static key = "ttdChats";
|
||||
|
||||
private static store = createStore(
|
||||
`${TTDIndexedDBAdapter.idb_name}-db`,
|
||||
`${TTDIndexedDBAdapter.idb_name}-store`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Load saved chats from IndexedDB.
|
||||
* @returns Promise resolving to saved chats array (empty if none found)
|
||||
*/
|
||||
static async loadChats(): Promise<SavedChats> {
|
||||
try {
|
||||
const data = await get<SavedChats>(
|
||||
TTDIndexedDBAdapter.key,
|
||||
TTDIndexedDBAdapter.store,
|
||||
);
|
||||
return data || [];
|
||||
} catch (error) {
|
||||
console.warn("Failed to load TTD chats from IndexedDB:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save chats to IndexedDB.
|
||||
* @param chats - The chats array to persist
|
||||
*/
|
||||
static async saveChats(chats: SavedChats): Promise<void> {
|
||||
try {
|
||||
await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save TTD chats to IndexedDB:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-51
@@ -10,6 +10,8 @@ const lessPrecise = (num: number, precision = 5) =>
|
||||
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;
|
||||
|
||||
@@ -22,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) {
|
||||
@@ -64,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: [],
|
||||
@@ -85,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") => {
|
||||
@@ -136,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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw Whiteboard</title>
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
+14
-27
@@ -1,5 +1,3 @@
|
||||
@import "../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||
|
||||
@@ -7,6 +5,12 @@
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
@@ -54,7 +58,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
.collab-offline-warning {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
@@ -65,18 +69,10 @@
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
z-index: 6;
|
||||
white-space: pre;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: var(--color-danger-dark);
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,31 +82,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.plus-banner {
|
||||
.plus-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--color-primary) !important;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.8333rem;
|
||||
font-size: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--button-color, var(--color-on-surface)) !important;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
@@ -122,7 +109,7 @@
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-banner {
|
||||
.plus-button {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
@@ -36,7 +36,6 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"socket.io-client": "4.7.2",
|
||||
"uqr": "0.1.2",
|
||||
"vite-plugin-html": "3.2.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getFeatureFlag } from "@excalidraw/common";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import callsites from "callsites";
|
||||
|
||||
@@ -34,7 +33,6 @@ Sentry.init({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
Sentry.featureFlagsIntegration(),
|
||||
],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
@@ -81,14 +79,3 @@ Sentry.init({
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
const flagsIntegration =
|
||||
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||
"FeatureFlags",
|
||||
);
|
||||
if (flagsIntegration) {
|
||||
flagsIntegration.addFeatureFlag(
|
||||
"COMPLEX_BINDINGS",
|
||||
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Spinner from "@excalidraw/excalidraw/components/Spinner";
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const QRCode = ({ value }: QRCodeProps) => {
|
||||
const [svgData, setSvgData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
import("./qrcode.chunk")
|
||||
.then(({ generateQRCodeSVG }) => {
|
||||
if (mounted) {
|
||||
try {
|
||||
setSvgData(generateQRCodeSVG(value));
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!svgData) {
|
||||
return (
|
||||
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ShareDialog__active__qrcode"
|
||||
role="img"
|
||||
aria-label="QR code for collaboration link"
|
||||
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -140,31 +140,6 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
$size: 150px;
|
||||
width: $size;
|
||||
height: $size;
|
||||
|
||||
& svg {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { renderSVG } from "uqr";
|
||||
|
||||
export const generateQRCodeSVG = (text: string): string => {
|
||||
return renderSVG(text);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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,190 +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 preserve future element fields across collab reconciliation", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
id: "A",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
backgroundColor: "#ff0000",
|
||||
});
|
||||
|
||||
const frameWithFutureFields = {
|
||||
...frame,
|
||||
schemaState: {
|
||||
tracks: {
|
||||
...frame.schemaState.tracks,
|
||||
"host.myapp.frame": 1,
|
||||
},
|
||||
},
|
||||
futureField: "keep-me",
|
||||
} as typeof frame & {
|
||||
schemaState: { tracks: Record<string, number> };
|
||||
futureField: string;
|
||||
};
|
||||
|
||||
API.updateScene({
|
||||
elements: [frameWithFutureFields],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as any).futureField).toBe("keep-me");
|
||||
expect((h.elements[0] as any).schemaState).toEqual(
|
||||
frameWithFutureFields.schemaState,
|
||||
);
|
||||
expect(h.elements[0].backgroundColor).toBe("#ff0000");
|
||||
});
|
||||
|
||||
const remoteMovedFrame = newElementWith(h.elements[0] as any, {
|
||||
x: 120,
|
||||
y: 80,
|
||||
});
|
||||
|
||||
const reconciled = (window.collab as any)._reconcileElements([
|
||||
remoteMovedFrame,
|
||||
]);
|
||||
|
||||
expect(reconciled[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
x: 120,
|
||||
y: 80,
|
||||
backgroundColor: "#ff0000",
|
||||
}),
|
||||
);
|
||||
expect((reconciled[0] as any).futureField).toBe("keep-me");
|
||||
expect((reconciled[0] as any).schemaState).toEqual(
|
||||
frameWithFutureFields.schemaState,
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve future element fields on local edits before broadcast", async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
|
||||
const rect = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "A",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const rectWithFutureFields = {
|
||||
...rect,
|
||||
schemaState: {
|
||||
tracks: {
|
||||
...rect.schemaState.tracks,
|
||||
"host.myapp.rect": 1,
|
||||
},
|
||||
},
|
||||
futureField: { value: "keep-me" },
|
||||
} as typeof rect & {
|
||||
schemaState: { tracks: Record<string, number> };
|
||||
futureField: { value: string };
|
||||
};
|
||||
|
||||
API.updateScene({
|
||||
elements: [rectWithFutureFields],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
const locallyEdited = newElementWith(h.elements[0] as any, { x: 200 });
|
||||
API.updateScene({
|
||||
elements: [locallyEdited],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((h.elements[0] as any).futureField).toEqual({ value: "keep-me" });
|
||||
expect((h.elements[0] as any).schemaState).toEqual(
|
||||
rectWithFutureFields.schemaState,
|
||||
);
|
||||
expect(h.elements[0]).toEqual(expect.objectContaining({ x: 200 }));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure this test starts from a deterministic scene regardless of previous
|
||||
// test state restored from persistence.
|
||||
API.updateScene({
|
||||
elements: [],
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
const durableBaseline = durableIncrements.length;
|
||||
const ephemeralBaseline = ephemeralIncrements.length;
|
||||
|
||||
const rectProps = {
|
||||
type: "rectangle",
|
||||
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(durableIncrements.length).toBe(durableBaseline + 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(ephemeralBaseline + 2);
|
||||
expect(
|
||||
ephemeralIncrements[ephemeralBaseline].change.elements[rect.id],
|
||||
).toEqual(expect.objectContaining({ x: 100 }));
|
||||
expect(
|
||||
ephemeralIncrements[ephemeralBaseline + 1].change.elements[rect.id],
|
||||
).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 = {
|
||||
@@ -310,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 }),
|
||||
@@ -343,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!
|
||||
@@ -359,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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { THEME } from "@excalidraw/excalidraw";
|
||||
import { EVENT, CODES, KEYS } from "@excalidraw/common";
|
||||
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 =>
|
||||
|
||||
@@ -23,57 +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\/math$/,
|
||||
replacement: path.resolve(__dirname, "../packages/math/index.ts"),
|
||||
},
|
||||
{
|
||||
find: /^@excalidraw\/math\/(.*?)/,
|
||||
replacement: path.resolve(__dirname, "../packages/math/$1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -102,10 +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";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -200,7 +168,6 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
],
|
||||
maximumFileSizeToCacheInBytes: 2.3 * 1024 ** 2, // 2.3MB
|
||||
},
|
||||
manifest: {
|
||||
short_name: "Excalidraw",
|
||||
@@ -230,7 +197,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
id: "excalidraw",
|
||||
id:"excalidraw",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
|
||||
+13
-17
@@ -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,17 +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:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@@ -80,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["../eslintrc.base.json"]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/common/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"description": "Excalidraw common functions, constants, etc.",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"excalidraw",
|
||||
"excalidraw-utils"
|
||||
],
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinycolor2": "1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tinycolor2": "1.4.6"
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`COLOR_PALETTE > color palette doesn't regress 1`] = `
|
||||
{
|
||||
"black": "#1e1e1e",
|
||||
"blue": [
|
||||
"#e7f5ff",
|
||||
"#a5d8ff",
|
||||
"#4dabf7",
|
||||
"#228be6",
|
||||
"#1971c2",
|
||||
],
|
||||
"bronze": [
|
||||
"#f8f1ee",
|
||||
"#eaddd7",
|
||||
"#d2bab0",
|
||||
"#a18072",
|
||||
"#846358",
|
||||
],
|
||||
"cyan": [
|
||||
"#e3fafc",
|
||||
"#99e9f2",
|
||||
"#3bc9db",
|
||||
"#15aabf",
|
||||
"#0c8599",
|
||||
],
|
||||
"grape": [
|
||||
"#f8f0fc",
|
||||
"#eebefa",
|
||||
"#da77f2",
|
||||
"#be4bdb",
|
||||
"#9c36b5",
|
||||
],
|
||||
"gray": [
|
||||
"#f8f9fa",
|
||||
"#e9ecef",
|
||||
"#ced4da",
|
||||
"#868e96",
|
||||
"#343a40",
|
||||
],
|
||||
"green": [
|
||||
"#ebfbee",
|
||||
"#b2f2bb",
|
||||
"#69db7c",
|
||||
"#40c057",
|
||||
"#2f9e44",
|
||||
],
|
||||
"orange": [
|
||||
"#fff4e6",
|
||||
"#ffd8a8",
|
||||
"#ffa94d",
|
||||
"#fd7e14",
|
||||
"#e8590c",
|
||||
],
|
||||
"pink": [
|
||||
"#fff0f6",
|
||||
"#fcc2d7",
|
||||
"#f783ac",
|
||||
"#e64980",
|
||||
"#c2255c",
|
||||
],
|
||||
"red": [
|
||||
"#fff5f5",
|
||||
"#ffc9c9",
|
||||
"#ff8787",
|
||||
"#fa5252",
|
||||
"#e03131",
|
||||
],
|
||||
"teal": [
|
||||
"#e6fcf5",
|
||||
"#96f2d7",
|
||||
"#38d9a9",
|
||||
"#12b886",
|
||||
"#099268",
|
||||
],
|
||||
"transparent": "transparent",
|
||||
"violet": [
|
||||
"#f3f0ff",
|
||||
"#d0bfff",
|
||||
"#9775fa",
|
||||
"#7950f2",
|
||||
"#6741d9",
|
||||
],
|
||||
"white": "#ffffff",
|
||||
"yellow": [
|
||||
"#fff9db",
|
||||
"#ffec99",
|
||||
"#ffd43b",
|
||||
"#fab005",
|
||||
"#f08c00",
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
|
||||
{
|
||||
"black": "#d3d3d3",
|
||||
"blue": [
|
||||
"#121e26",
|
||||
"#154162",
|
||||
"#2273b4",
|
||||
"#3791e0",
|
||||
"#56a2e8",
|
||||
],
|
||||
"bronze": [
|
||||
"#221c1a",
|
||||
"#362b26",
|
||||
"#5a463d",
|
||||
"#917569",
|
||||
"#a98d84",
|
||||
],
|
||||
"cyan": [
|
||||
"#0a1e20",
|
||||
"#004149",
|
||||
"#007281",
|
||||
"#0f8fa1",
|
||||
"#3da5b6",
|
||||
],
|
||||
"grape": [
|
||||
"#211a25",
|
||||
"#5b3165",
|
||||
"#a954be",
|
||||
"#d471ed",
|
||||
"#e28af8",
|
||||
],
|
||||
"gray": [
|
||||
"#161718",
|
||||
"#202325",
|
||||
"#33383d",
|
||||
"#6e757c",
|
||||
"#b7bcc1",
|
||||
],
|
||||
"green": [
|
||||
"#0f1d12",
|
||||
"#043b0c",
|
||||
"#056715",
|
||||
"#16842a",
|
||||
"#39994b",
|
||||
],
|
||||
"orange": [
|
||||
"#22190d",
|
||||
"#4c2b01",
|
||||
"#924800",
|
||||
"#cd6005",
|
||||
"#f17634",
|
||||
],
|
||||
"pink": [
|
||||
"#26191e",
|
||||
"#602e40",
|
||||
"#b04d70",
|
||||
"#f56e9d",
|
||||
"#ff8dbc",
|
||||
],
|
||||
"red": [
|
||||
"#1f1717",
|
||||
"#5a2c2c",
|
||||
"#b44d4d",
|
||||
"#fa6969",
|
||||
"#ff8383",
|
||||
],
|
||||
"teal": [
|
||||
"#0a1d17",
|
||||
"#00422b",
|
||||
"#00744b",
|
||||
"#039267",
|
||||
"#32a783",
|
||||
],
|
||||
"transparent": "#ededed00",
|
||||
"violet": [
|
||||
"#1f1c29",
|
||||
"#4a3b72",
|
||||
"#8a6cdf",
|
||||
"#a885ff",
|
||||
"#b595ff",
|
||||
],
|
||||
"white": "#121212",
|
||||
"yellow": [
|
||||
"#1e1900",
|
||||
"#362600",
|
||||
"#5f3a00",
|
||||
"#905000",
|
||||
"#b86200",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* x and y position of top left corner, x and y position of bottom right corner
|
||||
*/
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
box.length === 4 &&
|
||||
typeof box[0] === "number" &&
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
@@ -1,286 +0,0 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("COLOR_PALETTE", () => {
|
||||
it("color palette doesn't regress", () => {
|
||||
expect(COLOR_PALETTE).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDarkModeFilter", () => {
|
||||
describe("basic transformations", () => {
|
||||
it("transforms black to near-white", () => {
|
||||
const result = applyDarkModeFilter("#000000");
|
||||
// Black inverted 93% + hue rotate should be near white/light gray
|
||||
expect(result).toBe("#ededed");
|
||||
});
|
||||
|
||||
it("transforms white to near-black", () => {
|
||||
const result = applyDarkModeFilter("#ffffff");
|
||||
// White inverted 93% should be near black/dark gray
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms pure red", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("transforms pure green", () => {
|
||||
const result = applyDarkModeFilter("#00ff00");
|
||||
// Invert 93% + hue rotate 180deg
|
||||
expect(result).toBe("#008f00");
|
||||
});
|
||||
|
||||
it("transforms pure blue", () => {
|
||||
const result = applyDarkModeFilter("#0000ff");
|
||||
// Invert 93% + hue rotate 180deg produces a light purple
|
||||
expect(result).toBe("#cdcdff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("color formats", () => {
|
||||
it("handles hex with hash", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Fully opaque colors return 6-char hex
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("handles named colors", () => {
|
||||
const result = applyDarkModeFilter("red");
|
||||
// "red" = #ff0000, fully opaque
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgb format", () => {
|
||||
const result = applyDarkModeFilter("rgb(255, 0, 0)");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgba format and preserves alpha", () => {
|
||||
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
|
||||
expect(result).toMatch(/^#[0-9a-f]{8}$/);
|
||||
// Alpha 0.5 = 128 in hex = 80
|
||||
expect(result).toBe("#ff909080");
|
||||
});
|
||||
|
||||
it("handles transparent", () => {
|
||||
const result = applyDarkModeFilter("transparent");
|
||||
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
it("handles shorthand hex", () => {
|
||||
const result = applyDarkModeFilter("#f00");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha preservation", () => {
|
||||
it("omits alpha for full opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff0000ff");
|
||||
// Full opacity returns 6-char hex (no alpha suffix)
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("preserves 50% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000080");
|
||||
expect(result.slice(-2)).toBe("80");
|
||||
});
|
||||
|
||||
it("preserves 0% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000000");
|
||||
expect(result.slice(-2)).toBe("00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLOR_PALETTE regression tests", () => {
|
||||
it("transforms black from palette", () => {
|
||||
// COLOR_PALETTE.black is #1e1e1e (not pure black)
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
expect(result).toBe("#d3d3d3");
|
||||
});
|
||||
|
||||
it("transforms white from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms transparent from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
// Test each color family from the palette (all opaque, so 6-char hex)
|
||||
describe("red shades", () => {
|
||||
const redShades = COLOR_PALETTE.red;
|
||||
it.each(redShades.map((color, i) => [color, i]))(
|
||||
"transforms red shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("blue shades", () => {
|
||||
const blueShades = COLOR_PALETTE.blue;
|
||||
it.each(blueShades.map((color, i) => [color, i]))(
|
||||
"transforms blue shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("green shades", () => {
|
||||
const greenShades = COLOR_PALETTE.green;
|
||||
it.each(greenShades.map((color, i) => [color, i]))(
|
||||
"transforms green shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("gray shades", () => {
|
||||
const grayShades = COLOR_PALETTE.gray;
|
||||
it.each(grayShades.map((color, i) => [color, i]))(
|
||||
"transforms gray shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bronze shades", () => {
|
||||
const bronzeShades = COLOR_PALETTE.bronze;
|
||||
it.each(bronzeShades.map((color, i) => [color, i]))(
|
||||
"transforms bronze shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Snapshot test for full palette to catch any regressions
|
||||
it("matches snapshot for all palette colors", () => {
|
||||
const transformedPalette: Record<string, string | string[]> = {};
|
||||
|
||||
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
transformedPalette.transparent = applyDarkModeFilter(
|
||||
COLOR_PALETTE.transparent,
|
||||
);
|
||||
|
||||
// Transform color arrays
|
||||
for (const colorName of [
|
||||
"gray",
|
||||
"red",
|
||||
"pink",
|
||||
"grape",
|
||||
"violet",
|
||||
"blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"green",
|
||||
"yellow",
|
||||
"orange",
|
||||
"bronze",
|
||||
] as const) {
|
||||
const shades = COLOR_PALETTE[colorName];
|
||||
transformedPalette[colorName] = shades.map((shade) =>
|
||||
applyDarkModeFilter(shade),
|
||||
);
|
||||
}
|
||||
|
||||
expect(transformedPalette).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
it("returns same result for same input (cached)", () => {
|
||||
const result1 = applyDarkModeFilter("#ff0000");
|
||||
const result2 = applyDarkModeFilter("#ff0000");
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
expect(rgbToHex(0, 0, 0)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("converts white (255,255,255)", () => {
|
||||
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("converts red (255,0,0)", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("converts green (0,255,0)", () => {
|
||||
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("converts blue (0,0,255)", () => {
|
||||
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
|
||||
});
|
||||
|
||||
it("converts arbitrary color", () => {
|
||||
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leading zeros preservation", () => {
|
||||
it("preserves leading zeros for low values", () => {
|
||||
expect(rgbToHex(0, 0, 1)).toBe("#000001");
|
||||
expect(rgbToHex(0, 1, 0)).toBe("#000100");
|
||||
expect(rgbToHex(1, 0, 0)).toBe("#010000");
|
||||
});
|
||||
|
||||
it("preserves zeros for single-digit hex values", () => {
|
||||
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha handling", () => {
|
||||
it("omits alpha when undefined", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("omits alpha when fully opaque (1)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("includes alpha for semi-transparent (0.5)", () => {
|
||||
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
|
||||
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
|
||||
});
|
||||
|
||||
it("includes alpha for fully transparent (0)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
|
||||
});
|
||||
|
||||
it("includes alpha for near-opaque (0.99)", () => {
|
||||
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
|
||||
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
|
||||
});
|
||||
|
||||
it("pads alpha with leading zero when needed", () => {
|
||||
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
|
||||
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
import tinycolor from "tinycolor2";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
import { degreesToRadians } from "@excalidraw/math";
|
||||
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark mode color transformation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Browser-only cache to avoid memory leaks on server
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
degrees: Degrees,
|
||||
): { r: number; g: number; b: number } {
|
||||
// normalize
|
||||
const r = red / 255;
|
||||
const g = green / 255;
|
||||
const b = blue / 255;
|
||||
|
||||
// Convert degrees to radians
|
||||
const a = degreesToRadians(degrees);
|
||||
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
|
||||
// rotation matrix
|
||||
const matrix = [
|
||||
0.213 + c * 0.787 - s * 0.213,
|
||||
0.715 - c * 0.715 - s * 0.715,
|
||||
0.072 - c * 0.072 + s * 0.928,
|
||||
0.213 - c * 0.213 + s * 0.143,
|
||||
0.715 + c * 0.285 + s * 0.14,
|
||||
0.072 - c * 0.072 - s * 0.283,
|
||||
0.213 - c * 0.213 - s * 0.787,
|
||||
0.715 - c * 0.715 + s * 0.715,
|
||||
0.072 + c * 0.928 + s * 0.072,
|
||||
];
|
||||
|
||||
// transform
|
||||
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
|
||||
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
|
||||
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
|
||||
|
||||
// clamp the values to [0, 1] range and convert back to [0, 255]
|
||||
return {
|
||||
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
|
||||
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
|
||||
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
const cssInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
percent: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = clamp(percent, 0, 100) / 100;
|
||||
|
||||
// Function to invert a single color component
|
||||
const invertComponent = (color: number): number => {
|
||||
// Apply the invert formula
|
||||
const inverted = color * (1 - p) + (255 - color) * p;
|
||||
// Round to the nearest integer and clamp to [0, 255]
|
||||
return Math.round(clamp(inverted, 0, 255));
|
||||
};
|
||||
|
||||
// Calculate the inverted RGB components
|
||||
const invertedR = invertComponent(r);
|
||||
const invertedG = invertComponent(g);
|
||||
const invertedB = invertComponent(b);
|
||||
|
||||
return { r: invertedR, g: invertedG, b: invertedB };
|
||||
};
|
||||
|
||||
export const applyDarkModeFilter = (color: string): string => {
|
||||
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
|
||||
if (DARK_MODE_COLORS_CACHE) {
|
||||
DARK_MODE_COLORS_CACHE.set(color, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
|
||||
source: R,
|
||||
keys: K,
|
||||
) => {
|
||||
return keys.reduce((acc, key: K[number]) => {
|
||||
if (key in source) {
|
||||
acc[key] = source[key];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Pick<R, K[number]>) as Pick<R, K[number]>;
|
||||
};
|
||||
|
||||
export type ColorTuple = readonly [string, string, string, string, string];
|
||||
|
||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||
export type ColorPaletteCustom = { [key: string]: ColorTuple | string };
|
||||
export type ColorShadesIndexes = [number, number, number, number, number];
|
||||
|
||||
export const MAX_CUSTOM_COLORS_USED_IN_CANVAS = 5;
|
||||
export const COLORS_PER_ROW = 5;
|
||||
|
||||
export const DEFAULT_CHART_COLOR_INDEX = 4;
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_COLOR_INDEX = 4;
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX = 1;
|
||||
|
||||
export const COLOR_PALETTE = {
|
||||
transparent: "transparent",
|
||||
black: "#1e1e1e",
|
||||
white: "#ffffff",
|
||||
// open-color from https://github.com/yeun/open-color/blob/master/open-color.js
|
||||
// corresponds to indexes [0,2,4,6,8] (weights: 50, 200, 400, 600, 800)
|
||||
gray: ["#f8f9fa", "#e9ecef", "#ced4da", "#868e96", "#343a40"],
|
||||
red: ["#fff5f5", "#ffc9c9", "#ff8787", "#fa5252", "#e03131"],
|
||||
pink: ["#fff0f6", "#fcc2d7", "#f783ac", "#e64980", "#c2255c"],
|
||||
grape: ["#f8f0fc", "#eebefa", "#da77f2", "#be4bdb", "#9c36b5"],
|
||||
violet: ["#f3f0ff", "#d0bfff", "#9775fa", "#7950f2", "#6741d9"],
|
||||
blue: ["#e7f5ff", "#a5d8ff", "#4dabf7", "#228be6", "#1971c2"],
|
||||
cyan: ["#e3fafc", "#99e9f2", "#3bc9db", "#15aabf", "#0c8599"],
|
||||
teal: ["#e6fcf5", "#96f2d7", "#38d9a9", "#12b886", "#099268"],
|
||||
green: ["#ebfbee", "#b2f2bb", "#69db7c", "#40c057", "#2f9e44"],
|
||||
yellow: ["#fff9db", "#ffec99", "#ffd43b", "#fab005", "#f08c00"],
|
||||
orange: ["#fff4e6", "#ffd8a8", "#ffa94d", "#fd7e14", "#e8590c"],
|
||||
// radix bronze shades [3,5,7,9,11]
|
||||
bronze: ["#f8f1ee", "#eaddd7", "#d2bab0", "#a18072", "#846358"],
|
||||
} as const;
|
||||
|
||||
export type ColorPalette = typeof COLOR_PALETTE;
|
||||
export type ColorPickerColor = keyof typeof COLOR_PALETTE;
|
||||
|
||||
const COMMON_ELEMENT_SHADES = pick(COLOR_PALETTE, [
|
||||
"cyan",
|
||||
"blue",
|
||||
"violet",
|
||||
"grape",
|
||||
"pink",
|
||||
"green",
|
||||
"teal",
|
||||
"yellow",
|
||||
"orange",
|
||||
"red",
|
||||
]);
|
||||
|
||||
// quick picks defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_STROKE_PICKS = [
|
||||
COLOR_PALETTE.black,
|
||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_STROKE_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.transparent,
|
||||
COLOR_PALETTE.red[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.green[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.blue[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
COLOR_PALETTE.yellow[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX],
|
||||
] as ColorTuple;
|
||||
|
||||
// ORDER matters for positioning in quick picker
|
||||
export const DEFAULT_CANVAS_BACKGROUND_PICKS = [
|
||||
COLOR_PALETTE.white,
|
||||
// radix slate2
|
||||
"#f8f9fa",
|
||||
// radix blue2
|
||||
"#f5faff",
|
||||
// radix yellow2
|
||||
"#fffce8",
|
||||
// radix bronze2
|
||||
"#fdf8f6",
|
||||
] as ColorTuple;
|
||||
|
||||
// palette defaults
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_ELEMENT_STROKE_COLOR_PALETTE = {
|
||||
// 1st row
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
white: COLOR_PALETTE.white,
|
||||
gray: COLOR_PALETTE.gray,
|
||||
black: COLOR_PALETTE.black,
|
||||
bronze: COLOR_PALETTE.bronze,
|
||||
// rest
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// ORDER matters for positioning in pallete (5x3 grid)s
|
||||
export const DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE = {
|
||||
transparent: COLOR_PALETTE.transparent,
|
||||
white: COLOR_PALETTE.white,
|
||||
gray: COLOR_PALETTE.gray,
|
||||
black: COLOR_PALETTE.black,
|
||||
bronze: COLOR_PALETTE.bronze,
|
||||
|
||||
...COMMON_ELEMENT_SHADES,
|
||||
} as const;
|
||||
|
||||
// color palette helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// !!!MUST BE WITHOUT GRAY, TRANSPARENT AND BLACK!!!
|
||||
export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
||||
[
|
||||
// 2nd row
|
||||
COLOR_PALETTE.cyan[index],
|
||||
COLOR_PALETTE.blue[index],
|
||||
COLOR_PALETTE.violet[index],
|
||||
COLOR_PALETTE.grape[index],
|
||||
COLOR_PALETTE.pink[index],
|
||||
|
||||
// 3rd row
|
||||
COLOR_PALETTE.green[index],
|
||||
COLOR_PALETTE.teal[index],
|
||||
COLOR_PALETTE.yellow[index],
|
||||
COLOR_PALETTE.orange[index],
|
||||
COLOR_PALETTE.red[index],
|
||||
] as const;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// other helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
|
||||
// then slice(1) removes the leading "1" to get exactly 6 hex digits
|
||||
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
|
||||
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
|
||||
.toString(16)
|
||||
.slice(1)}`;
|
||||
if (a !== undefined && a < 1) {
|
||||
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
|
||||
// e.g. 0.5 -> 128 -> "80"
|
||||
const alphaHex = Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${hex6}${alphaHex}`;
|
||||
}
|
||||
return hex6;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns #RRGGBB or #RRGGBBAA based on color containing non-opaque alpha,
|
||||
* null if not valid color
|
||||
*/
|
||||
export const colorToHex = (color: string): string | null => {
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
return null;
|
||||
}
|
||||
const { r, g, b, a } = tc.toRgb();
|
||||
return rgbToHex(r, g, b, a);
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 0;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// color contract helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
const calculateContrast = (r: number, g: number, b: number): number => {
|
||||
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return yiq;
|
||||
};
|
||||
|
||||
// YIQ algo, inspiration from https://stackoverflow.com/a/11868398
|
||||
export const isColorDark = (color: string, threshold = 160): boolean => {
|
||||
// no color ("") -> assume it default to black
|
||||
if (!color) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTransparent(color)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
if (!tc.isValid()) {
|
||||
// invalid color -> assume it defaults to black
|
||||
return true;
|
||||
}
|
||||
|
||||
const { r, g, b } = tc.toRgb();
|
||||
return calculateContrast(r, g, b) < threshold;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// normalization
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* tries to keep the input color as-is if it's valid, making minimal adjustments
|
||||
* (trimming whitespace or adding `#` to hex colors)
|
||||
*/
|
||||
export const normalizeInputColor = (color: string): string | null => {
|
||||
color = color.trim();
|
||||
if (isTransparent(color)) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
if (tc.isValid()) {
|
||||
// testing for `#` first fixes a bug on Electron (more specfically, an
|
||||
// Obsidian popout window), where a hex color without `#` is considered valid
|
||||
if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) {
|
||||
return `#${color}`;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,222 +0,0 @@
|
||||
export type StylesPanelMode = "compact" | "full" | "mobile";
|
||||
|
||||
export type EditorInterface = Readonly<{
|
||||
formFactor: "phone" | "tablet" | "desktop";
|
||||
desktopUIMode: "compact" | "full";
|
||||
userAgent: Readonly<{
|
||||
isMobileDevice: boolean;
|
||||
platform: "ios" | "android" | "other" | "unknown";
|
||||
}>;
|
||||
isTouchScreen: boolean;
|
||||
canFitSidebar: boolean;
|
||||
isLandscape: boolean;
|
||||
}>;
|
||||
|
||||
// storage key
|
||||
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
|
||||
|
||||
// breakpoints
|
||||
export const MQ_MAX_MOBILE = 599;
|
||||
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1180; // ipad air
|
||||
|
||||
// desktop/laptop (NOTE: not used for form factor detection)
|
||||
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// user agent detections
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
// export const isMobile =
|
||||
// isIOS ||
|
||||
// /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
// navigator.userAgent,
|
||||
// ) ||
|
||||
// /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||
|
||||
// utilities
|
||||
export const isMobileBreakpoint = (width: number, height: number) => {
|
||||
return (
|
||||
width <= MQ_MAX_MOBILE ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||
);
|
||||
};
|
||||
|
||||
export const isTabletBreakpoint = (
|
||||
editorWidth: number,
|
||||
editorHeight: number,
|
||||
) => {
|
||||
const minSide = Math.min(editorWidth, editorHeight);
|
||||
const maxSide = Math.max(editorWidth, editorHeight);
|
||||
|
||||
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||
};
|
||||
|
||||
const isMobileOrTablet = (): boolean => {
|
||||
const ua = navigator.userAgent || "";
|
||||
const platform = navigator.platform || "";
|
||||
const uaData = (navigator as any).userAgentData as
|
||||
| { mobile?: boolean; platform?: string }
|
||||
| undefined;
|
||||
|
||||
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||
if (uaData) {
|
||||
const plat = (uaData.platform || "").toLowerCase();
|
||||
const isDesktopOS =
|
||||
plat === "windows" ||
|
||||
plat === "macos" ||
|
||||
plat === "linux" ||
|
||||
plat === "chrome os";
|
||||
if (uaData.mobile === true) {
|
||||
return true;
|
||||
}
|
||||
if (uaData.mobile === false && plat === "android") {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
if (isDesktopOS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) ios (includes ipad) --------------------------------------------
|
||||
if (isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 3) android legacy ua fallback -------------------------------------
|
||||
if (isAndroid) {
|
||||
const isAndroidPhone = /Mobile/i.test(ua);
|
||||
const isAndroidTablet = !isAndroidPhone;
|
||||
if (isAndroidPhone || isAndroidTablet) {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4) last resort desktop exclusion ----------------------------------
|
||||
const looksDesktopPlatform =
|
||||
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||
if (looksDesktopPlatform) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFormFactor = (
|
||||
editorWidth: number,
|
||||
editorHeight: number,
|
||||
): EditorInterface["formFactor"] => {
|
||||
if (isMobileBreakpoint(editorWidth, editorHeight)) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
if (isTabletBreakpoint(editorWidth, editorHeight)) {
|
||||
return "tablet";
|
||||
}
|
||||
|
||||
return "desktop";
|
||||
};
|
||||
|
||||
export const deriveStylesPanelMode = (
|
||||
editorInterface: EditorInterface,
|
||||
): StylesPanelMode => {
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
if (editorInterface.formFactor === "tablet") {
|
||||
return "compact";
|
||||
}
|
||||
|
||||
return editorInterface.desktopUIMode;
|
||||
};
|
||||
|
||||
export const createUserAgentDescriptor = (
|
||||
userAgentString: string,
|
||||
): EditorInterface["userAgent"] => {
|
||||
const normalizedUA = userAgentString ?? "";
|
||||
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
|
||||
|
||||
if (isIOS) {
|
||||
platform = "ios";
|
||||
} else if (isAndroid) {
|
||||
platform = "android";
|
||||
} else if (normalizedUA) {
|
||||
platform = "other";
|
||||
}
|
||||
|
||||
return {
|
||||
isMobileDevice: isMobileOrTablet(),
|
||||
platform,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const loadDesktopUIModePreference = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
|
||||
if (stored === "compact" || stored === "full") {
|
||||
return stored as EditorInterface["desktopUIMode"];
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore storage access issues (e.g., Safari private mode)
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
|
||||
} catch (error) {
|
||||
// ignore storage access issues (e.g., Safari private mode)
|
||||
}
|
||||
};
|
||||
|
||||
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||
if (mode !== "compact" && mode !== "full") {
|
||||
return;
|
||||
}
|
||||
|
||||
persistDesktopUIMode(mode);
|
||||
|
||||
return mode;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
export * from "./binary-heap";
|
||||
export * from "./bounds";
|
||||
export * from "./colors";
|
||||
export * from "./constants";
|
||||
export * from "./font-metadata";
|
||||
export * from "./queue";
|
||||
export * from "./keys";
|
||||
export * from "./points";
|
||||
export * from "./promise-pool";
|
||||
export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
export * from "./editorInterface";
|
||||
@@ -1,50 +0,0 @@
|
||||
import Pool from "es6-promise-pool";
|
||||
|
||||
// extending the missing types
|
||||
// relying on the [Index, T] to keep a correct order
|
||||
type TPromisePool<T, Index = number> = Pool<[Index, T][]> & {
|
||||
addEventListener: (
|
||||
type: "fulfilled",
|
||||
listener: (event: { data: { result: [Index, T] } }) => void,
|
||||
) => (event: { data: { result: [Index, T] } }) => void;
|
||||
removeEventListener: (
|
||||
type: "fulfilled",
|
||||
listener: (event: { data: { result: [Index, T] } }) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export class PromisePool<T> {
|
||||
private readonly pool: TPromisePool<T>;
|
||||
private readonly entries: Record<number, T> = {};
|
||||
|
||||
constructor(
|
||||
source: IterableIterator<Promise<void | readonly [number, T]>>,
|
||||
concurrency: number,
|
||||
) {
|
||||
this.pool = new Pool(
|
||||
source as unknown as () => void | PromiseLike<[number, T][]>,
|
||||
concurrency,
|
||||
) as TPromisePool<T>;
|
||||
}
|
||||
|
||||
public all() {
|
||||
const listener = (event: { data: { result: void | [number, T] } }) => {
|
||||
if (event.data.result) {
|
||||
// by default pool does not return the results, so we are gathering them manually
|
||||
// with the correct call order (represented by the index in the tuple)
|
||||
const [index, value] = event.data.result;
|
||||
this.entries[index] = value;
|
||||
}
|
||||
};
|
||||
|
||||
this.pool.addEventListener("fulfilled", listener);
|
||||
|
||||
return this.pool.start().then(() => {
|
||||
setTimeout(() => {
|
||||
this.pool.removeEventListener("fulfilled", listener);
|
||||
});
|
||||
|
||||
return Object.values(this.entries);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
isTransparent,
|
||||
mapFind,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("@excalidraw/common/utils", () => {
|
||||
describe("isTransparent()", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
expect(isTransparent("#fff00000")).toEqual(true);
|
||||
expect(isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceToCommonValue()", () => {
|
||||
it("should return the common value when all values are the same", () => {
|
||||
expect(reduceToCommonValue([1, 1])).toEqual(1);
|
||||
expect(reduceToCommonValue([0, 0])).toEqual(0);
|
||||
expect(reduceToCommonValue(["a", "a"])).toEqual("a");
|
||||
expect(reduceToCommonValue(new Set([1]))).toEqual(1);
|
||||
expect(reduceToCommonValue([""])).toEqual("");
|
||||
expect(reduceToCommonValue([0])).toEqual(0);
|
||||
|
||||
const o = {};
|
||||
expect(reduceToCommonValue([o, o])).toEqual(o);
|
||||
|
||||
expect(
|
||||
reduceToCommonValue([{ a: 1 }, { a: 1, b: 2 }], (o) => o.a),
|
||||
).toEqual(1);
|
||||
expect(
|
||||
reduceToCommonValue(new Set([{ a: 1 }, { a: 1, b: 2 }]), (o) => o.a),
|
||||
).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return `null` when values are different", () => {
|
||||
expect(reduceToCommonValue([1, 2, 3])).toEqual(null);
|
||||
expect(reduceToCommonValue(new Set([1, 2]))).toEqual(null);
|
||||
expect(reduceToCommonValue([{ a: 1 }, { a: 2 }], (o) => o.a)).toEqual(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return `null` when some values are nullable", () => {
|
||||
expect(reduceToCommonValue([1, null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([1, undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined, 1])).toEqual(null);
|
||||
expect(reduceToCommonValue([null])).toEqual(null);
|
||||
expect(reduceToCommonValue([undefined])).toEqual(null);
|
||||
expect(reduceToCommonValue([])).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapFind()", () => {
|
||||
it("should return the first mapped non-null element", () => {
|
||||
{
|
||||
let counter = 0;
|
||||
|
||||
const result = mapFind(["a", "b", "c"], (value) => {
|
||||
counter++;
|
||||
return value === "b" ? 42 : null;
|
||||
});
|
||||
expect(result).toEqual(42);
|
||||
expect(counter).toBe(2);
|
||||
}
|
||||
|
||||
expect(mapFind([1, 2], (value) => value * 0)).toBe(0);
|
||||
expect(mapFind([1, 2], () => false)).toBe(false);
|
||||
expect(mapFind([1, 2], () => "")).toBe("");
|
||||
});
|
||||
|
||||
it("should return undefined if no mapped element is found", () => {
|
||||
expect(mapFind([1, 2], () => undefined)).toBe(undefined);
|
||||
expect(mapFind([1, 2], () => null)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user