Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ec2889ba |
@@ -1,5 +1,3 @@
|
|||||||
MODE="development"
|
|
||||||
|
|
||||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
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/
|
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
@@ -50,6 +48,3 @@ UNWEjuqNMi/lwAErS9fFa2oJlWyT8U7zzv/5kQREkxZI6y9v0AF3qcbsy2731FnD
|
|||||||
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
|
s9ChJvOUW9toIab2gsIdrKW8ZNpu084ZFVKb6LNjvIXI1Se4oMTHeszXzNptzlot
|
||||||
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
|
kdxxjOoaQMAyfljFSot1F1FlU6MQlag7UnFGvFjRHN1JI5q4K+n3a67DX+TMyRqS
|
||||||
HQIDAQAB'
|
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_GET_URL=https://json.excalidraw.com/api/v2/
|
||||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": ["@excalidraw/eslint-config", "react-app"],
|
"extends": ["@excalidraw/eslint-config", "react-app"],
|
||||||
"rules": {
|
"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",
|
"import/no-anonymous-default-export": "off",
|
||||||
"no-restricted-globals": "off",
|
"no-restricted-globals": "off",
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
@@ -32,12 +17,6 @@
|
|||||||
"name": "jotai",
|
"name": "jotai",
|
||||||
"message": "Do not import from \"jotai\" directly. Use our app-specific modules (\"editor-jotai\" or \"app-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}
|
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core -W
|
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 }}"
|
||||||
@@ -17,14 +17,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
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
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: excalidraw/excalidraw:latest
|
tags: excalidraw/excalidraw:latest
|
||||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
|
||||||
|
|||||||
+1
-2
@@ -25,5 +25,4 @@ packages/excalidraw/types
|
|||||||
coverage
|
coverage
|
||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
meta*.json
|
meta*.json
|
||||||
.claude
|
|
||||||
@@ -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
|
WORKDIR /opt/node_app
|
||||||
|
|
||||||
@@ -6,14 +6,13 @@ COPY . .
|
|||||||
|
|
||||||
# do not ignore optional dependencies:
|
# do not ignore optional dependencies:
|
||||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
RUN yarn --network-timeout 600000
|
||||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
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
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,6 @@
|
|||||||
<a href="https://discord.gg/UexuTaE">
|
<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"/>
|
<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>
|
||||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
|
||||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
|
|
||||||
</a>
|
|
||||||
<a href="https://twitter.com/excalidraw">
|
<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"/>
|
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||||
</a>
|
</a>
|
||||||
@@ -66,7 +63,7 @@ The Excalidraw editor (npm package) supports:
|
|||||||
- 🏗️ Customizable.
|
- 🏗️ Customizable.
|
||||||
- 📷 Image support.
|
- 📷 Image support.
|
||||||
- 😀 Shape libraries support.
|
- 😀 Shape libraries support.
|
||||||
- 🌐 Localization (i18n) support.
|
- 👅 Localization (i18n) support.
|
||||||
- 🖼️ Export to PNG, SVG & clipboard.
|
- 🖼️ Export to PNG, SVG & clipboard.
|
||||||
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
- 💾 Open format - export drawings as an `.excalidraw` json file.
|
||||||
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
|
- ⚒️ 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.
|
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**
|
**Usage**
|
||||||
|
|
||||||
```jsx live
|
```jsx live
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: "500px" }}>
|
<div style={{ height: "500px"}}>
|
||||||
<Excalidraw>
|
<Excalidraw>
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<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.
|
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
||||||
|
|
||||||
```jsx live noInline
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const editorInterface = useEditorInterface();
|
const device = useDevice();
|
||||||
if (editorInterface.formFactor === "phone") {
|
if (device.editor.isMobile) {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
className="custom-footer"
|
className="custom-footer"
|
||||||
style={{ marginLeft: "20px", height: "2rem" }}
|
style= {{ marginLeft: '20px', height: '2rem'}}
|
||||||
onClick={() => alert("This is custom footer in mobile menu")}
|
onClick={() => alert("This is custom footer in mobile menu")}
|
||||||
>
|
>
|
||||||
custom footer
|
custom footer
|
||||||
@@ -65,4 +65,4 @@ const App = () => (
|
|||||||
// Need to render when code is span across multiple components
|
// Need to render when code is span across multiple components
|
||||||
// in Live Code blocks editor
|
// in Live Code blocks editor
|
||||||
render(<App />);
|
render(<App />);
|
||||||
```
|
```
|
||||||
@@ -363,7 +363,13 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
```ts
|
```ts
|
||||||
(
|
(
|
||||||
tool: (
|
tool: (
|
||||||
| { type: ToolType }
|
| (
|
||||||
|
| { type: Exclude<ToolType, "image"> }
|
||||||
|
| {
|
||||||
|
type: Extract<ToolType, "image">;
|
||||||
|
insertOnCanvasDirectly?: boolean;
|
||||||
|
}
|
||||||
|
)
|
||||||
| { type: "custom"; customType: string }
|
| { type: "custom"; customType: string }
|
||||||
) & { locked?: boolean },
|
) & { 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 |
|
| 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 |
|
| `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
|
## setCursor
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
All `props` are _optional_.
|
All `props` are _optional_.
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| 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. |
|
| [`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 |
|
| [`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 |
|
| [`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. |
|
| [`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 |
|
| [`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. |
|
| [`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. |
|
| [`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 |
|
| [`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 |
|
| [`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. |
|
| [`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 |
|
| [`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 |
|
| [`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>` |
|
| [`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
|
### 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}
|
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||||
</pre>
|
</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.
|
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
|
```jsx live noInline
|
||||||
const MobileFooter = ({}) => {
|
const MobileFooter = ({}) => {
|
||||||
const editorInterface = useEditorInterface();
|
const device = useDevice();
|
||||||
if (editorInterface.formFactor === "phone") {
|
if (device.editor.isMobile) {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<button
|
<button
|
||||||
@@ -336,20 +336,12 @@ render(<App />);
|
|||||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||||
|
|
||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
| ---- | ---- | ----------- |
|
| --- | --- | --- |
|
||||||
|
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
|
||||||
The `EditorInterface` object has the following properties:
|
| `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` |
|
||||||
| Name | Type | Description |
|
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
|
||||||
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
|
|
||||||
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
|
|
||||||
| `userAgent.raw` | `string` | Raw user agent string |
|
|
||||||
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
|
|
||||||
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
|
|
||||||
| `isTouchScreen` | `boolean` | True if touch events are detected |
|
|
||||||
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
|
|
||||||
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
|
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,32 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
|||||||
|
|
||||||
## Releasing
|
## 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
|
### Creating a production release
|
||||||
|
|
||||||
To release the next stable version follow the below steps:
|
To release the next stable version follow the below steps:
|
||||||
|
|
||||||
```bash
|
```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
|
```jsx showLineNumbers
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import "@excalidraw/excalidraw/index.css";
|
|
||||||
|
|
||||||
const Excalidraw = dynamic(
|
const Excalidraw = dynamic(
|
||||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
const FeatureList = [
|
const FeatureList = [
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
type FeatureItem = {
|
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 Link from "@docusaurus/Link";
|
||||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
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 styles from "./index.module.css";
|
||||||
|
import HomepageFeatures from "@site/src/components/Homepage";
|
||||||
|
|
||||||
function HomepageHeader() {
|
function HomepageHeader() {
|
||||||
const { siteConfig } = useDocusaurusContext();
|
const { siteConfig } = useDocusaurusContext();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Import the original mapper
|
// Import the original mapper
|
||||||
import Highlight from "@site/src/components/Highlight";
|
|
||||||
import MDXComponents from "@theme-original/MDXComponents";
|
import MDXComponents from "@theme-original/MDXComponents";
|
||||||
|
import Highlight from "@site/src/components/Highlight";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// Re-use the default mapping
|
// Re-use the default mapping
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const ExcalidrawScope = {
|
|||||||
initialData,
|
initialData,
|
||||||
useI18n: ExcalidrawComp.useI18n,
|
useI18n: ExcalidrawComp.useI18n,
|
||||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExcalidrawScope;
|
export default ExcalidrawScope;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
excalidraw:
|
excalidraw:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:packages": "yarn --cwd ../../ build:packages",
|
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||||
"build:workspace": "yarn build:packages && yarn copy:assets",
|
|
||||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||||
"dev": "yarn build:workspace && next dev -p 3005",
|
"dev": "yarn build:workspace && next dev -p 3005",
|
||||||
"build": "yarn build:workspace && next build",
|
"build": "yarn build:workspace && next build",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
|
|
||||||
import "../common.scss";
|
import "../common.scss";
|
||||||
|
|
||||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import * as excalidrawLib from "@excalidraw/excalidraw";
|
import * as excalidrawLib from "@excalidraw/excalidraw";
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import App from "../../with-script-in-browser/components/ExampleApp";
|
||||||
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
import App from "../../with-script-in-browser/components/ExampleApp";
|
|
||||||
|
|
||||||
const ExcalidrawWrapper: React.FC = () => {
|
const ExcalidrawWrapper: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
import "../common.scss";
|
import "../common.scss";
|
||||||
|
|
||||||
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excalidraw .selected-shape-actions {
|
.excalidraw .panelColumn {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { nanoid } from "nanoid";
|
|
||||||
import React, {
|
import React, {
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
@@ -7,24 +6,13 @@ import React, {
|
|||||||
Children,
|
Children,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import ExampleSidebar from "./sidebar/ExampleSidebar";
|
||||||
|
|
||||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
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 {
|
import {
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
distance2d,
|
distance2d,
|
||||||
@@ -35,12 +23,25 @@ import {
|
|||||||
|
|
||||||
import CustomFooter from "./CustomFooter";
|
import CustomFooter from "./CustomFooter";
|
||||||
import MobileFooter from "./MobileFooter";
|
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 "./ExampleApp.scss";
|
||||||
|
|
||||||
import type { ResolvablePromise } from "../utils";
|
|
||||||
|
|
||||||
type Comment = {
|
type Comment = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -104,7 +105,6 @@ export default function ExampleApp({
|
|||||||
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
||||||
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
||||||
const [gridModeEnabled, setGridModeEnabled] = useState(false);
|
const [gridModeEnabled, setGridModeEnabled] = useState(false);
|
||||||
const [renderScrollbars, setRenderScrollbars] = useState(false);
|
|
||||||
const [blobUrl, setBlobUrl] = useState<string>("");
|
const [blobUrl, setBlobUrl] = useState<string>("");
|
||||||
const [canvasUrl, setCanvasUrl] = useState<string>("");
|
const [canvasUrl, setCanvasUrl] = useState<string>("");
|
||||||
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
|
const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
|
||||||
@@ -193,7 +193,6 @@ export default function ExampleApp({
|
|||||||
}) => setPointerData(payload),
|
}) => setPointerData(payload),
|
||||||
viewModeEnabled,
|
viewModeEnabled,
|
||||||
zenModeEnabled,
|
zenModeEnabled,
|
||||||
renderScrollbars,
|
|
||||||
gridModeEnabled,
|
gridModeEnabled,
|
||||||
theme,
|
theme,
|
||||||
name: "Custom name of drawing",
|
name: "Custom name of drawing",
|
||||||
@@ -712,14 +711,6 @@ export default function ExampleApp({
|
|||||||
/>
|
/>
|
||||||
Grid mode
|
Grid mode
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={renderScrollbars}
|
|
||||||
onChange={() => setRenderScrollbars(!renderScrollbars)}
|
|
||||||
/>
|
|
||||||
Render scrollbars
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import CustomFooter from "./CustomFooter";
|
import CustomFooter from "./CustomFooter";
|
||||||
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
const MobileFooter = ({
|
const MobileFooter = ({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
@@ -12,10 +10,10 @@ const MobileFooter = ({
|
|||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||||
excalidrawLib: typeof TExcalidraw;
|
excalidrawLib: typeof TExcalidraw;
|
||||||
}) => {
|
}) => {
|
||||||
const { useEditorInterface, Footer } = excalidrawLib;
|
const { useDevice, Footer } = excalidrawLib;
|
||||||
|
|
||||||
const editorInterface = useEditorInterface();
|
const device = useDevice();
|
||||||
if (editorInterface.formFactor === "phone") {
|
if (device.editor.isMobile) {
|
||||||
return (
|
return (
|
||||||
<Footer>
|
<Footer>
|
||||||
<CustomFooter
|
<CustomFooter
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import "./ExampleSidebar.scss";
|
import "./ExampleSidebar.scss";
|
||||||
|
|
||||||
export default function Sidebar({ children }: { children: React.ReactNode }) {
|
export default function Sidebar({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import App from "./components/ExampleApp";
|
||||||
import React, { StrictMode } from "react";
|
import React, { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
|
||||||
|
|
||||||
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
import type * as TExcalidraw from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
import App from "./components/ExampleApp";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -15,8 +15,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5002",
|
"build:preview": "yarn build && vite preview --port 5002",
|
||||||
"build:preview": "yarn build && yarn preview",
|
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
||||||
"build:packages": "yarn --cwd ../../ build:packages"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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">;
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"outputDirectory": "dist",
|
"outputDirectory": "dist",
|
||||||
"installCommand": "yarn install",
|
"installCommand": "yarn install",
|
||||||
"buildCommand": "yarn build:packages && yarn build"
|
"buildCommand": "yarn build:package && yarn build"
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-475
@@ -1,28 +1,40 @@
|
|||||||
|
import polyfill from "@excalidraw/excalidraw/polyfill";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||||
|
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||||
|
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
||||||
|
import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
||||||
|
import {
|
||||||
|
APP_NAME,
|
||||||
|
EVENT,
|
||||||
|
THEME,
|
||||||
|
TITLE_TIMEOUT,
|
||||||
|
VERSION_TIMEOUT,
|
||||||
|
} from "@excalidraw/excalidraw/constants";
|
||||||
|
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
||||||
|
import type {
|
||||||
|
FileId,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
} from "@excalidraw/excalidraw/element/types";
|
||||||
|
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
||||||
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
LiveCollaborationTrigger,
|
LiveCollaborationTrigger,
|
||||||
TTDDialogTrigger,
|
TTDDialogTrigger,
|
||||||
CaptureUpdateAction,
|
CaptureUpdateAction,
|
||||||
reconcileElements,
|
reconcileElements,
|
||||||
getCommonBounds,
|
|
||||||
useEditorInterface,
|
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import type {
|
||||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
AppState,
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
BinaryFiles,
|
||||||
|
ExcalidrawInitialDataState,
|
||||||
|
UIAppState,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import type { ResolvablePromise } from "@excalidraw/excalidraw/utils";
|
||||||
import {
|
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,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
getFrame,
|
getFrame,
|
||||||
@@ -30,14 +42,75 @@ import {
|
|||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
isRunningInIframe,
|
isRunningInIframe,
|
||||||
isDevEnv,
|
} from "@excalidraw/excalidraw/utils";
|
||||||
} from "@excalidraw/common";
|
import {
|
||||||
import polyfill from "@excalidraw/excalidraw/polyfill";
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
isExcalidrawPlusSignedUser,
|
||||||
import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
|
STORAGE_KEYS,
|
||||||
import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
} 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 {
|
import {
|
||||||
GithubIcon,
|
GithubIcon,
|
||||||
XBrandIcon,
|
XBrandIcon,
|
||||||
@@ -48,97 +121,6 @@ import {
|
|||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
import { isElementLink } from "@excalidraw/element";
|
|
||||||
import { restore, restoreAppState } 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 { getSelectedElements } from "@excalidraw/element/selection";
|
|
||||||
|
|
||||||
import {
|
|
||||||
decodeConstraints,
|
|
||||||
encodeConstraints,
|
|
||||||
} from "@excalidraw/excalidraw/scene/scrollConstraints";
|
|
||||||
|
|
||||||
import { useApp } from "@excalidraw/excalidraw/components/App";
|
|
||||||
|
|
||||||
import { clamp } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
|
||||||
import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
FileId,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
OrderedExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
import type {
|
|
||||||
AppState,
|
|
||||||
ExcalidrawImperativeAPI,
|
|
||||||
BinaryFiles,
|
|
||||||
ExcalidrawInitialDataState,
|
|
||||||
UIAppState,
|
|
||||||
ScrollConstraints,
|
|
||||||
} from "@excalidraw/excalidraw/types";
|
|
||||||
import type { Merge, 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,
|
|
||||||
isCollaborationLink,
|
|
||||||
loadScene,
|
|
||||||
} 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 { useHandleAppTheme } from "./useHandleAppTheme";
|
||||||
import { getPreferredLanguage } from "./app-language/language-detector";
|
import { getPreferredLanguage } from "./app-language/language-detector";
|
||||||
import { useAppLangCode } from "./app-language/language-state";
|
import { useAppLangCode } from "./app-language/language-state";
|
||||||
@@ -149,284 +131,10 @@ import DebugCanvas, {
|
|||||||
} from "./components/DebugCanvas";
|
} from "./components/DebugCanvas";
|
||||||
import { AIComponents } from "./components/AI";
|
import { AIComponents } from "./components/AI";
|
||||||
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||||
|
import { isElementLink } from "@excalidraw/excalidraw/element/elementLink";
|
||||||
import "./index.scss";
|
|
||||||
|
|
||||||
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
|
|
||||||
import { AppSidebar } from "./components/AppSidebar";
|
|
||||||
|
|
||||||
import type { CollabAPI } from "./collab/Collab";
|
|
||||||
|
|
||||||
polyfill();
|
polyfill();
|
||||||
|
|
||||||
type DebugScrollConstraints = Merge<
|
|
||||||
ScrollConstraints,
|
|
||||||
{ viewportZoomFactor: number; enabled: boolean }
|
|
||||||
>;
|
|
||||||
|
|
||||||
const ConstraintsSettings = ({
|
|
||||||
initialConstraints,
|
|
||||||
excalidrawAPI,
|
|
||||||
}: {
|
|
||||||
initialConstraints: DebugScrollConstraints;
|
|
||||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
|
||||||
}) => {
|
|
||||||
const [constraints, setConstraints] =
|
|
||||||
useState<DebugScrollConstraints>(initialConstraints);
|
|
||||||
|
|
||||||
const app = useApp();
|
|
||||||
const frames = app.scene.getNonDeletedFramesLikes();
|
|
||||||
const [activeFrameId, setActiveFrameId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
params.set("constraints", encodeConstraints(constraints));
|
|
||||||
history.replaceState(null, "", `?${params.toString()}`);
|
|
||||||
|
|
||||||
constraints.enabled
|
|
||||||
? excalidrawAPI.setScrollConstraints(constraints)
|
|
||||||
: excalidrawAPI.setScrollConstraints(null);
|
|
||||||
}, [constraints, excalidrawAPI]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const frame = frames.find((frame) => frame.id === activeFrameId);
|
|
||||||
if (frame) {
|
|
||||||
const { x, y, width, height } = frame;
|
|
||||||
setConstraints((s) => ({
|
|
||||||
x: Math.round(x),
|
|
||||||
y: Math.round(y),
|
|
||||||
width: Math.round(width),
|
|
||||||
height: Math.round(height),
|
|
||||||
enabled: s.enabled,
|
|
||||||
viewportZoomFactor: s.viewportZoomFactor,
|
|
||||||
lockZoom: s.lockZoom,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [activeFrameId, frames]);
|
|
||||||
|
|
||||||
const [selection, setSelection] = useState<ExcalidrawElement[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
return excalidrawAPI.onChange((elements, appState) => {
|
|
||||||
setSelection(getSelectedElements(elements, appState));
|
|
||||||
});
|
|
||||||
}, [excalidrawAPI]);
|
|
||||||
|
|
||||||
const parseValue = (
|
|
||||||
value: string,
|
|
||||||
opts?: {
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const { min = -Infinity, max = Infinity } = opts || {};
|
|
||||||
let parsedValue = parseInt(value);
|
|
||||||
if (isNaN(parsedValue)) {
|
|
||||||
parsedValue = 0;
|
|
||||||
}
|
|
||||||
return clamp(parsedValue, min, max);
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
width: "4rem",
|
|
||||||
height: "1rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
bottom: 10,
|
|
||||||
left: "calc(50%)",
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
zIndex: 999999,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.6rem",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
enabled:{" "}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={!!constraints.enabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({ ...s, enabled: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
x:{" "}
|
|
||||||
<input
|
|
||||||
placeholder="x"
|
|
||||||
type="number"
|
|
||||||
step={"10"}
|
|
||||||
value={constraints.x.toString()}
|
|
||||||
onChange={(e) => {
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
x: parseValue(e.target.value),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
y:{" "}
|
|
||||||
<input
|
|
||||||
placeholder="y"
|
|
||||||
type="number"
|
|
||||||
step={"10"}
|
|
||||||
value={constraints.y.toString()}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
y: parseValue(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
w:{" "}
|
|
||||||
<input
|
|
||||||
placeholder="width"
|
|
||||||
type="number"
|
|
||||||
step={"10"}
|
|
||||||
value={constraints.width.toString()}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
width: parseValue(e.target.value, {
|
|
||||||
min: 200,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
h:{" "}
|
|
||||||
<input
|
|
||||||
placeholder="height"
|
|
||||||
type="number"
|
|
||||||
step={"10"}
|
|
||||||
value={constraints.height.toString()}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
height: parseValue(e.target.value, {
|
|
||||||
min: 200,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
zoomFactor:
|
|
||||||
<input
|
|
||||||
placeholder="zoom factor"
|
|
||||||
type="number"
|
|
||||||
min="0.1"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={constraints.viewportZoomFactor.toString()}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
viewportZoomFactor: parseFloat(e.target.value.toString()) ?? 0.7,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
overscrollAllowance:
|
|
||||||
<input
|
|
||||||
placeholder="overscroll allowance"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={constraints.overscrollAllowance?.toString()}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
overscrollAllowance: parseFloat(e.target.value.toString()) ?? 0.5,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
lockZoom:{" "}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
defaultChecked={!!constraints.lockZoom}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConstraints((s) => ({ ...s, lockZoom: e.target.checked }))
|
|
||||||
}
|
|
||||||
value={constraints.lockZoom?.toString()}
|
|
||||||
/>
|
|
||||||
{selection.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const bbox = getCommonBounds(selection);
|
|
||||||
setConstraints((s) => ({
|
|
||||||
...s,
|
|
||||||
x: Math.round(bbox[0]),
|
|
||||||
y: Math.round(bbox[1]),
|
|
||||||
width: Math.round(bbox[2] - bbox[0]),
|
|
||||||
height: Math.round(bbox[3] - bbox[1]),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
use selection
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{frames.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "0.6rem",
|
|
||||||
flexDirection: "row",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const currentIndex = frames.findIndex(
|
|
||||||
(frame) => frame.id === activeFrameId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
|
||||||
setActiveFrameId(frames[frames.length - 1].id);
|
|
||||||
} else {
|
|
||||||
const nextIndex =
|
|
||||||
(currentIndex - 1 + frames.length) % frames.length;
|
|
||||||
setActiveFrameId(frames[nextIndex].id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const currentIndex = frames.findIndex(
|
|
||||||
(frame) => frame.id === activeFrameId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentIndex === -1) {
|
|
||||||
setActiveFrameId(frames[0].id);
|
|
||||||
} else {
|
|
||||||
const nextIndex = (currentIndex + 1) % frames.length;
|
|
||||||
setActiveFrameId(frames[nextIndex].id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -498,20 +206,10 @@ const initializeScene = async (opts: {
|
|||||||
)
|
)
|
||||||
> => {
|
> => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const hashParams = new URLSearchParams(window.location.hash.slice(1));
|
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const shareableLink = hashParams.get("json")?.split(",");
|
const jsonBackendMatch = window.location.hash.match(
|
||||||
|
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
|
||||||
if (shareableLink) {
|
);
|
||||||
hashParams.delete("json");
|
|
||||||
const hash = `#${decodeURIComponent(hashParams.toString())}`;
|
|
||||||
window.history.replaceState(
|
|
||||||
{},
|
|
||||||
APP_NAME,
|
|
||||||
`${window.location.origin}${hash}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
||||||
|
|
||||||
const localDataState = importFromLocalStorage();
|
const localDataState = importFromLocalStorage();
|
||||||
@@ -521,7 +219,7 @@ const initializeScene = async (opts: {
|
|||||||
} = await loadScene(null, null, localDataState);
|
} = await loadScene(null, null, localDataState);
|
||||||
|
|
||||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||||
const isExternalScene = !!(id || shareableLink || roomLinkData);
|
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||||
if (isExternalScene) {
|
if (isExternalScene) {
|
||||||
if (
|
if (
|
||||||
// don't prompt if scene is empty
|
// don't prompt if scene is empty
|
||||||
@@ -531,16 +229,16 @@ const initializeScene = async (opts: {
|
|||||||
// otherwise, prompt whether user wants to override current scene
|
// otherwise, prompt whether user wants to override current scene
|
||||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||||
) {
|
) {
|
||||||
if (shareableLink) {
|
if (jsonBackendMatch) {
|
||||||
scene = await loadScene(
|
scene = await loadScene(
|
||||||
shareableLink[0],
|
jsonBackendMatch[1],
|
||||||
shareableLink[1],
|
jsonBackendMatch[2],
|
||||||
localDataState,
|
localDataState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
scene.scrollToContent = true;
|
scene.scrollToContent = true;
|
||||||
if (!roomLinkData) {
|
if (!roomLinkData) {
|
||||||
// window.history.replaceState({}, APP_NAME, window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||||
@@ -557,7 +255,7 @@ const initializeScene = async (opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
roomLinkData = null;
|
roomLinkData = null;
|
||||||
// window.history.replaceState({}, APP_NAME, window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
}
|
}
|
||||||
} else if (externalUrlMatch) {
|
} else if (externalUrlMatch) {
|
||||||
window.history.replaceState({}, APP_NAME, window.location.origin);
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
||||||
@@ -618,12 +316,12 @@ const initializeScene = async (opts: {
|
|||||||
key: roomLinkData.roomKey,
|
key: roomLinkData.roomKey,
|
||||||
};
|
};
|
||||||
} else if (scene) {
|
} else if (scene) {
|
||||||
return isExternalScene && shareableLink
|
return isExternalScene && jsonBackendMatch
|
||||||
? {
|
? {
|
||||||
scene,
|
scene,
|
||||||
isExternalScene,
|
isExternalScene,
|
||||||
id: shareableLink[0],
|
id: jsonBackendMatch[1],
|
||||||
key: shareableLink[1],
|
key: jsonBackendMatch[2],
|
||||||
}
|
}
|
||||||
: { scene, isExternalScene: false };
|
: { scene, isExternalScene: false };
|
||||||
}
|
}
|
||||||
@@ -638,8 +336,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const [langCode, setLangCode] = useAppLangCode();
|
const [langCode, setLangCode] = useAppLangCode();
|
||||||
|
|
||||||
const editorInterface = useEditorInterface();
|
|
||||||
|
|
||||||
// initial state
|
// initial state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -681,7 +377,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
const [, forceRefresh] = useState(false);
|
const [, forceRefresh] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDevEnv()) {
|
if (import.meta.env.DEV) {
|
||||||
const debugState = loadSavedDebugState();
|
const debugState = loadSavedDebugState();
|
||||||
|
|
||||||
if (debugState.enabled && !window.visualDebug) {
|
if (debugState.enabled && !window.visualDebug) {
|
||||||
@@ -797,6 +493,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleTimeout = setTimeout(
|
||||||
|
() => (document.title = APP_NAME),
|
||||||
|
TITLE_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@@ -887,6 +588,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
clearTimeout(titleTimeout);
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
@@ -900,13 +602,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
excalidrawAPI.getSceneElements(),
|
excalidrawAPI.getSceneElements(),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
|
preventUnload(event);
|
||||||
preventUnload(event);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
||||||
@@ -961,8 +657,8 @@ const ExcalidrawWrapper = () => {
|
|||||||
debugRenderer(
|
debugRenderer(
|
||||||
debugCanvasRef.current,
|
debugCanvasRef.current,
|
||||||
appState,
|
appState,
|
||||||
elements,
|
|
||||||
window.devicePixelRatio,
|
window.devicePixelRatio,
|
||||||
|
() => forceRefresh((prev) => !prev),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1026,39 +722,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
const isOffline = useAtomValue(isOfflineAtom);
|
const isOffline = useAtomValue(isOfflineAtom);
|
||||||
|
|
||||||
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
|
||||||
|
|
||||||
const onCollabDialogOpen = useCallback(
|
const onCollabDialogOpen = useCallback(
|
||||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||||
[setShareDialogState],
|
[setShareDialogState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [constraints] = useState<DebugScrollConstraints>(() => {
|
|
||||||
const stored = new URLSearchParams(location.search.slice(1)).get(
|
|
||||||
"constraints",
|
|
||||||
);
|
|
||||||
let storedConstraints = {};
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
storedConstraints = decodeConstraints(stored);
|
|
||||||
} catch {
|
|
||||||
console.error("Invalid scroll constraints in URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: document.body.clientWidth,
|
|
||||||
height: document.body.clientHeight,
|
|
||||||
lockZoom: false,
|
|
||||||
viewportZoomFactor: 0.7,
|
|
||||||
overscrollAllowance: 0.5,
|
|
||||||
enabled: !isTestEnv(),
|
|
||||||
...storedConstraints,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// browsers generally prevent infinite self-embedding, there are
|
// browsers generally prevent infinite self-embedding, there are
|
||||||
// cases where it still happens, and while we disallow self-embedding
|
// cases where it still happens, and while we disallow self-embedding
|
||||||
// by not whitelisting our own origin, this serves as an additional guard
|
// by not whitelisting our own origin, this serves as an additional guard
|
||||||
@@ -1172,27 +840,18 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="excalidraw-ui-top-right">
|
<div className="top-right-ui">
|
||||||
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
|
||||||
<ExcalidrawPlusPromoBanner
|
|
||||||
isSignedIn={isExcalidrawPlusSignedUser}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{collabError.message && <CollabError collabError={collabError} />}
|
{collabError.message && <CollabError collabError={collabError} />}
|
||||||
<LiveCollaborationTrigger
|
<LiveCollaborationTrigger
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
setShareDialogState({ isOpen: true, type: "share" })
|
setShareDialogState({ isOpen: true, type: "share" })
|
||||||
}
|
}
|
||||||
editorInterface={editorInterface}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
// scrollConstraints={constraints.enabled ? constraints : undefined}
|
|
||||||
onLinkOpen={(element, event) => {
|
onLinkOpen={(element, event) => {
|
||||||
if (element.link && isElementLink(element.link)) {
|
if (element.link && isElementLink(element.link)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1200,12 +859,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* {excalidrawAPI && !isTestEnv() && (
|
|
||||||
<ConstraintsSettings
|
|
||||||
excalidrawAPI={excalidrawAPI}
|
|
||||||
initialConstraints={constraints}
|
|
||||||
/>
|
|
||||||
)} */}
|
|
||||||
<AppMainMenu
|
<AppMainMenu
|
||||||
onCollabDialogOpen={onCollabDialogOpen}
|
onCollabDialogOpen={onCollabDialogOpen}
|
||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
@@ -1243,15 +896,10 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
<TTDDialogTrigger />
|
<TTDDialogTrigger />
|
||||||
{isCollaborating && isOffline && (
|
{isCollaborating && isOffline && (
|
||||||
<div className="alertalert--warning">
|
<div className="collab-offline-warning">
|
||||||
{t("alerts.collabOfflineWarning")}
|
{t("alerts.collabOfflineWarning")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{localStorageQuotaExceeded && (
|
|
||||||
<div className="alert alert--danger">
|
|
||||||
{t("alerts.localStorageQuotaExceeded")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestShareableLink && (
|
{latestShareableLink && (
|
||||||
<ShareableLinkDialog
|
<ShareableLinkDialog
|
||||||
link={latestShareableLink}
|
link={latestShareableLink}
|
||||||
@@ -1280,8 +928,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppSidebar />
|
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||||
{errorMessage}
|
{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 { useEffect, useState } from "react";
|
||||||
|
import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils";
|
||||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
|
||||||
import type { UIAppState } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getElementsStorageSize,
|
getElementsStorageSize,
|
||||||
getTotalStorageSize,
|
getTotalStorageSize,
|
||||||
} from "./data/localStorage";
|
} 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 };
|
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 { useLayoutEffect, useRef } from "react";
|
||||||
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
import { LocalData } from "./data/LocalData";
|
||||||
import type {
|
import type {
|
||||||
FileId,
|
FileId,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/excalidraw/element/types";
|
||||||
import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
|
import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
|
||||||
|
import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
|
||||||
import { STORAGE_KEYS } from "./app_constants";
|
import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
|
||||||
import { LocalData } from "./data/LocalData";
|
|
||||||
|
|
||||||
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
|
const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
|
||||||
import { useSetAtom } from "../app-jotai";
|
import { useSetAtom } from "../app-jotai";
|
||||||
|
|
||||||
import { appLangCodeAtom } from "./language-state";
|
import { appLangCodeAtom } from "./language-state";
|
||||||
|
|
||||||
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defaultLang, languages } from "@excalidraw/excalidraw";
|
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
|
import { defaultLang, languages } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
export const languageDetector = new LanguageDetector();
|
export const languageDetector = new LanguageDetector();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { atom, useAtom } from "../app-jotai";
|
import { atom, useAtom } from "../app-jotai";
|
||||||
|
|
||||||
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
import { getPreferredLanguage, languageDetector } from "./language-detector";
|
||||||
|
|
||||||
export const appLangCodeAtom = atom(getPreferredLanguage());
|
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 CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
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 = 3 * 1024 * 1024; // 3 MiB
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,5 @@
|
|||||||
import {
|
|
||||||
CaptureUpdateAction,
|
|
||||||
getSceneVersion,
|
|
||||||
restoreElements,
|
|
||||||
zoomToFitBounds,
|
|
||||||
reconcileElements,
|
|
||||||
} from "@excalidraw/excalidraw";
|
|
||||||
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
|
||||||
import { APP_NAME, EVENT } 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 throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import { PureComponent } from "react";
|
||||||
|
|
||||||
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 {
|
import type {
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
ExcalidrawImperativeAPI,
|
ExcalidrawImperativeAPI,
|
||||||
@@ -47,9 +7,28 @@ import type {
|
|||||||
Collaborator,
|
Collaborator,
|
||||||
Gesture,
|
Gesture,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
||||||
|
import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants";
|
||||||
import { appJotaiStore, atom } from "../app-jotai";
|
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 {
|
import {
|
||||||
CURSOR_SYNC_TIMEOUT,
|
CURSOR_SYNC_TIMEOUT,
|
||||||
FILE_UPLOAD_MAX_BYTES,
|
FILE_UPLOAD_MAX_BYTES,
|
||||||
@@ -60,17 +39,15 @@ import {
|
|||||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||||
WS_EVENTS,
|
WS_EVENTS,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
|
import type {
|
||||||
|
SocketUpdateDataSource,
|
||||||
|
SyncableExcalidrawElement,
|
||||||
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
generateCollaborationLinkData,
|
generateCollaborationLinkData,
|
||||||
getCollaborationLink,
|
getCollaborationLink,
|
||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import {
|
|
||||||
encodeFilesForUpload,
|
|
||||||
FileManager,
|
|
||||||
updateStaleImageStatuses,
|
|
||||||
} from "../data/FileManager";
|
|
||||||
import { LocalData } from "../data/LocalData";
|
|
||||||
import {
|
import {
|
||||||
isSavedToFirebase,
|
isSavedToFirebase,
|
||||||
loadFilesFromFirebase,
|
loadFilesFromFirebase,
|
||||||
@@ -82,15 +59,36 @@ import {
|
|||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
saveUsernameToLocalStorage,
|
saveUsernameToLocalStorage,
|
||||||
} from "../data/localStorage";
|
} from "../data/localStorage";
|
||||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
|
||||||
|
|
||||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
|
||||||
import Portal from "./Portal";
|
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 {
|
import type {
|
||||||
SocketUpdateDataSource,
|
ReconciledExcalidrawElement,
|
||||||
SyncableExcalidrawElement,
|
RemoteExcalidrawElement,
|
||||||
} from "../data";
|
} from "@excalidraw/excalidraw/data/reconcile";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const isCollaboratingAtom = atom(false);
|
export const isCollaboratingAtom = atom(false);
|
||||||
@@ -238,7 +236,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
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"]);
|
window.collab = window.collab || ({} as Window["collab"]);
|
||||||
Object.defineProperties(window, {
|
Object.defineProperties(window, {
|
||||||
collab: {
|
collab: {
|
||||||
@@ -298,13 +296,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
// the purpose is to run in immediately after user decides to stay
|
// the purpose is to run in immediately after user decides to stay
|
||||||
this.saveCollabRoomToFirebase(syncableElements);
|
this.saveCollabRoomToFirebase(syncableElements);
|
||||||
|
|
||||||
if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
|
preventUnload(event);
|
||||||
preventUnload(event);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -530,10 +522,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingRoomLinkData) {
|
if (!existingRoomLinkData) {
|
||||||
// when joining existing room, don't merge it with current scene data
|
|
||||||
this.excalidrawAPI.resetScene();
|
|
||||||
} else {
|
|
||||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||||
if (isImageElement(element) && element.status === "saved") {
|
if (isImageElement(element) && element.status === "saved") {
|
||||||
return newElementWith(element, { status: "pending" });
|
return newElementWith(element, { status: "pending" });
|
||||||
@@ -1020,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"]);
|
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 { warning } from "@excalidraw/excalidraw/components/icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { atom } from "../app-jotai";
|
import { atom } from "../app-jotai";
|
||||||
|
|
||||||
import "./CollabError.scss";
|
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 {
|
import type {
|
||||||
SocketUpdateData,
|
SocketUpdateData,
|
||||||
SocketUpdateDataSource,
|
SocketUpdateDataSource,
|
||||||
SyncableExcalidrawElement,
|
SyncableExcalidrawElement,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
|
import { isSyncableElement } from "../data";
|
||||||
|
|
||||||
import type { TCollabClass } from "./Collab";
|
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 type { Socket } from "socket.io-client";
|
||||||
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
class Portal {
|
class Portal {
|
||||||
collab: TCollabClass;
|
collab: TCollabClass;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
import {
|
import {
|
||||||
DiagramToCodePlugin,
|
DiagramToCodePlugin,
|
||||||
exportToBlob,
|
exportToBlob,
|
||||||
@@ -6,9 +7,7 @@ import {
|
|||||||
TTDDialog,
|
TTDDialog,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
import { getDataURL } from "@excalidraw/excalidraw/data/blob";
|
||||||
import { safelyParseJSON } from "@excalidraw/common";
|
import { safelyParseJSON } from "@excalidraw/excalidraw/utils";
|
||||||
|
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
export const AIComponents = ({
|
export const AIComponents = ({
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
@@ -73,7 +72,7 @@ export const AIComponents = ({
|
|||||||
</br>
|
</br>
|
||||||
<div>You can also try <a href="${
|
<div>You can also try <a href="${
|
||||||
import.meta.env.VITE_APP_PLUS_LP
|
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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Footer } from "@excalidraw/excalidraw/index";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Footer } from "@excalidraw/excalidraw/index";
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|
||||||
|
|
||||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
|
||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
|
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppFooter = React.memo(
|
export const AppFooter = React.memo(
|
||||||
({ onChange }: { onChange: () => void }) => {
|
({ onChange }: { onChange: () => void }) => {
|
||||||
@@ -18,7 +17,11 @@ export const AppFooter = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
{isExcalidrawPlusSignedUser ? (
|
||||||
|
<ExcalidrawPlusAppLink />
|
||||||
|
) : (
|
||||||
|
<EncryptedIcon />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
loginIcon,
|
loginIcon,
|
||||||
ExcalLogo,
|
ExcalLogo,
|
||||||
eyeIcon,
|
eyeIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
|
import type { Theme } from "@excalidraw/excalidraw/element/types";
|
||||||
import { MainMenu } from "@excalidraw/excalidraw/index";
|
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 { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
import { LanguageList } from "../app-language/LanguageList";
|
||||||
import { saveDebugState } from "./DebugCanvas";
|
import { saveDebugState } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppMainMenu: React.FC<{
|
export const AppMainMenu: React.FC<{
|
||||||
@@ -59,7 +54,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
>
|
>
|
||||||
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
</MainMenu.ItemLink>
|
</MainMenu.ItemLink>
|
||||||
{isDevEnv() && (
|
{import.meta.env.DEV && (
|
||||||
<MainMenu.Item
|
<MainMenu.Item
|
||||||
icon={eyeIcon}
|
icon={eyeIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -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 { loginIcon } from "@excalidraw/excalidraw/components/icons";
|
||||||
import { POINTER_EVENTS } from "@excalidraw/common";
|
|
||||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||||
import { WelcomeScreen } from "@excalidraw/excalidraw/index";
|
import { WelcomeScreen } from "@excalidraw/excalidraw/index";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
|
||||||
|
|
||||||
export const AppWelcomeScreen: React.FC<{
|
export const AppWelcomeScreen: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
|
|||||||
@@ -1,44 +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 {
|
import {
|
||||||
ArrowheadArrowIcon,
|
ArrowheadArrowIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} from "@excalidraw/excalidraw/components/icons";
|
||||||
import {
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
bootstrapCanvas,
|
import type { Curve } from "../../packages/math";
|
||||||
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,
|
|
||||||
isFixedPointBinding,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLineSegment,
|
isLineSegment,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LineSegment,
|
type LineSegment,
|
||||||
} from "@excalidraw/math";
|
} from "../../packages/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "../../packages/math/curve";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
|
||||||
import type { DebugElement } from "@excalidraw/common";
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawArrowElement,
|
|
||||||
ExcalidrawBindableElement,
|
|
||||||
FixedPointBinding,
|
|
||||||
OrderedExcalidrawElement,
|
|
||||||
PointBinding,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
|
||||||
|
|
||||||
const renderLine = (
|
const renderLine = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@@ -89,180 +69,6 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
|||||||
context.save();
|
context.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
const _renderBinding = (
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
binding: FixedPointBinding | PointBinding,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
zoom: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
color: string,
|
|
||||||
) => {
|
|
||||||
if (isFixedPointBinding(binding)) {
|
|
||||||
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 | PointBinding,
|
|
||||||
context: CanvasRenderingContext2D,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
zoom: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
color: string,
|
|
||||||
) => {
|
|
||||||
if (isFixedPointBinding(binding)) {
|
|
||||||
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 as FixedPointBinding,
|
|
||||||
elementsMap,
|
|
||||||
zoom,
|
|
||||||
dim,
|
|
||||||
dim,
|
|
||||||
"red",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
"red",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = (
|
const render = (
|
||||||
frame: DebugElement[],
|
frame: DebugElement[],
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@@ -295,14 +101,18 @@ const render = (
|
|||||||
const _debugRenderer = (
|
const _debugRenderer = (
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
|
||||||
scale: number,
|
scale: number,
|
||||||
|
refresh: () => void,
|
||||||
) => {
|
) => {
|
||||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
const context = bootstrapCanvas({
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
@@ -319,7 +129,6 @@ const _debugRenderer = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderOrigin(context, appState.zoom.value);
|
renderOrigin(context, appState.zoom.value);
|
||||||
renderBindings(context, elements, appState.zoom.value);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
window.visualDebug?.currentFrame &&
|
window.visualDebug?.currentFrame &&
|
||||||
@@ -371,10 +180,10 @@ export const debugRenderer = throttleRAF(
|
|||||||
(
|
(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
|
||||||
scale: number,
|
scale: number,
|
||||||
|
refresh: () => void,
|
||||||
) => {
|
) => {
|
||||||
_debugRenderer(canvas, appState, elements, scale);
|
_debugRenderer(canvas, appState, scale, refresh);
|
||||||
},
|
},
|
||||||
{ trailing: true },
|
{ trailing: true },
|
||||||
);
|
);
|
||||||
@@ -501,29 +310,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
|||||||
interface DebugCanvasProps {
|
interface DebugCanvasProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
scale: number;
|
scale: number;
|
||||||
|
ref?: React.Ref<HTMLCanvasElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||||
({ appState, scale }, ref) => {
|
const { width, height } = appState;
|
||||||
const { width, height } = appState;
|
|
||||||
|
|
||||||
return (
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
<canvas
|
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||||
style={{
|
ref,
|
||||||
width,
|
() => canvasRef.current,
|
||||||
height,
|
[canvasRef],
|
||||||
position: "absolute",
|
);
|
||||||
zIndex: 2,
|
|
||||||
pointerEvents: "none",
|
return (
|
||||||
}}
|
<canvas
|
||||||
width={width * scale}
|
style={{
|
||||||
height={height * scale}
|
width,
|
||||||
ref={ref}
|
height,
|
||||||
>
|
position: "absolute",
|
||||||
Debug Canvas
|
zIndex: 2,
|
||||||
</canvas>
|
pointerEvents: "none",
|
||||||
);
|
}}
|
||||||
},
|
width={width * scale}
|
||||||
);
|
height={height * scale}
|
||||||
|
ref={canvasRef}
|
||||||
|
>
|
||||||
|
Debug Canvas
|
||||||
|
</canvas>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DebugCanvas;
|
export default DebugCanvas;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
|
|
||||||
import { shield } from "@excalidraw/excalidraw/components/icons";
|
import { shield } from "@excalidraw/excalidraw/components/icons";
|
||||||
|
import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
|
||||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||||
|
|
||||||
export const EncryptedIcon = () => {
|
export const EncryptedIcon = () => {
|
||||||
@@ -10,7 +10,7 @@ export const EncryptedIcon = () => {
|
|||||||
className="encrypted-icon tooltip"
|
className="encrypted-icon tooltip"
|
||||||
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
href="https://plus.excalidraw.com/blog/end-to-end-encryption"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener noreferrer"
|
||||||
aria-label={t("encrypted.link")}
|
aria-label={t("encrypted.link")}
|
||||||
>
|
>
|
||||||
<Tooltip label={t("encrypted.tooltip")} long={true}>
|
<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 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 { Card } from "@excalidraw/excalidraw/components/Card";
|
||||||
import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
|
|
||||||
import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
|
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 { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FileId,
|
FileId,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/excalidraw/element/types";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} 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 { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||||
import { encodeFilesForUpload } from "../data/FileManager";
|
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 (
|
export const exportToExcalidrawPlus = async (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { THEME } from "@excalidraw/common";
|
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { THEME } from "@excalidraw/excalidraw/constants";
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/excalidraw/element/types";
|
||||||
|
|
||||||
// https://github.com/tholman/github-corners
|
// https://github.com/tholman/github-corners
|
||||||
export const GitHubCorner = React.memo(
|
export const GitHubCorner = React.memo(
|
||||||
|
|||||||
@@ -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 React from "react";
|
||||||
|
import * as Sentry from "@sentry/browser";
|
||||||
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
|
import Trans from "@excalidraw/excalidraw/components/Trans";
|
||||||
|
|
||||||
interface TopErrorBoundaryState {
|
interface TopErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
import { CaptureUpdateAction } from "@excalidraw/excalidraw";
|
||||||
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
import { compressData } from "@excalidraw/excalidraw/data/encode";
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
FileId,
|
FileId,
|
||||||
InitializedExcalidrawImageElement,
|
InitializedExcalidrawImageElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/excalidraw/element/types";
|
||||||
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import type {
|
import type {
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFileMetadata,
|
BinaryFileMetadata,
|
||||||
|
|||||||
@@ -10,13 +10,6 @@
|
|||||||
* (localStorage, indexedDB).
|
* (localStorage, indexedDB).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
|
|
||||||
import {
|
|
||||||
CANVAS_SEARCH_TAB,
|
|
||||||
DEFAULT_SIDEBAR,
|
|
||||||
debounce,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
|
||||||
import {
|
import {
|
||||||
createStore,
|
createStore,
|
||||||
entries,
|
entries,
|
||||||
@@ -26,29 +19,32 @@ import {
|
|||||||
setMany,
|
setMany,
|
||||||
get,
|
get,
|
||||||
} from "idb-keyval";
|
} from "idb-keyval";
|
||||||
|
import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
|
||||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
import {
|
||||||
|
CANVAS_SEARCH_TAB,
|
||||||
|
DEFAULT_SIDEBAR,
|
||||||
|
} from "@excalidraw/excalidraw/constants";
|
||||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
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 {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} 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 { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
import { FileManager } from "./FileManager";
|
import { FileManager } from "./FileManager";
|
||||||
import { Locker } from "./Locker";
|
import { Locker } from "./Locker";
|
||||||
import { updateBrowserStateVersion } from "./tabSync";
|
import { updateBrowserStateVersion } from "./tabSync";
|
||||||
|
|
||||||
const filesStore = createStore("files-db", "files-store");
|
const filesStore = createStore("files-db", "files-store");
|
||||||
|
|
||||||
export const localStorageQuotaExceededAtom = atom(false);
|
|
||||||
|
|
||||||
class LocalFileManager extends FileManager {
|
class LocalFileManager extends FileManager {
|
||||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||||
await entries(filesStore).then((entries) => {
|
await entries(filesStore).then((entries) => {
|
||||||
@@ -73,9 +69,6 @@ const saveDataStateToLocalStorage = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
const localStorageQuotaExceeded = appJotaiStore.get(
|
|
||||||
localStorageQuotaExceededAtom,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const _appState = clearAppStateForLocalStorage(appState);
|
const _appState = clearAppStateForLocalStorage(appState);
|
||||||
|
|
||||||
@@ -95,22 +88,12 @@ const saveDataStateToLocalStorage = (
|
|||||||
JSON.stringify(_appState),
|
JSON.stringify(_appState),
|
||||||
);
|
);
|
||||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||||
if (localStorageQuotaExceeded) {
|
|
||||||
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Unable to access window.localStorage
|
// Unable to access window.localStorage
|
||||||
console.error(error);
|
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";
|
type SavingLockTypes = "collaboration";
|
||||||
|
|
||||||
export class LocalData {
|
export class LocalData {
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { reconcileElements } from "@excalidraw/excalidraw";
|
import { reconcileElements } from "@excalidraw/excalidraw";
|
||||||
import { MIME_TYPES } 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 { decompressData } from "@excalidraw/excalidraw/data/encode";
|
||||||
import {
|
import {
|
||||||
encryptData,
|
encryptData,
|
||||||
decryptData,
|
decryptData,
|
||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { restoreElements } from "@excalidraw/excalidraw/data/restore";
|
import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
|
||||||
import { getSceneVersion } from "@excalidraw/element";
|
import type { SyncableExcalidrawElement } from ".";
|
||||||
|
import { getSyncableElements } from ".";
|
||||||
import { initializeApp } from "firebase/app";
|
import { initializeApp } from "firebase/app";
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
getFirestore,
|
||||||
@@ -16,27 +31,8 @@ import {
|
|||||||
Bytes,
|
Bytes,
|
||||||
} from "firebase/firestore";
|
} from "firebase/firestore";
|
||||||
import { getStorage, ref, uploadBytes } from "firebase/storage";
|
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 { Socket } from "socket.io-client";
|
||||||
|
import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
|
||||||
|
|
||||||
// private
|
// private
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -259,9 +255,7 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
deleteInvisibleElements: true,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -9,38 +9,34 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/data/encryption";
|
} from "@excalidraw/excalidraw/data/encryption";
|
||||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
|
||||||
import { isInitializedImageElement } from "@excalidraw/element";
|
|
||||||
import { t } from "@excalidraw/excalidraw/i18n";
|
|
||||||
import { bytesToHexString } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type { UserIdleState } from "@excalidraw/common";
|
|
||||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
import type { 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 {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FileId,
|
FileId,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/excalidraw/element/types";
|
||||||
|
import { t } from "@excalidraw/excalidraw/i18n";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
BinaryFileData,
|
BinaryFileData,
|
||||||
BinaryFiles,
|
BinaryFiles,
|
||||||
SocketId,
|
SocketId,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} 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 {
|
import {
|
||||||
DELETED_ELEMENT_TIMEOUT,
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
FILE_UPLOAD_MAX_BYTES,
|
FILE_UPLOAD_MAX_BYTES,
|
||||||
ROOM_ID_BYTES,
|
ROOM_ID_BYTES,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
|
|
||||||
import { encodeFilesForUpload } from "./FileManager";
|
import { encodeFilesForUpload } from "./FileManager";
|
||||||
import { saveFilesToFirebase } from "./firebase";
|
import { saveFilesToFirebase } from "./firebase";
|
||||||
|
|
||||||
import type { WS_SUBTYPES } from "../app_constants";
|
|
||||||
|
|
||||||
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||||
MakeBrand<"SyncableExcalidrawElement">;
|
MakeBrand<"SyncableExcalidrawElement">;
|
||||||
|
|
||||||
@@ -258,16 +254,11 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
|
import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
import {
|
import {
|
||||||
clearAppStateForLocalStorage,
|
clearAppStateForLocalStorage,
|
||||||
getDefaultAppState,
|
getDefaultAppState,
|
||||||
} from "@excalidraw/excalidraw/appState";
|
} from "@excalidraw/excalidraw/appState";
|
||||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
export const saveUsernameToLocalStorage = (username: string) => {
|
export const saveUsernameToLocalStorage = (username: string) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw Whiteboard</title>
|
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
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 -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
+14
-27
@@ -1,5 +1,3 @@
|
|||||||
@import "../packages/excalidraw/css/variables.module.scss";
|
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||||
|
|
||||||
@@ -7,6 +5,12 @@
|
|||||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-right-ui {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -54,7 +58,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.collab-offline-warning {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6.5rem;
|
top: 6.5rem;
|
||||||
@@ -65,18 +69,10 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: var(--color-text-warning);
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
white-space: pre;
|
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;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--color-primary);
|
||||||
padding: 0.5rem 0.875rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
|
color: var(--color-primary) !important;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
|
|
||||||
font-family: var(--ui-font);
|
font-size: 0.75rem;
|
||||||
font-size: 0.8333rem;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: var(--lg-button-size);
|
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 {
|
&:hover {
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: white !important;
|
color: white !important;
|
||||||
@@ -122,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme--dark {
|
.theme--dark {
|
||||||
.plus-banner {
|
.plus-button {
|
||||||
&:hover {
|
&:hover {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import ExcalidrawApp from "./App";
|
||||||
import { registerSW } from "virtual:pwa-register";
|
import { registerSW } from "virtual:pwa-register";
|
||||||
|
|
||||||
import "../excalidraw-app/sentry";
|
import "../excalidraw-app/sentry";
|
||||||
|
|
||||||
import ExcalidrawApp from "./App";
|
|
||||||
|
|
||||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||||
const rootElement = document.getElementById("root")!;
|
const rootElement = document.getElementById("root")!;
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@excalidraw/random-username": "1.0.0",
|
"@excalidraw/random-username": "1.0.0",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
|
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 { Dialog } from "@excalidraw/excalidraw/components/Dialog";
|
||||||
import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
|
|
||||||
import { TextField } from "@excalidraw/excalidraw/components/TextField";
|
|
||||||
import {
|
import {
|
||||||
copyIcon,
|
copyIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
@@ -12,19 +14,16 @@ import {
|
|||||||
shareIOS,
|
shareIOS,
|
||||||
shareWindows,
|
shareWindows,
|
||||||
} from "@excalidraw/excalidraw/components/icons";
|
} 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 { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||||
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
|
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 { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
|
||||||
|
|
||||||
import "./ShareDialog.scss";
|
import "./ShareDialog.scss";
|
||||||
|
|
||||||
import type { CollabAPI } from "../collab/Collab";
|
|
||||||
|
|
||||||
type OnExportToBackend = () => void;
|
type OnExportToBackend = () => void;
|
||||||
type ShareDialogType = "share" | "collaborationOnly";
|
type ShareDialogType = "share" | "collaborationOnly";
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import ExcalidrawApp from "../App";
|
||||||
import {
|
import {
|
||||||
mockBoundingClientRect,
|
mockBoundingClientRect,
|
||||||
render,
|
render,
|
||||||
restoreOriginalGetBoundingClientRect,
|
restoreOriginalGetBoundingClientRect,
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
|
||||||
describe("Test MobileMenu", () => {
|
describe("Test MobileMenu", () => {
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@@ -17,15 +17,30 @@ describe("Test MobileMenu", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
h.app.refreshEditorInterface();
|
// @ts-ignore
|
||||||
|
h.app.refreshViewportBreakpoints();
|
||||||
|
// @ts-ignore
|
||||||
|
h.app.refreshEditorBreakpoints();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
restoreOriginalGetBoundingClientRect();
|
restoreOriginalGetBoundingClientRect();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should set editor interface correctly", () => {
|
it("should set device correctly", () => {
|
||||||
expect(h.app.editorInterface.formFactor).toBe("phone");
|
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 () => {
|
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
|
|||||||
<a
|
<a
|
||||||
class="welcome-screen-menu-item "
|
class="welcome-screen-menu-item "
|
||||||
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||||
rel="noopener"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<div
|
<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 {
|
import {
|
||||||
createRedoAction,
|
createRedoAction,
|
||||||
createUndoAction,
|
createUndoAction,
|
||||||
} from "@excalidraw/excalidraw/actions/actionHistory";
|
} from "@excalidraw/excalidraw/actions/actionHistory";
|
||||||
import { syncInvalidIndices } from "@excalidraw/element";
|
import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
|
|
||||||
import { vi } from "vitest";
|
|
||||||
|
|
||||||
import { StoreIncrement } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import ExcalidrawApp from "../App";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -69,79 +64,6 @@ vi.mock("socket.io-client", () => {
|
|||||||
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
|
||||||
*/
|
*/
|
||||||
describe("collaboration", () => {
|
describe("collaboration", () => {
|
||||||
it("should emit two ephemeral increments even though updates get batched", async () => {
|
|
||||||
const durableIncrements: DurableIncrement[] = [];
|
|
||||||
const ephemeralIncrements: EphemeralIncrement[] = [];
|
|
||||||
|
|
||||||
await render(<ExcalidrawApp />);
|
|
||||||
|
|
||||||
h.store.onStoreIncrementEmitter.on((increment) => {
|
|
||||||
if (StoreIncrement.isDurable(increment)) {
|
|
||||||
durableIncrements.push(increment);
|
|
||||||
} else {
|
|
||||||
ephemeralIncrements.push(increment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line dot-notation
|
|
||||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
|
||||||
expect(durableIncrements.length).toBe(0);
|
|
||||||
expect(ephemeralIncrements.length).toBe(0);
|
|
||||||
|
|
||||||
const rectProps = {
|
|
||||||
type: "rectangle",
|
|
||||||
id: "A",
|
|
||||||
height: 200,
|
|
||||||
width: 100,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const rect = API.createElement({ ...rectProps });
|
|
||||||
|
|
||||||
API.updateScene({
|
|
||||||
elements: [rect],
|
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// expect(commitSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(durableIncrements.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// simulate two batched remote updates
|
|
||||||
act(() => {
|
|
||||||
h.app.updateScene({
|
|
||||||
elements: [newElementWith(h.elements[0], { x: 100 })],
|
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
|
||||||
});
|
|
||||||
h.app.updateScene({
|
|
||||||
elements: [newElementWith(h.elements[0], { x: 200 })],
|
|
||||||
captureUpdate: CaptureUpdateAction.NEVER,
|
|
||||||
});
|
|
||||||
|
|
||||||
// we scheduled two micro actions,
|
|
||||||
// which confirms they are going to be executed as part of one batched component update
|
|
||||||
// eslint-disable-next-line dot-notation
|
|
||||||
expect(h.store["scheduledMicroActions"].length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// altough the updates get batched,
|
|
||||||
// we expect two ephemeral increments for each update,
|
|
||||||
// and each such update should have the expected change
|
|
||||||
expect(ephemeralIncrements.length).toBe(2);
|
|
||||||
expect(ephemeralIncrements[0].change.elements.A).toEqual(
|
|
||||||
expect.objectContaining({ x: 100 }),
|
|
||||||
);
|
|
||||||
expect(ephemeralIncrements[1].change.elements.A).toEqual(
|
|
||||||
expect.objectContaining({ x: 200 }),
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line dot-notation
|
|
||||||
expect(h.store["scheduledMicroActions"].length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should allow to undo / redo even on force-deleted elements", async () => {
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
||||||
await render(<ExcalidrawApp />);
|
await render(<ExcalidrawApp />);
|
||||||
const rect1Props = {
|
const rect1Props = {
|
||||||
@@ -199,13 +121,12 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
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));
|
act(() => h.app.actionManager.executeAction(undoAction));
|
||||||
|
|
||||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
expect(API.getRedoStack().length).toBe(1);
|
|
||||||
expect(API.getSnapshot()).toEqual([
|
expect(API.getSnapshot()).toEqual([
|
||||||
expect.objectContaining(rect1Props),
|
expect.objectContaining(rect1Props),
|
||||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||||
@@ -232,7 +153,7 @@ describe("collaboration", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
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));
|
act(() => h.app.actionManager.executeAction(redoAction));
|
||||||
|
|
||||||
// with explicit redo (as removal) we again restore the element from the snapshot!
|
// with explicit redo (as removal) we again restore the element from the snapshot!
|
||||||
@@ -248,5 +169,79 @@ describe("collaboration", () => {
|
|||||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
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 { useEffect, useLayoutEffect, useState } from "react";
|
||||||
|
import { THEME } from "@excalidraw/excalidraw";
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
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";
|
import { STORAGE_KEYS } from "./app_constants";
|
||||||
|
|
||||||
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
|
||||||
|
|||||||
@@ -23,57 +23,29 @@ export default defineConfig(({ mode }) => {
|
|||||||
envDir: "../",
|
envDir: "../",
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
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$/,
|
find: /^@excalidraw\/excalidraw$/,
|
||||||
replacement: path.resolve(
|
replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
|
||||||
__dirname,
|
|
||||||
"../packages/excalidraw/index.tsx",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
find: /^@excalidraw\/excalidraw\/(.*?)/,
|
find: /^@excalidraw\/excalidraw\/(.*?)/,
|
||||||
replacement: path.resolve(__dirname, "../packages/excalidraw/$1"),
|
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$/,
|
find: /^@excalidraw\/utils$/,
|
||||||
replacement: path.resolve(
|
replacement: path.resolve(__dirname, "../packages/utils/index.ts"),
|
||||||
__dirname,
|
|
||||||
"../packages/utils/src/index.ts",
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
find: /^@excalidraw\/utils\/(.*?)/,
|
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"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -225,7 +197,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
start_url: "/",
|
start_url: "/",
|
||||||
id: "excalidraw",
|
id:"excalidraw",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
theme_color: "#121212",
|
theme_color: "#121212",
|
||||||
background_color: "#ffffff",
|
background_color: "#ffffff",
|
||||||
|
|||||||
+12
-16
@@ -4,7 +4,9 @@
|
|||||||
"packageManager": "yarn@1.22.22",
|
"packageManager": "yarn@1.22.22",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"excalidraw-app",
|
"excalidraw-app",
|
||||||
"packages/*",
|
"packages/excalidraw",
|
||||||
|
"packages/utils",
|
||||||
|
"packages/math",
|
||||||
"examples/*"
|
"examples/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,7 +26,6 @@
|
|||||||
"dotenv": "16.0.1",
|
"dotenv": "16.0.1",
|
||||||
"eslint-config-prettier": "8.5.0",
|
"eslint-config-prettier": "8.5.0",
|
||||||
"eslint-config-react-app": "7.0.1",
|
"eslint-config-react-app": "7.0.1",
|
||||||
"eslint-plugin-import": "2.31.0",
|
|
||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"http-server": "14.1.1",
|
"http-server": "14.1.1",
|
||||||
"husky": "7.0.4",
|
"husky": "7.0.4",
|
||||||
@@ -33,7 +34,6 @@
|
|||||||
"pepjs": "0.5.3",
|
"pepjs": "0.5.3",
|
||||||
"prettier": "2.6.2",
|
"prettier": "2.6.2",
|
||||||
"rewire": "6.0.0",
|
"rewire": "6.0.0",
|
||||||
"rimraf": "^5.0.0",
|
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.0.12",
|
"vite": "5.0.12",
|
||||||
"vite-plugin-checker": "0.7.2",
|
"vite-plugin-checker": "0.7.2",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"vitest-canvas-mock": "0.3.3"
|
"vitest-canvas-mock": "0.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": "18.0.0 - 22.x.x"
|
||||||
},
|
},
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"prettier": "@excalidraw/prettier-config",
|
"prettier": "@excalidraw/prettier-config",
|
||||||
@@ -52,17 +52,13 @@
|
|||||||
"build-node": "node ./scripts/build-node.js",
|
"build-node": "node ./scripts/build-node.js",
|
||||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||||
"build:common": "yarn --cwd ./packages/common build:esm",
|
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
||||||
"build:element": "yarn --cwd ./packages/element build:esm",
|
|
||||||
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
|
||||||
"build:math": "yarn --cwd ./packages/math build:esm",
|
|
||||||
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
|
||||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||||
"build": "yarn --cwd ./excalidraw-app build",
|
"build": "yarn --cwd ./excalidraw-app build",
|
||||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||||
"start": "yarn --cwd ./excalidraw-app start",
|
"start": "yarn --cwd ./excalidraw-app start",
|
||||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
"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:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||||
"test:app": "vitest",
|
"test:app": "vitest",
|
||||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||||
@@ -80,15 +76,15 @@
|
|||||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||||
"release": "node scripts/release.js",
|
"autorelease": "node scripts/autorelease.js",
|
||||||
"release:test": "node scripts/release.js --tag=test",
|
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||||
"release:next": "node scripts/release.js --tag=next",
|
"release:excalidraw": "node scripts/release.js",
|
||||||
"release:latest": "node scripts/release.js --tag=latest",
|
"rm:build": "rm -rf excalidraw-app/{build,dist,dev-dist} && rm -rf packages/*/{dist,build} && rm -rf examples/*/{build,dist}",
|
||||||
"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": "rm -rf node_modules && rm -rf excalidraw-app/node_modules && rm -rf packages/*/node_modules",
|
||||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
|
||||||
"clean-install": "yarn rm:node_modules && yarn install"
|
"clean-install": "yarn rm:node_modules && yarn install"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
"@types/d3-dispatch": "3.0.6",
|
||||||
"strip-ansi": "6.0.1"
|
"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,59 +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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,223 +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
|
|
||||||
// mobile: up to 699px
|
|
||||||
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 = 1400; // upper bound (excludes laptops/desktops)
|
|
||||||
|
|
||||||
// desktop/laptop
|
|
||||||
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 "./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 "./visualdebug";
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "./dist/types"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*", "global.d.ts"],
|
|
||||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["../eslintrc.base.json"]
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# @excalidraw/element
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @excalidraw/element
|
|
||||||
```
|
|
||||||
|
|
||||||
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn add @excalidraw/element
|
|
||||||
```
|
|
||||||
|
|
||||||
With PNPM, similarly install the package with this command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm add @excalidraw/element
|
|
||||||
```
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
import "@excalidraw/excalidraw/global";
|
|
||||||
import "@excalidraw/excalidraw/css";
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@excalidraw/element",
|
|
||||||
"version": "0.18.0",
|
|
||||||
"type": "module",
|
|
||||||
"types": "./dist/types/element/src/index.d.ts",
|
|
||||||
"main": "./dist/prod/index.js",
|
|
||||||
"module": "./dist/prod/index.js",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/types/element/src/index.d.ts",
|
|
||||||
"development": "./dist/dev/index.js",
|
|
||||||
"production": "./dist/prod/index.js",
|
|
||||||
"default": "./dist/prod/index.js"
|
|
||||||
},
|
|
||||||
"./*": {
|
|
||||||
"types": "./dist/types/element/src/*.d.ts",
|
|
||||||
"development": "./dist/dev/index.js",
|
|
||||||
"production": "./dist/prod/index.js",
|
|
||||||
"default": "./dist/prod/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist/*"
|
|
||||||
],
|
|
||||||
"description": "Excalidraw elements-related logic",
|
|
||||||
"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": {
|
|
||||||
"@excalidraw/common": "0.18.0",
|
|
||||||
"@excalidraw/math": "0.18.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,556 +0,0 @@
|
|||||||
import { isTransparent } from "@excalidraw/common";
|
|
||||||
import {
|
|
||||||
curveIntersectLineSegment,
|
|
||||||
isPointWithinBounds,
|
|
||||||
lineSegment,
|
|
||||||
lineSegmentIntersectionPoints,
|
|
||||||
pointFrom,
|
|
||||||
pointFromVector,
|
|
||||||
pointRotateRads,
|
|
||||||
pointsEqual,
|
|
||||||
vectorFromPoint,
|
|
||||||
vectorNormalize,
|
|
||||||
vectorScale,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ellipse,
|
|
||||||
ellipseSegmentInterceptPoints,
|
|
||||||
} from "@excalidraw/math/ellipse";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
Curve,
|
|
||||||
GlobalPoint,
|
|
||||||
LineSegment,
|
|
||||||
Radians,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { isPathALoop } from "./utils";
|
|
||||||
import {
|
|
||||||
type Bounds,
|
|
||||||
doBoundsIntersect,
|
|
||||||
elementCenterPoint,
|
|
||||||
getCenterForBounds,
|
|
||||||
getCubicBezierCurveBound,
|
|
||||||
getElementBounds,
|
|
||||||
} from "./bounds";
|
|
||||||
import {
|
|
||||||
hasBoundTextElement,
|
|
||||||
isFreeDrawElement,
|
|
||||||
isIframeLikeElement,
|
|
||||||
isImageElement,
|
|
||||||
isLinearElement,
|
|
||||||
isTextElement,
|
|
||||||
} from "./typeChecks";
|
|
||||||
import {
|
|
||||||
deconstructDiamondElement,
|
|
||||||
deconstructLinearOrFreeDrawElement,
|
|
||||||
deconstructRectanguloidElement,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
|
||||||
|
|
||||||
import { distanceToElement } from "./distance";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawDiamondElement,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawEllipseElement,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawRectanguloidElement,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
|
||||||
if (element.type === "arrow") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDraggableFromInside =
|
|
||||||
!isTransparent(element.backgroundColor) ||
|
|
||||||
hasBoundTextElement(element) ||
|
|
||||||
isIframeLikeElement(element) ||
|
|
||||||
isTextElement(element);
|
|
||||||
|
|
||||||
if (element.type === "line") {
|
|
||||||
return isDraggableFromInside && isPathALoop(element.points);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.type === "freedraw") {
|
|
||||||
return isDraggableFromInside && isPathALoop(element.points);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isDraggableFromInside || isImageElement(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HitTestArgs = {
|
|
||||||
point: GlobalPoint;
|
|
||||||
element: ExcalidrawElement;
|
|
||||||
threshold: number;
|
|
||||||
elementsMap: ElementsMap;
|
|
||||||
frameNameBound?: FrameNameBounds | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hitElementItself = ({
|
|
||||||
point,
|
|
||||||
element,
|
|
||||||
threshold,
|
|
||||||
elementsMap,
|
|
||||||
frameNameBound = null,
|
|
||||||
}: HitTestArgs) => {
|
|
||||||
// Hit test against a frame's name
|
|
||||||
const hitFrameName = frameNameBound
|
|
||||||
? isPointWithinBounds(
|
|
||||||
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
|
||||||
point,
|
|
||||||
pointFrom(
|
|
||||||
frameNameBound.x + frameNameBound.width + threshold,
|
|
||||||
frameNameBound.y + frameNameBound.height + threshold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// Hit test against the extended, rotated bounding box of the element first
|
|
||||||
const bounds = getElementBounds(element, elementsMap, true);
|
|
||||||
const hitBounds = isPointWithinBounds(
|
|
||||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
|
||||||
pointRotateRads(
|
|
||||||
point,
|
|
||||||
getCenterForBounds(bounds),
|
|
||||||
-element.angle as Radians,
|
|
||||||
),
|
|
||||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
|
||||||
);
|
|
||||||
|
|
||||||
// PERF: Bail out early if the point is not even in the
|
|
||||||
// rotated bounding box or not hitting the frame name (saves 99%)
|
|
||||||
if (!hitBounds && !hitFrameName) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do the precise (and relatively costly) hit test
|
|
||||||
const hitElement = shouldTestInside(element)
|
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
|
||||||
// we would need `onShape` as well to include the "borders"
|
|
||||||
isPointInElement(point, element, elementsMap) ||
|
|
||||||
isPointOnElementOutline(point, element, elementsMap, threshold)
|
|
||||||
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
|
||||||
|
|
||||||
return hitElement || hitFrameName;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hitElementBoundingBox = (
|
|
||||||
point: GlobalPoint,
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
tolerance = 0,
|
|
||||||
) => {
|
|
||||||
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
|
||||||
x1 -= tolerance;
|
|
||||||
y1 -= tolerance;
|
|
||||||
x2 += tolerance;
|
|
||||||
y2 += tolerance;
|
|
||||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = (
|
|
||||||
hitArgs: HitTestArgs,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
) =>
|
|
||||||
!hitElementItself(hitArgs) &&
|
|
||||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
|
||||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
|
||||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
|
||||||
|
|
||||||
export const hitElementBoundText = (
|
|
||||||
point: GlobalPoint,
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): boolean => {
|
|
||||||
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (!boundTextElementCandidate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const boundTextElement = isLinearElement(element)
|
|
||||||
? {
|
|
||||||
...boundTextElementCandidate,
|
|
||||||
// arrow's bound text accurate position is not stored in the element's property
|
|
||||||
// but rather calculated and returned from the following static method
|
|
||||||
...LinearElementEditor.getBoundTextElementPosition(
|
|
||||||
element,
|
|
||||||
boundTextElementCandidate,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: boundTextElementCandidate;
|
|
||||||
|
|
||||||
return isPointInElement(point, boundTextElement, elementsMap);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intersect a line with an element for binding test
|
|
||||||
*
|
|
||||||
* @param element
|
|
||||||
* @param line
|
|
||||||
* @param offset
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const intersectElementWithLineSegment = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
line: LineSegment<GlobalPoint>,
|
|
||||||
offset: number = 0,
|
|
||||||
onlyFirst = false,
|
|
||||||
): GlobalPoint[] => {
|
|
||||||
// First check if the line intersects the element's axis-aligned bounding box
|
|
||||||
// as it is much faster than checking intersection against the element's shape
|
|
||||||
const intersectorBounds = [
|
|
||||||
Math.min(line[0][0] - offset, line[1][0] - offset),
|
|
||||||
Math.min(line[0][1] - offset, line[1][1] - offset),
|
|
||||||
Math.max(line[0][0] + offset, line[1][0] + offset),
|
|
||||||
Math.max(line[0][1] + offset, line[1][1] + offset),
|
|
||||||
] as Bounds;
|
|
||||||
const elementBounds = getElementBounds(element, elementsMap);
|
|
||||||
|
|
||||||
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do the actual intersection test against the element's shape
|
|
||||||
switch (element.type) {
|
|
||||||
case "rectangle":
|
|
||||||
case "image":
|
|
||||||
case "text":
|
|
||||||
case "iframe":
|
|
||||||
case "embeddable":
|
|
||||||
case "frame":
|
|
||||||
case "selection":
|
|
||||||
case "magicframe":
|
|
||||||
return intersectRectanguloidWithLineSegment(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
line,
|
|
||||||
offset,
|
|
||||||
onlyFirst,
|
|
||||||
);
|
|
||||||
case "diamond":
|
|
||||||
return intersectDiamondWithLineSegment(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
line,
|
|
||||||
offset,
|
|
||||||
onlyFirst,
|
|
||||||
);
|
|
||||||
case "ellipse":
|
|
||||||
return intersectEllipseWithLineSegment(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
line,
|
|
||||||
offset,
|
|
||||||
);
|
|
||||||
case "line":
|
|
||||||
case "freedraw":
|
|
||||||
case "arrow":
|
|
||||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const curveIntersections = (
|
|
||||||
curves: Curve<GlobalPoint>[],
|
|
||||||
segment: LineSegment<GlobalPoint>,
|
|
||||||
intersections: GlobalPoint[],
|
|
||||||
center: GlobalPoint,
|
|
||||||
angle: Radians,
|
|
||||||
onlyFirst = false,
|
|
||||||
) => {
|
|
||||||
for (const c of curves) {
|
|
||||||
// Optimize by doing a cheap bounding box check first
|
|
||||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
|
||||||
const b2 = [
|
|
||||||
Math.min(segment[0][0], segment[1][0]),
|
|
||||||
Math.min(segment[0][1], segment[1][1]),
|
|
||||||
Math.max(segment[0][0], segment[1][0]),
|
|
||||||
Math.max(segment[0][1], segment[1][1]),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
if (!doBoundsIntersect(b1, b2)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hits = curveIntersectLineSegment(c, segment);
|
|
||||||
|
|
||||||
if (hits.length > 0) {
|
|
||||||
for (const j of hits) {
|
|
||||||
intersections.push(pointRotateRads(j, center, angle));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onlyFirst) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return intersections;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lineIntersections = (
|
|
||||||
lines: LineSegment<GlobalPoint>[],
|
|
||||||
segment: LineSegment<GlobalPoint>,
|
|
||||||
intersections: GlobalPoint[],
|
|
||||||
center: GlobalPoint,
|
|
||||||
angle: Radians,
|
|
||||||
onlyFirst = false,
|
|
||||||
) => {
|
|
||||||
for (const l of lines) {
|
|
||||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
|
||||||
if (intersection) {
|
|
||||||
intersections.push(pointRotateRads(intersection, center, angle));
|
|
||||||
|
|
||||||
if (onlyFirst) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return intersections;
|
|
||||||
};
|
|
||||||
|
|
||||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
|
||||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
|
||||||
segment: LineSegment<GlobalPoint>,
|
|
||||||
onlyFirst = false,
|
|
||||||
): GlobalPoint[] => {
|
|
||||||
// NOTE: This is the only one which return the decomposed elements
|
|
||||||
// rotated! This is due to taking advantage of roughjs definitions.
|
|
||||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
|
||||||
const intersections: GlobalPoint[] = [];
|
|
||||||
|
|
||||||
for (const l of lines) {
|
|
||||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
|
||||||
if (intersection) {
|
|
||||||
intersections.push(intersection);
|
|
||||||
|
|
||||||
if (onlyFirst) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const c of curves) {
|
|
||||||
// Optimize by doing a cheap bounding box check first
|
|
||||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
|
||||||
const b2 = [
|
|
||||||
Math.min(segment[0][0], segment[1][0]),
|
|
||||||
Math.min(segment[0][1], segment[1][1]),
|
|
||||||
Math.max(segment[0][0], segment[1][0]),
|
|
||||||
Math.max(segment[0][1], segment[1][1]),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
if (!doBoundsIntersect(b1, b2)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hits = curveIntersectLineSegment(c, segment);
|
|
||||||
|
|
||||||
if (hits.length > 0) {
|
|
||||||
intersections.push(...hits);
|
|
||||||
|
|
||||||
if (onlyFirst) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return intersections;
|
|
||||||
};
|
|
||||||
|
|
||||||
const intersectRectanguloidWithLineSegment = (
|
|
||||||
element: ExcalidrawRectanguloidElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
segment: LineSegment<GlobalPoint>,
|
|
||||||
offset: number = 0,
|
|
||||||
onlyFirst = false,
|
|
||||||
): GlobalPoint[] => {
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
|
||||||
// instead. It's all the same distance-wise.
|
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
|
||||||
segment[0],
|
|
||||||
center,
|
|
||||||
-element.angle as Radians,
|
|
||||||
);
|
|
||||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
|
||||||
segment[1],
|
|
||||||
center,
|
|
||||||
-element.angle as Radians,
|
|
||||||
);
|
|
||||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
|
||||||
|
|
||||||
// Get the element's building components we can test against
|
|
||||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
|
||||||
|
|
||||||
const intersections: GlobalPoint[] = [];
|
|
||||||
|
|
||||||
lineIntersections(
|
|
||||||
sides,
|
|
||||||
rotatedIntersector,
|
|
||||||
intersections,
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
onlyFirst,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onlyFirst && intersections.length > 0) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
|
|
||||||
curveIntersections(
|
|
||||||
corners,
|
|
||||||
rotatedIntersector,
|
|
||||||
intersections,
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
onlyFirst,
|
|
||||||
);
|
|
||||||
|
|
||||||
return intersections;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param element
|
|
||||||
* @param a
|
|
||||||
* @param b
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const intersectDiamondWithLineSegment = (
|
|
||||||
element: ExcalidrawDiamondElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
l: LineSegment<GlobalPoint>,
|
|
||||||
offset: number = 0,
|
|
||||||
onlyFirst = false,
|
|
||||||
): GlobalPoint[] => {
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
|
||||||
// points. It's all the same distance-wise.
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
|
||||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
|
||||||
|
|
||||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
|
||||||
const intersections: GlobalPoint[] = [];
|
|
||||||
|
|
||||||
lineIntersections(
|
|
||||||
sides,
|
|
||||||
rotatedIntersector,
|
|
||||||
intersections,
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
onlyFirst,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onlyFirst && intersections.length > 0) {
|
|
||||||
return intersections;
|
|
||||||
}
|
|
||||||
|
|
||||||
curveIntersections(
|
|
||||||
corners,
|
|
||||||
rotatedIntersector,
|
|
||||||
intersections,
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
onlyFirst,
|
|
||||||
);
|
|
||||||
|
|
||||||
return intersections;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param element
|
|
||||||
* @param a
|
|
||||||
* @param b
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const intersectEllipseWithLineSegment = (
|
|
||||||
element: ExcalidrawEllipseElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
l: LineSegment<GlobalPoint>,
|
|
||||||
offset: number = 0,
|
|
||||||
): GlobalPoint[] => {
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
|
||||||
|
|
||||||
return ellipseSegmentInterceptPoints(
|
|
||||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
|
||||||
lineSegment(rotatedA, rotatedB),
|
|
||||||
).map((p) => pointRotateRads(p, center, element.angle));
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given point is considered on the given shape's border
|
|
||||||
*
|
|
||||||
* @param point
|
|
||||||
* @param element
|
|
||||||
* @param tolerance
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const isPointOnElementOutline = (
|
|
||||||
point: GlobalPoint,
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
tolerance = 1,
|
|
||||||
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given point is considered inside the element's border
|
|
||||||
*
|
|
||||||
* @param point
|
|
||||||
* @param element
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const isPointInElement = (
|
|
||||||
point: GlobalPoint,
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
) => {
|
|
||||||
if (
|
|
||||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
|
||||||
!isPathALoop(element.points)
|
|
||||||
) {
|
|
||||||
// There isn't any "inside" for a non-looping path
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
|
||||||
|
|
||||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
|
||||||
const otherPoint = pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
|
||||||
Math.max(element.width, element.height) * 2,
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
);
|
|
||||||
const intersector = lineSegment(point, otherPoint);
|
|
||||||
const intersections = intersectElementWithLineSegment(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
intersector,
|
|
||||||
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
|
||||||
|
|
||||||
return intersections.length % 2 === 1;
|
|
||||||
};
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
import {
|
|
||||||
ORIG_ID,
|
|
||||||
randomId,
|
|
||||||
randomInteger,
|
|
||||||
arrayToMap,
|
|
||||||
castArray,
|
|
||||||
findLastIndex,
|
|
||||||
getUpdatedTimestamp,
|
|
||||||
isTestEnv,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getElementsInGroup,
|
|
||||||
getNewGroupIdsForDuplication,
|
|
||||||
getSelectedGroupForElement,
|
|
||||||
} from "./groups";
|
|
||||||
|
|
||||||
import {
|
|
||||||
bindElementsToFramesAfterDuplication,
|
|
||||||
getFrameChildren,
|
|
||||||
} from "./frame";
|
|
||||||
|
|
||||||
import { normalizeElementOrder } from "./sortElements";
|
|
||||||
|
|
||||||
import { bumpVersion } from "./mutateElement";
|
|
||||||
|
|
||||||
import {
|
|
||||||
hasBoundTextElement,
|
|
||||||
isBoundToContainer,
|
|
||||||
isFrameLikeElement,
|
|
||||||
} from "./typeChecks";
|
|
||||||
|
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
|
||||||
|
|
||||||
import { fixDuplicatedBindingsAfterDuplication } from "./binding";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawElement,
|
|
||||||
GroupId,
|
|
||||||
NonDeletedSceneElementsMap,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Duplicate an element, often used in the alt-drag operation.
|
|
||||||
* Note that this method has gotten a bit complicated since the
|
|
||||||
* introduction of gruoping/ungrouping elements.
|
|
||||||
* @param editingGroupId The current group being edited. The new
|
|
||||||
* element will inherit this group and its
|
|
||||||
* parents.
|
|
||||||
* @param groupIdMapForOperation A Map that maps old group IDs to
|
|
||||||
* duplicated ones. If you are duplicating
|
|
||||||
* multiple elements at once, share this map
|
|
||||||
* amongst all of them
|
|
||||||
* @param element Element to duplicate
|
|
||||||
*/
|
|
||||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
|
||||||
editingGroupId: AppState["editingGroupId"],
|
|
||||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
|
||||||
element: TElement,
|
|
||||||
randomizeSeed?: boolean,
|
|
||||||
): Readonly<TElement> => {
|
|
||||||
const copy = deepCopyElement(element);
|
|
||||||
|
|
||||||
if (isTestEnv()) {
|
|
||||||
__test__defineOrigId(copy, element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.id = randomId();
|
|
||||||
copy.updated = getUpdatedTimestamp();
|
|
||||||
if (randomizeSeed) {
|
|
||||||
copy.seed = randomInteger();
|
|
||||||
bumpVersion(copy);
|
|
||||||
}
|
|
||||||
|
|
||||||
copy.groupIds = getNewGroupIdsForDuplication(
|
|
||||||
copy.groupIds,
|
|
||||||
editingGroupId,
|
|
||||||
(groupId) => {
|
|
||||||
if (!groupIdMapForOperation.has(groupId)) {
|
|
||||||
groupIdMapForOperation.set(groupId, randomId());
|
|
||||||
}
|
|
||||||
return groupIdMapForOperation.get(groupId)!;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return copy;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const duplicateElements = (
|
|
||||||
opts: {
|
|
||||||
elements: readonly ExcalidrawElement[];
|
|
||||||
randomizeSeed?: boolean;
|
|
||||||
overrides?: (data: {
|
|
||||||
duplicateElement: ExcalidrawElement;
|
|
||||||
origElement: ExcalidrawElement;
|
|
||||||
origIdToDuplicateId: Map<
|
|
||||||
ExcalidrawElement["id"],
|
|
||||||
ExcalidrawElement["id"]
|
|
||||||
>;
|
|
||||||
}) => Partial<ExcalidrawElement>;
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* Duplicates all elements in array.
|
|
||||||
*
|
|
||||||
* Use this when programmaticaly duplicating elements, without direct
|
|
||||||
* user interaction.
|
|
||||||
*/
|
|
||||||
type: "everything";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
/**
|
|
||||||
* Duplicates specified elements and inserts them back into the array
|
|
||||||
* in specified order.
|
|
||||||
*
|
|
||||||
* Use this when duplicating Scene elements, during user interaction
|
|
||||||
* such as alt-drag or on duplicate action.
|
|
||||||
*/
|
|
||||||
type: "in-place";
|
|
||||||
idsOfElementsToDuplicate: Map<
|
|
||||||
ExcalidrawElement["id"],
|
|
||||||
ExcalidrawElement
|
|
||||||
>;
|
|
||||||
appState: {
|
|
||||||
editingGroupId: AppState["editingGroupId"];
|
|
||||||
selectedGroupIds: AppState["selectedGroupIds"];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
),
|
|
||||||
) => {
|
|
||||||
let { elements } = opts;
|
|
||||||
|
|
||||||
const appState =
|
|
||||||
"appState" in opts
|
|
||||||
? opts.appState
|
|
||||||
: ({
|
|
||||||
editingGroupId: null,
|
|
||||||
selectedGroupIds: {},
|
|
||||||
} as const);
|
|
||||||
|
|
||||||
// Ids of elements that have already been processed so we don't push them
|
|
||||||
// into the array twice if we end up backtracking when retrieving
|
|
||||||
// discontiguous group of elements (can happen due to a bug, or in edge
|
|
||||||
// cases such as a group containing deleted elements which were not selected).
|
|
||||||
//
|
|
||||||
// This is not enough to prevent duplicates, so we do a second loop afterwards
|
|
||||||
// to remove them.
|
|
||||||
//
|
|
||||||
// For convenience we mark even the newly created ones even though we don't
|
|
||||||
// loop over them.
|
|
||||||
const processedIds = new Map<ExcalidrawElement["id"], true>();
|
|
||||||
const groupIdMap = new Map();
|
|
||||||
const duplicatedElements: ExcalidrawElement[] = [];
|
|
||||||
const origElements: ExcalidrawElement[] = [];
|
|
||||||
const origIdToDuplicateId = new Map<
|
|
||||||
ExcalidrawElement["id"],
|
|
||||||
ExcalidrawElement["id"]
|
|
||||||
>();
|
|
||||||
const duplicateIdToOrigElement = new Map<
|
|
||||||
ExcalidrawElement["id"],
|
|
||||||
ExcalidrawElement
|
|
||||||
>();
|
|
||||||
const duplicateElementsMap = new Map<string, ExcalidrawElement>();
|
|
||||||
const elementsMap = arrayToMap(elements) as ElementsMap;
|
|
||||||
const _idsOfElementsToDuplicate =
|
|
||||||
opts.type === "in-place"
|
|
||||||
? opts.idsOfElementsToDuplicate
|
|
||||||
: new Map(elements.map((el) => [el.id, el]));
|
|
||||||
|
|
||||||
// For sanity
|
|
||||||
if (opts.type === "in-place") {
|
|
||||||
for (const groupId of Object.keys(opts.appState.selectedGroupIds)) {
|
|
||||||
elements
|
|
||||||
.filter((el) => el.groupIds?.includes(groupId))
|
|
||||||
.forEach((el) => _idsOfElementsToDuplicate.set(el.id, el));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
elements = normalizeElementOrder(elements);
|
|
||||||
|
|
||||||
const elementsWithDuplicates: ExcalidrawElement[] = elements.slice();
|
|
||||||
|
|
||||||
// helper functions
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Used for the heavy lifing of copying a single element, a group of elements
|
|
||||||
// an element with bound text etc.
|
|
||||||
const copyElements = <T extends ExcalidrawElement | ExcalidrawElement[]>(
|
|
||||||
element: T,
|
|
||||||
): T extends ExcalidrawElement[]
|
|
||||||
? ExcalidrawElement[]
|
|
||||||
: ExcalidrawElement | null => {
|
|
||||||
const elements = castArray(element);
|
|
||||||
|
|
||||||
const _newElements = elements.reduce(
|
|
||||||
(acc: ExcalidrawElement[], element) => {
|
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
return acc;
|
|
||||||
}
|
|
||||||
|
|
||||||
processedIds.set(element.id, true);
|
|
||||||
|
|
||||||
const newElement = duplicateElement(
|
|
||||||
appState.editingGroupId,
|
|
||||||
groupIdMap,
|
|
||||||
element,
|
|
||||||
opts.randomizeSeed,
|
|
||||||
);
|
|
||||||
|
|
||||||
processedIds.set(newElement.id, true);
|
|
||||||
|
|
||||||
duplicateElementsMap.set(newElement.id, newElement);
|
|
||||||
origIdToDuplicateId.set(element.id, newElement.id);
|
|
||||||
duplicateIdToOrigElement.set(newElement.id, element);
|
|
||||||
|
|
||||||
origElements.push(element);
|
|
||||||
duplicatedElements.push(newElement);
|
|
||||||
|
|
||||||
acc.push(newElement);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
Array.isArray(element) ? _newElements : _newElements[0] || null
|
|
||||||
) as T extends ExcalidrawElement[]
|
|
||||||
? ExcalidrawElement[]
|
|
||||||
: ExcalidrawElement | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to position cloned elements in the Z-order the product needs it
|
|
||||||
const insertBeforeOrAfterIndex = (
|
|
||||||
index: number,
|
|
||||||
elements: ExcalidrawElement | null | ExcalidrawElement[],
|
|
||||||
) => {
|
|
||||||
if (!elements) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index > elementsWithDuplicates.length - 1) {
|
|
||||||
elementsWithDuplicates.push(...castArray(elements));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
|
|
||||||
};
|
|
||||||
|
|
||||||
const frameIdsToDuplicate = new Set(
|
|
||||||
elements
|
|
||||||
.filter(
|
|
||||||
(el) => _idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
|
|
||||||
)
|
|
||||||
.map((el) => el.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
if (processedIds.has(element.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_idsOfElementsToDuplicate.has(element.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// groups
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const groupId = getSelectedGroupForElement(appState, element);
|
|
||||||
if (groupId) {
|
|
||||||
const groupElements = getElementsInGroup(elements, groupId).flatMap(
|
|
||||||
(element) =>
|
|
||||||
isFrameLikeElement(element)
|
|
||||||
? [...getFrameChildren(elements, element.id), element]
|
|
||||||
: [element],
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
|
||||||
return el.groupIds?.includes(groupId);
|
|
||||||
});
|
|
||||||
|
|
||||||
insertBeforeOrAfterIndex(targetIndex, copyElements(groupElements));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// frame duplication
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFrameLikeElement(element)) {
|
|
||||||
const frameId = element.id;
|
|
||||||
|
|
||||||
const frameChildren = getFrameChildren(elements, frameId);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
|
||||||
return el.frameId === frameId || el.id === frameId;
|
|
||||||
});
|
|
||||||
|
|
||||||
insertBeforeOrAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
copyElements([...frameChildren, element]),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// text container
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if (hasBoundTextElement(element)) {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
|
||||||
return (
|
|
||||||
el.id === element.id ||
|
|
||||||
("containerId" in el && el.containerId === element.id)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
insertBeforeOrAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
copyElements([element, boundTextElement]),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBoundToContainer(element)) {
|
|
||||||
const container = getContainerElement(element, elementsMap);
|
|
||||||
|
|
||||||
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
|
|
||||||
return el.id === element.id || el.id === container?.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (container) {
|
|
||||||
insertBeforeOrAfterIndex(
|
|
||||||
targetIndex,
|
|
||||||
copyElements([container, element]),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
insertBeforeOrAfterIndex(targetIndex, copyElements(element));
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// default duplication (regular elements)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
insertBeforeOrAfterIndex(
|
|
||||||
findLastIndex(elementsWithDuplicates, (el) => el.id === element.id),
|
|
||||||
copyElements(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fixDuplicatedBindingsAfterDuplication(
|
|
||||||
duplicatedElements,
|
|
||||||
origIdToDuplicateId,
|
|
||||||
duplicateElementsMap as NonDeletedSceneElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
bindElementsToFramesAfterDuplication(
|
|
||||||
elementsWithDuplicates,
|
|
||||||
origElements,
|
|
||||||
origIdToDuplicateId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.overrides) {
|
|
||||||
for (const duplicateElement of duplicatedElements) {
|
|
||||||
const origElement = duplicateIdToOrigElement.get(duplicateElement.id);
|
|
||||||
if (origElement) {
|
|
||||||
Object.assign(
|
|
||||||
duplicateElement,
|
|
||||||
opts.overrides({
|
|
||||||
duplicateElement,
|
|
||||||
origElement,
|
|
||||||
origIdToDuplicateId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
duplicatedElements,
|
|
||||||
duplicateElementsMap,
|
|
||||||
elementsWithDuplicates,
|
|
||||||
origIdToDuplicateId,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
|
||||||
//
|
|
||||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
|
||||||
// Typed arrays and other non-null objects.
|
|
||||||
//
|
|
||||||
// Adapted from https://github.com/lukeed/klona
|
|
||||||
//
|
|
||||||
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
|
||||||
// passing ExcalidrawElement as the top-level argument).
|
|
||||||
const _deepCopyElement = (val: any, depth: number = 0) => {
|
|
||||||
// only clone non-primitives
|
|
||||||
if (val == null || typeof val !== "object") {
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectType = Object.prototype.toString.call(val);
|
|
||||||
|
|
||||||
if (objectType === "[object Object]") {
|
|
||||||
const tmp =
|
|
||||||
typeof val.constructor === "function"
|
|
||||||
? Object.create(Object.getPrototypeOf(val))
|
|
||||||
: {};
|
|
||||||
for (const key in val) {
|
|
||||||
if (val.hasOwnProperty(key)) {
|
|
||||||
// don't copy non-serializable objects like these caches. They'll be
|
|
||||||
// populated when the element is rendered.
|
|
||||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(val)) {
|
|
||||||
let k = val.length;
|
|
||||||
const arr = new Array(k);
|
|
||||||
while (k--) {
|
|
||||||
arr[k] = _deepCopyElement(val[k], depth + 1);
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we're not cloning non-array & non-plain-object objects because we
|
|
||||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
|
||||||
// sure we start cloning them, so let's warn about it.
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
if (
|
|
||||||
objectType !== "[object Object]" &&
|
|
||||||
objectType !== "[object Array]" &&
|
|
||||||
objectType.startsWith("[object ")
|
|
||||||
) {
|
|
||||||
console.warn(
|
|
||||||
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
|
||||||
* any value. The purpose is to to break object references for immutability
|
|
||||||
* reasons, whenever we want to keep the original element, but ensure it's not
|
|
||||||
* mutated.
|
|
||||||
*
|
|
||||||
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
|
||||||
* Typed arrays and other non-null objects.
|
|
||||||
*/
|
|
||||||
export const deepCopyElement = <T extends ExcalidrawElement>(
|
|
||||||
val: T,
|
|
||||||
): Mutable<T> => {
|
|
||||||
return _deepCopyElement(val);
|
|
||||||
};
|
|
||||||
|
|
||||||
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
|
||||||
Object.defineProperty(clonedObj, ORIG_ID, {
|
|
||||||
value: origId,
|
|
||||||
writable: false,
|
|
||||||
enumerable: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
|
||||||
pointFrom,
|
|
||||||
pointFromVector,
|
|
||||||
pointRotateRads,
|
|
||||||
pointScaleFromOrigin,
|
|
||||||
pointsEqual,
|
|
||||||
triangleIncludesPoint,
|
|
||||||
vectorCross,
|
|
||||||
vectorFromPoint,
|
|
||||||
vectorScale,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
LocalPoint,
|
|
||||||
GlobalPoint,
|
|
||||||
Triangle,
|
|
||||||
Vector,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
|
||||||
|
|
||||||
import type { ExcalidrawBindableElement } from "./types";
|
|
||||||
|
|
||||||
export const HEADING_RIGHT = [1, 0] as Heading;
|
|
||||||
export const HEADING_DOWN = [0, 1] as Heading;
|
|
||||||
export const HEADING_LEFT = [-1, 0] as Heading;
|
|
||||||
export const HEADING_UP = [0, -1] as Heading;
|
|
||||||
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
|
||||||
|
|
||||||
export const vectorToHeading = (vec: Vector): Heading => {
|
|
||||||
const [x, y] = vec;
|
|
||||||
const absX = Math.abs(x);
|
|
||||||
const absY = Math.abs(y);
|
|
||||||
if (x > absY) {
|
|
||||||
return HEADING_RIGHT;
|
|
||||||
} else if (x <= -absY) {
|
|
||||||
return HEADING_LEFT;
|
|
||||||
} else if (y > absX) {
|
|
||||||
return HEADING_DOWN;
|
|
||||||
}
|
|
||||||
return HEADING_UP;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p: P,
|
|
||||||
o: P,
|
|
||||||
) => vectorToHeading(vectorFromPoint<P>(p, o));
|
|
||||||
|
|
||||||
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p: P,
|
|
||||||
o: P,
|
|
||||||
) => headingIsHorizontal(headingForPoint<P>(p, o));
|
|
||||||
|
|
||||||
export const compareHeading = (a: Heading, b: Heading) =>
|
|
||||||
a[0] === b[0] && a[1] === b[1];
|
|
||||||
|
|
||||||
export const headingIsHorizontal = (a: Heading) =>
|
|
||||||
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
|
|
||||||
|
|
||||||
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
|
||||||
|
|
||||||
const headingForPointFromDiamondElement = (
|
|
||||||
element: Readonly<ExcalidrawBindableElement>,
|
|
||||||
aabb: Readonly<Bounds>,
|
|
||||||
point: Readonly<GlobalPoint>,
|
|
||||||
): Heading => {
|
|
||||||
const midPoint = getCenterForBounds(aabb);
|
|
||||||
|
|
||||||
if (isDevEnv() || isTestEnv()) {
|
|
||||||
invariant(
|
|
||||||
element.width > 0 && element.height > 0,
|
|
||||||
"Diamond element has no width or height",
|
|
||||||
);
|
|
||||||
invariant(
|
|
||||||
!pointsEqual(midPoint, point),
|
|
||||||
"The point is too close to the element mid point to determine heading",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHRINK = 0.95; // Rounded elements tolerance
|
|
||||||
const top = pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorFromPoint(
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
|
||||||
midPoint,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
),
|
|
||||||
SHRINK,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
);
|
|
||||||
const right = pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorFromPoint(
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + element.width,
|
|
||||||
element.y + element.height / 2,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
),
|
|
||||||
SHRINK,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
);
|
|
||||||
const bottom = pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorFromPoint(
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + element.width / 2,
|
|
||||||
element.y + element.height,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
),
|
|
||||||
SHRINK,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
);
|
|
||||||
const left = pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorFromPoint(
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
|
||||||
midPoint,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
),
|
|
||||||
SHRINK,
|
|
||||||
),
|
|
||||||
midPoint,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Corners
|
|
||||||
if (
|
|
||||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, right)) <=
|
|
||||||
0 &&
|
|
||||||
vectorCross(vectorFromPoint(point, top), vectorFromPoint(top, left)) > 0
|
|
||||||
) {
|
|
||||||
return headingForPoint(top, midPoint);
|
|
||||||
} else if (
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, right),
|
|
||||||
vectorFromPoint(right, bottom),
|
|
||||||
) <= 0 &&
|
|
||||||
vectorCross(vectorFromPoint(point, right), vectorFromPoint(right, top)) > 0
|
|
||||||
) {
|
|
||||||
return headingForPoint(right, midPoint);
|
|
||||||
} else if (
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, bottom),
|
|
||||||
vectorFromPoint(bottom, left),
|
|
||||||
) <= 0 &&
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, bottom),
|
|
||||||
vectorFromPoint(bottom, right),
|
|
||||||
) > 0
|
|
||||||
) {
|
|
||||||
return headingForPoint(bottom, midPoint);
|
|
||||||
} else if (
|
|
||||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, top)) <=
|
|
||||||
0 &&
|
|
||||||
vectorCross(vectorFromPoint(point, left), vectorFromPoint(left, bottom)) > 0
|
|
||||||
) {
|
|
||||||
return headingForPoint(left, midPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sides
|
|
||||||
if (
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, midPoint),
|
|
||||||
vectorFromPoint(top, midPoint),
|
|
||||||
) <= 0 &&
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, midPoint),
|
|
||||||
vectorFromPoint(right, midPoint),
|
|
||||||
) > 0
|
|
||||||
) {
|
|
||||||
const p = element.width > element.height ? top : right;
|
|
||||||
return headingForPoint(p, midPoint);
|
|
||||||
} else if (
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, midPoint),
|
|
||||||
vectorFromPoint(right, midPoint),
|
|
||||||
) <= 0 &&
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, midPoint),
|
|
||||||
vectorFromPoint(bottom, midPoint),
|
|
||||||
) > 0
|
|
||||||
) {
|
|
||||||
const p = element.width > element.height ? bottom : right;
|
|
||||||
return headingForPoint(p, midPoint);
|
|
||||||
} else if (
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, midPoint),
|
|
||||||
vectorFromPoint(bottom, midPoint),
|
|
||||||
) <= 0 &&
|
|
||||||
vectorCross(
|
|
||||||
vectorFromPoint(point, midPoint),
|
|
||||||
vectorFromPoint(left, midPoint),
|
|
||||||
) > 0
|
|
||||||
) {
|
|
||||||
const p = element.width > element.height ? bottom : left;
|
|
||||||
return headingForPoint(p, midPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
const p = element.width > element.height ? top : left;
|
|
||||||
return headingForPoint(p, midPoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gets the heading for the point by creating a bounding box around the rotated
|
|
||||||
// close fitting bounding box, then creating 4 search cones around the center of
|
|
||||||
// the external bbox.
|
|
||||||
export const headingForPointFromElement = <Point extends GlobalPoint>(
|
|
||||||
element: Readonly<ExcalidrawBindableElement>,
|
|
||||||
aabb: Readonly<Bounds>,
|
|
||||||
p: Readonly<Point>,
|
|
||||||
): Heading => {
|
|
||||||
const SEARCH_CONE_MULTIPLIER = 2;
|
|
||||||
|
|
||||||
const midPoint = getCenterForBounds(aabb);
|
|
||||||
|
|
||||||
if (element.type === "diamond") {
|
|
||||||
return headingForPointFromDiamondElement(element, aabb, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
const topLeft = pointScaleFromOrigin(
|
|
||||||
pointFrom(aabb[0], aabb[1]),
|
|
||||||
midPoint,
|
|
||||||
SEARCH_CONE_MULTIPLIER,
|
|
||||||
) as Point;
|
|
||||||
const topRight = pointScaleFromOrigin(
|
|
||||||
pointFrom(aabb[2], aabb[1]),
|
|
||||||
midPoint,
|
|
||||||
SEARCH_CONE_MULTIPLIER,
|
|
||||||
) as Point;
|
|
||||||
const bottomLeft = pointScaleFromOrigin(
|
|
||||||
pointFrom(aabb[0], aabb[3]),
|
|
||||||
midPoint,
|
|
||||||
SEARCH_CONE_MULTIPLIER,
|
|
||||||
) as Point;
|
|
||||||
const bottomRight = pointScaleFromOrigin(
|
|
||||||
pointFrom(aabb[2], aabb[3]),
|
|
||||||
midPoint,
|
|
||||||
SEARCH_CONE_MULTIPLIER,
|
|
||||||
) as Point;
|
|
||||||
|
|
||||||
return triangleIncludesPoint<Point>(
|
|
||||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
|
||||||
p,
|
|
||||||
)
|
|
||||||
? HEADING_UP
|
|
||||||
: triangleIncludesPoint<Point>(
|
|
||||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
|
||||||
p,
|
|
||||||
)
|
|
||||||
? HEADING_RIGHT
|
|
||||||
: triangleIncludesPoint<Point>(
|
|
||||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
|
||||||
p,
|
|
||||||
)
|
|
||||||
? HEADING_DOWN
|
|
||||||
: HEADING_LEFT;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const flipHeading = (h: Heading): Heading =>
|
|
||||||
[
|
|
||||||
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
|
|
||||||
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
|
|
||||||
] as Heading;
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { getCommonBounds } from "./bounds";
|
|
||||||
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "./types";
|
|
||||||
|
|
||||||
// TODO rewrite (mostly vibe-coded)
|
|
||||||
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
|
||||||
elements: TElement[] | TElement[][],
|
|
||||||
centerX: number,
|
|
||||||
centerY: number,
|
|
||||||
padding = 50,
|
|
||||||
): TElement[] => {
|
|
||||||
// Ensure there are elements to position
|
|
||||||
if (!elements || elements.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const res: TElement[] = [];
|
|
||||||
// Normalize input to work with atomic units (groups of elements)
|
|
||||||
// If elements is a flat array, treat each element as its own atomic unit
|
|
||||||
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
|
||||||
? (elements as TElement[][])
|
|
||||||
: (elements as TElement[]).map((element) => [element]);
|
|
||||||
|
|
||||||
// Determine the number of columns for atomic units
|
|
||||||
// A common approach for a "grid-like" layout without specific column constraints
|
|
||||||
// is to aim for a roughly square arrangement.
|
|
||||||
const numUnits = atomicUnits.length;
|
|
||||||
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
|
||||||
|
|
||||||
// Group atomic units into rows based on the calculated number of columns
|
|
||||||
const rows: TElement[][][] = [];
|
|
||||||
for (let i = 0; i < numUnits; i += numColumns) {
|
|
||||||
rows.push(atomicUnits.slice(i, i + numColumns));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate properties for each row (total width, max height)
|
|
||||||
// and the total actual height of all row content.
|
|
||||||
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
|
||||||
const rowProperties = rows.map((rowUnits) => {
|
|
||||||
let rowWidth = 0;
|
|
||||||
let maxUnitHeightInRow = 0;
|
|
||||||
|
|
||||||
const unitBounds = rowUnits.map((unit) => {
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
|
||||||
return {
|
|
||||||
elements: unit,
|
|
||||||
bounds: [minX, minY, maxX, maxY] as const,
|
|
||||||
width: maxX - minX,
|
|
||||||
height: maxY - minY,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
unitBounds.forEach((unitBound, index) => {
|
|
||||||
rowWidth += unitBound.width;
|
|
||||||
// Add padding between units in the same row, but not after the last one
|
|
||||||
if (index < unitBounds.length - 1) {
|
|
||||||
rowWidth += padding;
|
|
||||||
}
|
|
||||||
if (unitBound.height > maxUnitHeightInRow) {
|
|
||||||
maxUnitHeightInRow = unitBound.height;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
totalGridActualHeight += maxUnitHeightInRow;
|
|
||||||
return {
|
|
||||||
unitBounds,
|
|
||||||
width: rowWidth,
|
|
||||||
maxHeight: maxUnitHeightInRow,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate the total height of the grid including padding between rows
|
|
||||||
const totalGridHeightWithPadding =
|
|
||||||
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
|
||||||
|
|
||||||
// Calculate the starting Y position to center the entire grid vertically around centerY
|
|
||||||
let currentY = centerY - totalGridHeightWithPadding / 2;
|
|
||||||
|
|
||||||
// Position atomic units row by row
|
|
||||||
rowProperties.forEach((rowProp) => {
|
|
||||||
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
|
||||||
|
|
||||||
// Calculate the starting X for the current row to center it horizontally around centerX
|
|
||||||
let currentX = centerX - rowWidth / 2;
|
|
||||||
|
|
||||||
unitBounds.forEach((unitBound) => {
|
|
||||||
// Calculate the offset needed to position this atomic unit
|
|
||||||
const [originalMinX, originalMinY] = unitBound.bounds;
|
|
||||||
const offsetX = currentX - originalMinX;
|
|
||||||
const offsetY = currentY - originalMinY;
|
|
||||||
|
|
||||||
// Apply the offset to all elements in this atomic unit
|
|
||||||
unitBound.elements.forEach((element) => {
|
|
||||||
res.push(
|
|
||||||
newElementWith(element, {
|
|
||||||
x: element.x + offsetX,
|
|
||||||
y: element.y + offsetY,
|
|
||||||
} as ElementUpdate<TElement>),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move X for the next unit in the row
|
|
||||||
currentX += unitBound.width + padding;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move Y to the starting position for the next row
|
|
||||||
// This accounts for the tallest unit in the current row and the inter-row padding
|
|
||||||
currentY += rowMaxHeight + padding;
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,483 +0,0 @@
|
|||||||
import {
|
|
||||||
DEFAULT_ADAPTIVE_RADIUS,
|
|
||||||
DEFAULT_PROPORTIONAL_RADIUS,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
|
||||||
ROUNDNESS,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
|
||||||
curve,
|
|
||||||
curveCatmullRomCubicApproxPoints,
|
|
||||||
curveOffsetPoints,
|
|
||||||
lineSegment,
|
|
||||||
pointDistance,
|
|
||||||
pointFrom,
|
|
||||||
pointFromArray,
|
|
||||||
rectangle,
|
|
||||||
type GlobalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { getDiamondPoints } from "./bounds";
|
|
||||||
|
|
||||||
import { generateLinearCollisionShape } from "./shape";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawDiamondElement,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawRectanguloidElement,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
|
||||||
|
|
||||||
const ElementShapesCache = new WeakMap<
|
|
||||||
ExcalidrawElement,
|
|
||||||
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
|
||||||
>();
|
|
||||||
|
|
||||||
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
|
||||||
element: T,
|
|
||||||
offset: number,
|
|
||||||
): ElementShape | undefined => {
|
|
||||||
const record = ElementShapesCache.get(element);
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { version, shapes } = record;
|
|
||||||
|
|
||||||
if (version !== element.version) {
|
|
||||||
ElementShapesCache.delete(element);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return shapes.get(offset);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
|
||||||
element: T,
|
|
||||||
shape: ElementShape,
|
|
||||||
offset: number,
|
|
||||||
) => {
|
|
||||||
const record = ElementShapesCache.get(element);
|
|
||||||
|
|
||||||
if (!record) {
|
|
||||||
ElementShapesCache.set(element, {
|
|
||||||
version: element.version,
|
|
||||||
shapes: new Map([[offset, shape]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { version, shapes } = record;
|
|
||||||
|
|
||||||
if (version !== element.version) {
|
|
||||||
ElementShapesCache.set(element, {
|
|
||||||
version: element.version,
|
|
||||||
shapes: new Map([[offset, shape]]),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
shapes.set(offset, shape);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
|
||||||
*
|
|
||||||
* @param element The linear element to deconstruct
|
|
||||||
* @returns The rotated in components.
|
|
||||||
*/
|
|
||||||
export function deconstructLinearOrFreeDrawElement(
|
|
||||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
|
||||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
|
||||||
|
|
||||||
if (cachedShape) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ops = generateLinearCollisionShape(element) as {
|
|
||||||
op: string;
|
|
||||||
data: number[];
|
|
||||||
}[];
|
|
||||||
const lines = [];
|
|
||||||
const curves = [];
|
|
||||||
|
|
||||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
|
||||||
const op = ops[idx];
|
|
||||||
const prevPoint =
|
|
||||||
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
|
||||||
switch (op.op) {
|
|
||||||
case "move":
|
|
||||||
continue;
|
|
||||||
case "lineTo":
|
|
||||||
if (!prevPoint) {
|
|
||||||
throw new Error("prevPoint is undefined");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + prevPoint[0],
|
|
||||||
element.y + prevPoint[1],
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + op.data[0],
|
|
||||||
element.y + op.data[1],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
case "bcurveTo":
|
|
||||||
if (!prevPoint) {
|
|
||||||
throw new Error("prevPoint is undefined");
|
|
||||||
}
|
|
||||||
|
|
||||||
curves.push(
|
|
||||||
curve<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + prevPoint[0],
|
|
||||||
element.y + prevPoint[1],
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + op.data[0],
|
|
||||||
element.y + op.data[1],
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + op.data[2],
|
|
||||||
element.y + op.data[3],
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
element.x + op.data[4],
|
|
||||||
element.y + op.data[5],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
default: {
|
|
||||||
console.error("Unknown op type", op.op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shape = [lines, curves] as ElementShape;
|
|
||||||
setElementShapesCacheEntry(element, shape, 0);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the building components of a rectanguloid element in the form of
|
|
||||||
* line segments and curves **unrotated**.
|
|
||||||
*
|
|
||||||
* @param element Target rectanguloid element
|
|
||||||
* @param offset Optional offset to expand the rectanguloid shape
|
|
||||||
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
|
||||||
*/
|
|
||||||
export function deconstructRectanguloidElement(
|
|
||||||
element: ExcalidrawRectanguloidElement,
|
|
||||||
offset: number = 0,
|
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
|
||||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
|
||||||
|
|
||||||
if (cachedShape) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
let radius = getCornerRadius(
|
|
||||||
Math.min(element.width, element.height),
|
|
||||||
element,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (radius === 0) {
|
|
||||||
radius = 0.01;
|
|
||||||
}
|
|
||||||
|
|
||||||
const r = rectangle(
|
|
||||||
pointFrom(element.x, element.y),
|
|
||||||
pointFrom(element.x + element.width, element.y + element.height),
|
|
||||||
);
|
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
|
||||||
);
|
|
||||||
const right = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
|
||||||
);
|
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
|
||||||
);
|
|
||||||
const left = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseCorners = [
|
|
||||||
curve(
|
|
||||||
left[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
|
||||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
|
||||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
|
||||||
),
|
|
||||||
top[0],
|
|
||||||
), // TOP LEFT
|
|
||||||
curve(
|
|
||||||
top[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
|
||||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
|
||||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
|
||||||
),
|
|
||||||
right[0],
|
|
||||||
), // TOP RIGHT
|
|
||||||
curve(
|
|
||||||
right[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
|
||||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
|
||||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
|
||||||
),
|
|
||||||
bottom[1],
|
|
||||||
), // BOTTOM RIGHT
|
|
||||||
curve(
|
|
||||||
bottom[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
|
||||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
|
||||||
),
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
|
||||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
|
||||||
),
|
|
||||||
left[0],
|
|
||||||
), // BOTTOM LEFT
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners =
|
|
||||||
offset > 0
|
|
||||||
? baseCorners.map(
|
|
||||||
(corner) =>
|
|
||||||
curveCatmullRomCubicApproxPoints(
|
|
||||||
curveOffsetPoints(corner, offset),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
[baseCorners[0]],
|
|
||||||
[baseCorners[1]],
|
|
||||||
[baseCorners[2]],
|
|
||||||
[baseCorners[3]],
|
|
||||||
];
|
|
||||||
|
|
||||||
const sides = [
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[0][corners[0].length - 1][3],
|
|
||||||
corners[1][0][0],
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[1][corners[1].length - 1][3],
|
|
||||||
corners[2][0][0],
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[2][corners[2].length - 1][3],
|
|
||||||
corners[3][0][0],
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[3][corners[3].length - 1][3],
|
|
||||||
corners[0][0][0],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const shape = [sides, corners.flat()] as ElementShape;
|
|
||||||
|
|
||||||
setElementShapesCacheEntry(element, shape, offset);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the **unrotated** building components of a diamond element
|
|
||||||
* in the form of line segments and curves as a tuple, in this order.
|
|
||||||
*
|
|
||||||
* @param element The element to deconstruct
|
|
||||||
* @param offset An optional offset
|
|
||||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
|
||||||
*/
|
|
||||||
export function deconstructDiamondElement(
|
|
||||||
element: ExcalidrawDiamondElement,
|
|
||||||
offset: number = 0,
|
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
|
||||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
|
||||||
|
|
||||||
if (cachedShape) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
|
||||||
getDiamondPoints(element);
|
|
||||||
const verticalRadius = element.roundness
|
|
||||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
|
||||||
: (topX - leftX) * 0.01;
|
|
||||||
const horizontalRadius = element.roundness
|
|
||||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
|
||||||
: (rightY - topY) * 0.01;
|
|
||||||
|
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
|
||||||
pointFrom(element.x + topX, element.y + topY),
|
|
||||||
pointFrom(element.x + rightX, element.y + rightY),
|
|
||||||
pointFrom(element.x + bottomX, element.y + bottomY),
|
|
||||||
pointFrom(element.x + leftX, element.y + leftY),
|
|
||||||
];
|
|
||||||
|
|
||||||
const baseCorners = [
|
|
||||||
curve(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
right[0] - verticalRadius,
|
|
||||||
right[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
right,
|
|
||||||
right,
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
right[0] - verticalRadius,
|
|
||||||
right[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
), // RIGHT
|
|
||||||
curve(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
bottom[0] + verticalRadius,
|
|
||||||
bottom[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
bottom,
|
|
||||||
bottom,
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
bottom[0] - verticalRadius,
|
|
||||||
bottom[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
), // BOTTOM
|
|
||||||
curve(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
left[0] + verticalRadius,
|
|
||||||
left[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
left,
|
|
||||||
left,
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
left[0] + verticalRadius,
|
|
||||||
left[1] - horizontalRadius,
|
|
||||||
),
|
|
||||||
), // LEFT
|
|
||||||
curve(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
top[0] - verticalRadius,
|
|
||||||
top[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
top,
|
|
||||||
top,
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
top[0] + verticalRadius,
|
|
||||||
top[1] + horizontalRadius,
|
|
||||||
),
|
|
||||||
), // TOP
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners =
|
|
||||||
offset > 0
|
|
||||||
? baseCorners.map(
|
|
||||||
(corner) =>
|
|
||||||
curveCatmullRomCubicApproxPoints(
|
|
||||||
curveOffsetPoints(corner, offset),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
[baseCorners[0]],
|
|
||||||
[baseCorners[1]],
|
|
||||||
[baseCorners[2]],
|
|
||||||
[baseCorners[3]],
|
|
||||||
];
|
|
||||||
|
|
||||||
const sides = [
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[0][corners[0].length - 1][3],
|
|
||||||
corners[1][0][0],
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[1][corners[1].length - 1][3],
|
|
||||||
corners[2][0][0],
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[2][corners[2].length - 1][3],
|
|
||||||
corners[3][0][0],
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
corners[3][corners[3].length - 1][3],
|
|
||||||
corners[0][0][0],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const shape = [sides, corners.flat()] as ElementShape;
|
|
||||||
|
|
||||||
setElementShapesCacheEntry(element, shape, offset);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checks if the first and last point are close enough
|
|
||||||
// to be considered a loop
|
|
||||||
export const isPathALoop = (
|
|
||||||
points: ExcalidrawLinearElement["points"],
|
|
||||||
/** supply if you want the loop detection to account for current zoom */
|
|
||||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
|
||||||
): boolean => {
|
|
||||||
if (points.length >= 3) {
|
|
||||||
const [first, last] = [points[0], points[points.length - 1]];
|
|
||||||
const distance = pointDistance(first, last);
|
|
||||||
|
|
||||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
|
||||||
// really close we make the threshold smaller, and vice versa.
|
|
||||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
|
||||||
if (
|
|
||||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
|
||||||
element.roundness?.type === ROUNDNESS.LEGACY
|
|
||||||
) {
|
|
||||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
|
||||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
|
||||||
|
|
||||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
|
|
||||||
if (x <= CUTOFF_SIZE) {
|
|
||||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fixedRadiusSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
|
||||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|
||||||
import "@excalidraw/utils/test-utils";
|
|
||||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
|
||||||
|
|
||||||
import { hitElementItself } from "../src/collision";
|
|
||||||
|
|
||||||
describe("check rotated elements can be hit:", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
localStorage.clear();
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("arrow", () => {
|
|
||||||
UI.createElement("arrow", {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 124,
|
|
||||||
height: 302,
|
|
||||||
angle: 1.8700426423973724,
|
|
||||||
points: [
|
|
||||||
[0, 0],
|
|
||||||
[120, -198],
|
|
||||||
[-4, -302],
|
|
||||||
] as LocalPoint[],
|
|
||||||
});
|
|
||||||
//const p = [120, -211];
|
|
||||||
//const p = [0, 13];
|
|
||||||
const hit = hitElementItself({
|
|
||||||
point: pointFrom<GlobalPoint>(88, -68),
|
|
||||||
element: window.h.elements[0],
|
|
||||||
threshold: 10,
|
|
||||||
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
|
||||||
});
|
|
||||||
expect(hit).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
|
|
||||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
|
||||||
import type { LinearElementEditor } from "@excalidraw/element";
|
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
|
||||||
|
|
||||||
describe("ElementsDelta", () => {
|
|
||||||
describe("elements delta calculation", () => {
|
|
||||||
it("should not throw when element gets removed but was already deleted", () => {
|
|
||||||
const element = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevElements = new Map([[element.id, element]]);
|
|
||||||
const nextElements = new Map();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
ElementsDelta.calculate(prevElements, nextElements),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when adding element as already deleted", () => {
|
|
||||||
const element = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevElements = new Map();
|
|
||||||
const nextElements = new Map([[element.id, element]]);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
ElementsDelta.calculate(prevElements, nextElements),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create updated delta even when there is only version and versionNonce change", () => {
|
|
||||||
const baseElement = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
strokeColor: "#000000",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
});
|
|
||||||
|
|
||||||
const modifiedElement = {
|
|
||||||
...baseElement,
|
|
||||||
version: baseElement.version + 1,
|
|
||||||
versionNonce: baseElement.versionNonce + 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create maps for the delta calculation
|
|
||||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
|
||||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
|
||||||
|
|
||||||
// Calculate the delta
|
|
||||||
const delta = ElementsDelta.calculate(
|
|
||||||
prevElements as SceneElementsMap,
|
|
||||||
nextElements as SceneElementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(delta).toEqual(
|
|
||||||
ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
[baseElement.id]: Delta.create(
|
|
||||||
{
|
|
||||||
version: baseElement.version,
|
|
||||||
versionNonce: baseElement.versionNonce,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: baseElement.version + 1,
|
|
||||||
versionNonce: baseElement.versionNonce + 1,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("squash", () => {
|
|
||||||
it("should not squash when second delta is empty", () => {
|
|
||||||
const updatedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{ id1: updatedDelta },
|
|
||||||
);
|
|
||||||
const elementsDelta2 = ElementsDelta.empty();
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash mutually exclusive delta types", () => {
|
|
||||||
const addedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
const removedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedDelta = Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{ id1: addedDelta },
|
|
||||||
{ id2: removedDelta },
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{ id3: updatedDelta },
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
|
||||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
|
||||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash the same delta types", () => {
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 200, version: 2, versionNonce: 2 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2 },
|
|
||||||
{ y: 200, version: 3, versionNonce: 3 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.added.id1).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(elementsDelta.removed.id2).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(elementsDelta.updated.id3).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
|
||||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash different delta types ", () => {
|
|
||||||
// id1: added -> updated => added
|
|
||||||
// id2: removed -> added => added
|
|
||||||
// id3: updated -> removed => removed
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
|
||||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
|
||||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ x: 300, version: 1, versionNonce: 1 },
|
|
||||||
{ x: 301, version: 2, versionNonce: 2 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{
|
|
||||||
id2: Delta.create(
|
|
||||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id3: Delta.create(
|
|
||||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{ y: 100, version: 2, versionNonce: 2 },
|
|
||||||
{ y: 101, version: 3, versionNonce: 3 },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(elementsDelta).toBe(elementsDelta1);
|
|
||||||
expect(elementsDelta.added).toEqual({
|
|
||||||
id1: Delta.create(
|
|
||||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
id2: Delta.create(
|
|
||||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
|
||||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(elementsDelta.removed).toEqual({
|
|
||||||
id3: Delta.create(
|
|
||||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
|
||||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(elementsDelta.updated).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash bound elements", () => {
|
|
||||||
const elementsDelta1 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
boundElements: [{ id: "t1", type: "text" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 2,
|
|
||||||
versionNonce: 2,
|
|
||||||
boundElements: [{ id: "t2", type: "text" }],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta2 = ElementsDelta.create(
|
|
||||||
{},
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
id1: Delta.create(
|
|
||||||
{
|
|
||||||
version: 2,
|
|
||||||
versionNonce: 2,
|
|
||||||
boundElements: [{ id: "a1", type: "arrow" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: 3,
|
|
||||||
versionNonce: 3,
|
|
||||||
boundElements: [{ id: "a2", type: "arrow" }],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
|
||||||
|
|
||||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
|
||||||
{ id: "t1", type: "text" },
|
|
||||||
{ id: "a1", type: "arrow" },
|
|
||||||
]);
|
|
||||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
|
||||||
{ id: "t2", type: "text" },
|
|
||||||
{ id: "a2", type: "arrow" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AppStateDelta", () => {
|
|
||||||
describe("ensure stable delta properties order", () => {
|
|
||||||
it("should maintain stable order for root properties", () => {
|
|
||||||
const name = "untitled scene";
|
|
||||||
const selectedLinearElement = {
|
|
||||||
elementId: "id1" as LinearElementEditor["elementId"],
|
|
||||||
isEditing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const commonAppState = {
|
|
||||||
viewBackgroundColor: "#ffffff",
|
|
||||||
selectedElementIds: {},
|
|
||||||
selectedGroupIds: {},
|
|
||||||
editingGroupId: null,
|
|
||||||
croppingElementId: null,
|
|
||||||
editingLinearElementId: null,
|
|
||||||
selectedLinearElementIsEditing: null,
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
activeLockedId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevAppState1: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
name: "",
|
|
||||||
selectedLinearElement: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
name,
|
|
||||||
selectedLinearElement,
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
|
||||||
selectedLinearElement: null,
|
|
||||||
name: "",
|
|
||||||
...commonAppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
|
||||||
selectedLinearElement,
|
|
||||||
name,
|
|
||||||
...commonAppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
|
||||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
|
||||||
|
|
||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain stable order for selectedElementIds", () => {
|
|
||||||
const commonAppState = {
|
|
||||||
name: "",
|
|
||||||
viewBackgroundColor: "#ffffff",
|
|
||||||
selectedGroupIds: {},
|
|
||||||
editingGroupId: null,
|
|
||||||
croppingElementId: null,
|
|
||||||
selectedLinearElement: null,
|
|
||||||
activeLockedId: null,
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevAppState1: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedElementIds: { id5: true, id2: true, id4: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedElementIds: {
|
|
||||||
id1: true,
|
|
||||||
id2: true,
|
|
||||||
id3: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedElementIds: { id4: true, id2: true, id5: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedElementIds: {
|
|
||||||
id3: true,
|
|
||||||
id2: true,
|
|
||||||
id1: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
|
||||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
|
||||||
|
|
||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain stable order for selectedGroupIds", () => {
|
|
||||||
const commonAppState = {
|
|
||||||
name: "",
|
|
||||||
viewBackgroundColor: "#ffffff",
|
|
||||||
selectedElementIds: {},
|
|
||||||
editingGroupId: null,
|
|
||||||
croppingElementId: null,
|
|
||||||
selectedLinearElement: null,
|
|
||||||
activeLockedId: null,
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevAppState1: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedGroupIds: { id5: false, id2: true, id4: true, id0: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedGroupIds: {
|
|
||||||
id0: true,
|
|
||||||
id1: true,
|
|
||||||
id2: false,
|
|
||||||
id3: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedGroupIds: { id0: true, id4: true, id2: true, id5: false },
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
|
||||||
...commonAppState,
|
|
||||||
selectedGroupIds: {
|
|
||||||
id3: true,
|
|
||||||
id2: false,
|
|
||||||
id1: true,
|
|
||||||
id0: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const delta1 = AppStateDelta.calculate(prevAppState1, nextAppState1);
|
|
||||||
const delta2 = AppStateDelta.calculate(prevAppState2, nextAppState2);
|
|
||||||
|
|
||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("squash", () => {
|
|
||||||
it("should not squash when second delta is empty", () => {
|
|
||||||
const delta = Delta.create(
|
|
||||||
{ name: "untitled scene" },
|
|
||||||
{ name: "titled scene" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const appStateDelta1 = AppStateDelta.create(delta);
|
|
||||||
const appStateDelta2 = AppStateDelta.empty();
|
|
||||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
|
||||||
|
|
||||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(appStateDelta).toBe(appStateDelta1);
|
|
||||||
expect(appStateDelta.delta).toBe(delta);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash exclusive properties", () => {
|
|
||||||
const delta1 = Delta.create(
|
|
||||||
{ name: "untitled scene" },
|
|
||||||
{ name: "titled scene" },
|
|
||||||
);
|
|
||||||
const delta2 = Delta.create(
|
|
||||||
{ viewBackgroundColor: "#ffffff" },
|
|
||||||
{ viewBackgroundColor: "#000000" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
|
||||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
|
||||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
|
||||||
|
|
||||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(appStateDelta).toBe(appStateDelta1);
|
|
||||||
expect(appStateDelta.delta).toEqual(
|
|
||||||
Delta.create(
|
|
||||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
|
||||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
|
||||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
|
||||||
{
|
|
||||||
name: "untitled scene",
|
|
||||||
selectedElementIds: { id1: true },
|
|
||||||
selectedGroupIds: {},
|
|
||||||
lockedMultiSelections: { g1: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "titled scene",
|
|
||||||
selectedElementIds: { id2: true },
|
|
||||||
selectedGroupIds: { g1: true },
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
|
||||||
{
|
|
||||||
selectedElementIds: { id3: true },
|
|
||||||
selectedGroupIds: { g1: true },
|
|
||||||
lockedMultiSelections: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
selectedElementIds: { id2: true },
|
|
||||||
selectedGroupIds: { g2: true, g3: true },
|
|
||||||
lockedMultiSelections: { g3: true },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
|
||||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
|
||||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
|
||||||
|
|
||||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
|
||||||
expect(appStateDelta).toBe(appStateDelta1);
|
|
||||||
expect(appStateDelta.delta).toEqual(
|
|
||||||
Delta.create<Partial<ObservedAppState>>(
|
|
||||||
{
|
|
||||||
name: "untitled scene",
|
|
||||||
selectedElementIds: { id1: true, id3: true },
|
|
||||||
selectedGroupIds: { g1: true },
|
|
||||||
lockedMultiSelections: { g1: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "titled scene",
|
|
||||||
selectedElementIds: { id2: true },
|
|
||||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
|
||||||
lockedMultiSelections: { g3: true },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import {
|
|
||||||
distributeHorizontally,
|
|
||||||
distributeVertically,
|
|
||||||
} from "@excalidraw/excalidraw/actions";
|
|
||||||
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
|
||||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
unmountComponent,
|
|
||||||
render,
|
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
|
||||||
|
|
||||||
const mouse = new Pointer("mouse");
|
|
||||||
|
|
||||||
// Scenario: three rectangles that will be distributed with gaps
|
|
||||||
const createAndSelectThreeRectanglesWithGap = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(10, 10);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(300, 300);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
// Last rectangle is selected by default
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.click(0, 10);
|
|
||||||
mouse.click(10, 0);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scenario: three rectangles that will be distributed by their centers
|
|
||||||
const createAndSelectThreeRectanglesWithoutGap = () => {
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down();
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(10, 10);
|
|
||||||
mouse.up(200, 200);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
UI.clickTool("rectangle");
|
|
||||||
mouse.down(200, 200);
|
|
||||||
mouse.up(100, 100);
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
// Last rectangle is selected by default
|
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
|
||||||
mouse.click(0, 10);
|
|
||||||
mouse.click(10, 0);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("distributing", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
unmountComponent();
|
|
||||||
mouse.reset();
|
|
||||||
|
|
||||||
await act(() => {
|
|
||||||
return setLanguage(defaultLang);
|
|
||||||
});
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements horizontally", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithGap();
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
|
||||||
|
|
||||||
API.executeAction(distributeHorizontally);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements vertically", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithGap();
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
|
||||||
|
|
||||||
API.executeAction(distributeVertically);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements horizontally based on their centers", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithoutGap();
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(distributeHorizontally);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should distribute selected elements vertically with based on their centers", async () => {
|
|
||||||
createAndSelectThreeRectanglesWithoutGap();
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
|
|
||||||
API.executeAction(distributeVertically);
|
|
||||||
|
|
||||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
|
||||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
|
||||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,849 +0,0 @@
|
|||||||
import { pointFrom } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import {
|
|
||||||
FONT_FAMILY,
|
|
||||||
ORIG_ID,
|
|
||||||
ROUNDNESS,
|
|
||||||
isPrimitive,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
|
|
||||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
|
||||||
|
|
||||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions";
|
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
|
|
||||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|
||||||
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
assertElements,
|
|
||||||
getCloneByOrigId,
|
|
||||||
render,
|
|
||||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import { duplicateElement, duplicateElements } from "../src/duplicate";
|
|
||||||
|
|
||||||
import type { ExcalidrawLinearElement } from "../src/types";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
const mouse = new Pointer("mouse");
|
|
||||||
|
|
||||||
const assertCloneObjects = (source: any, clone: any) => {
|
|
||||||
for (const key in clone) {
|
|
||||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
|
||||||
expect(clone[key]).not.toBe(source[key]);
|
|
||||||
if (source[key]) {
|
|
||||||
assertCloneObjects(source[key], clone[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("duplicating single elements", () => {
|
|
||||||
it("clones arrow element", () => {
|
|
||||||
const element = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
strokeColor: "#000000",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
fillStyle: "hachure",
|
|
||||||
strokeWidth: 1,
|
|
||||||
strokeStyle: "solid",
|
|
||||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
|
||||||
roughness: 1,
|
|
||||||
opacity: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
element.__proto__ = { hello: "world" };
|
|
||||||
|
|
||||||
mutateElement(element, new Map(), {
|
|
||||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
|
||||||
});
|
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element, true);
|
|
||||||
|
|
||||||
assertCloneObjects(element, copy);
|
|
||||||
|
|
||||||
// assert we clone the object's prototype
|
|
||||||
// @ts-ignore
|
|
||||||
expect(copy.__proto__).toEqual({ hello: "world" });
|
|
||||||
expect(copy.hasOwnProperty("hello")).toBe(false);
|
|
||||||
|
|
||||||
expect(copy.points).not.toBe(element.points);
|
|
||||||
expect(copy).not.toHaveProperty("shape");
|
|
||||||
expect(copy.id).not.toBe(element.id);
|
|
||||||
expect(typeof copy.id).toBe("string");
|
|
||||||
expect(copy.seed).not.toBe(element.seed);
|
|
||||||
expect(typeof copy.seed).toBe("number");
|
|
||||||
expect(copy).toEqual({
|
|
||||||
...element,
|
|
||||||
id: copy.id,
|
|
||||||
seed: copy.seed,
|
|
||||||
version: copy.version,
|
|
||||||
versionNonce: copy.versionNonce,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clones text element", () => {
|
|
||||||
const element = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
strokeColor: "#000000",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
fillStyle: "hachure",
|
|
||||||
strokeWidth: 1,
|
|
||||||
strokeStyle: "solid",
|
|
||||||
roundness: null,
|
|
||||||
roughness: 1,
|
|
||||||
opacity: 100,
|
|
||||||
text: "hello",
|
|
||||||
fontSize: 20,
|
|
||||||
fontFamily: FONT_FAMILY.Virgil,
|
|
||||||
textAlign: "left",
|
|
||||||
verticalAlign: "top",
|
|
||||||
});
|
|
||||||
|
|
||||||
const copy = duplicateElement(null, new Map(), element);
|
|
||||||
|
|
||||||
assertCloneObjects(element, copy);
|
|
||||||
|
|
||||||
expect(copy).not.toHaveProperty("points");
|
|
||||||
expect(copy).not.toHaveProperty("shape");
|
|
||||||
expect(copy.id).not.toBe(element.id);
|
|
||||||
expect(typeof copy.id).toBe("string");
|
|
||||||
expect(typeof copy.seed).toBe("number");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplicating multiple elements", () => {
|
|
||||||
it("duplicateElements should clone bindings", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
id: "rectangle1",
|
|
||||||
boundElements: [
|
|
||||||
{ id: "arrow1", type: "arrow" },
|
|
||||||
{ id: "arrow2", type: "arrow" },
|
|
||||||
{ id: "text1", type: "text" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const text1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
id: "text1",
|
|
||||||
containerId: "rectangle1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow1 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startBinding: {
|
|
||||||
elementId: "rectangle1",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow2 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow2",
|
|
||||||
endBinding: {
|
|
||||||
elementId: "rectangle1",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
boundElements: [{ id: "text2", type: "text" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const text2 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
id: "text2",
|
|
||||||
containerId: "arrow2",
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
|
||||||
const { duplicatedElements } = duplicateElements({
|
|
||||||
type: "everything",
|
|
||||||
elements: origElements,
|
|
||||||
});
|
|
||||||
|
|
||||||
// generic id in-equality checks
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
expect(origElements.map((e) => e.type)).toEqual(
|
|
||||||
duplicatedElements.map((e) => e.type),
|
|
||||||
);
|
|
||||||
origElements.forEach((origElement, idx) => {
|
|
||||||
const clonedElement = duplicatedElements[idx];
|
|
||||||
expect(origElement).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: expect.not.stringMatching(clonedElement.id),
|
|
||||||
type: clonedElement.type,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if ("containerId" in origElement) {
|
|
||||||
expect(origElement.containerId).not.toBe(
|
|
||||||
(clonedElement as any).containerId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ("endBinding" in origElement) {
|
|
||||||
if (origElement.endBinding) {
|
|
||||||
expect(origElement.endBinding.elementId).not.toBe(
|
|
||||||
(clonedElement as any).endBinding?.elementId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
expect((clonedElement as any).endBinding).toBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ("startBinding" in origElement) {
|
|
||||||
if (origElement.startBinding) {
|
|
||||||
expect(origElement.startBinding.elementId).not.toBe(
|
|
||||||
(clonedElement as any).startBinding?.elementId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
expect((clonedElement as any).startBinding).toBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const clonedArrows = duplicatedElements.filter(
|
|
||||||
(e) => e.type === "arrow",
|
|
||||||
) as ExcalidrawLinearElement[];
|
|
||||||
|
|
||||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
|
||||||
duplicatedElements as any as typeof origElements;
|
|
||||||
|
|
||||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
|
||||||
expect(
|
|
||||||
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
|
|
||||||
).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: clonedText1.id,
|
|
||||||
type: clonedText1.type,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(clonedRectangle.type).toBe("rectangle");
|
|
||||||
|
|
||||||
clonedArrows.forEach((arrow) => {
|
|
||||||
expect(
|
|
||||||
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
|
|
||||||
).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: arrow.id,
|
|
||||||
type: arrow.type,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (arrow.endBinding) {
|
|
||||||
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
|
|
||||||
}
|
|
||||||
if (arrow.startBinding) {
|
|
||||||
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(clonedArrow2.boundElements).toEqual([
|
|
||||||
{ type: "text", id: clonedArrowLabel.id },
|
|
||||||
]);
|
|
||||||
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove id references of elements that aren't found", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
id: "rectangle1",
|
|
||||||
boundElements: [
|
|
||||||
// should keep
|
|
||||||
{ id: "arrow1", type: "arrow" },
|
|
||||||
// should drop
|
|
||||||
{ id: "arrow-not-exists", type: "arrow" },
|
|
||||||
// should drop
|
|
||||||
{ id: "text-not-exists", type: "text" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow1 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startBinding: {
|
|
||||||
elementId: "rectangle1",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text1 = API.createElement({
|
|
||||||
type: "text",
|
|
||||||
id: "text1",
|
|
||||||
containerId: "rectangle-not-exists",
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow2 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow2",
|
|
||||||
startBinding: {
|
|
||||||
elementId: "rectangle1",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
endBinding: {
|
|
||||||
elementId: "rectangle-not-exists",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow3 = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow3",
|
|
||||||
startBinding: {
|
|
||||||
elementId: "rectangle-not-exists",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
endBinding: {
|
|
||||||
elementId: "rectangle1",
|
|
||||||
focus: 0.2,
|
|
||||||
gap: 7,
|
|
||||||
fixedPoint: [0.5, 1],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
|
||||||
const duplicatedElements = duplicateElements({
|
|
||||||
type: "everything",
|
|
||||||
elements: origElements,
|
|
||||||
}).duplicatedElements as any as typeof origElements;
|
|
||||||
|
|
||||||
const [
|
|
||||||
clonedRectangle,
|
|
||||||
clonedText1,
|
|
||||||
clonedArrow1,
|
|
||||||
clonedArrow2,
|
|
||||||
clonedArrow3,
|
|
||||||
] = duplicatedElements;
|
|
||||||
|
|
||||||
expect(clonedRectangle.boundElements).toEqual([
|
|
||||||
{ id: clonedArrow1.id, type: "arrow" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(clonedText1.containerId).toBe(null);
|
|
||||||
|
|
||||||
expect(clonedArrow2.startBinding).toEqual({
|
|
||||||
...arrow2.startBinding,
|
|
||||||
elementId: clonedRectangle.id,
|
|
||||||
});
|
|
||||||
expect(clonedArrow2.endBinding).toBe(null);
|
|
||||||
expect(clonedArrow3.startBinding).toBe(null);
|
|
||||||
expect(clonedArrow3.endBinding).toEqual({
|
|
||||||
...arrow3.endBinding,
|
|
||||||
elementId: clonedRectangle.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("should duplicate all group ids", () => {
|
|
||||||
it("should regenerate all group ids and keep them consistent across elements", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["g1"],
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["g2", "g1"],
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["g2", "g1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
|
||||||
const { duplicatedElements } = duplicateElements({
|
|
||||||
type: "everything",
|
|
||||||
elements: origElements,
|
|
||||||
});
|
|
||||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
|
||||||
duplicatedElements;
|
|
||||||
|
|
||||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
|
||||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
|
||||||
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
|
|
||||||
|
|
||||||
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
|
|
||||||
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
|
|
||||||
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should keep and regenerate ids of groups even if invalid", () => {
|
|
||||||
// lone element shouldn't be able to be grouped with itself,
|
|
||||||
// but hard to check against in a performant way so we ignore it
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
groupIds: ["g1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
duplicatedElements: [clonedRectangle1],
|
|
||||||
} = duplicateElements({ type: "everything", elements: [rectangle1] });
|
|
||||||
|
|
||||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
|
||||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("group-related duplication", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("action-duplicating within group", async () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2]);
|
|
||||||
API.setSelectedElements([rectangle2], "group1");
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
|
||||||
]);
|
|
||||||
expect(h.state.editingGroupId).toBe("group1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating within group", async () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2]);
|
|
||||||
API.setSelectedElements([rectangle2], "group1");
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
|
||||||
mouse.up(rectangle2.x + 50, rectangle2.y + 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: ["group1"] },
|
|
||||||
]);
|
|
||||||
expect(h.state.editingGroupId).toBe("group1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating within group away outside frame", () => {
|
|
||||||
const frame = API.createElement({
|
|
||||||
type: "frame",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
width: 50,
|
|
||||||
height: 50,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
frameId: frame.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([frame, rectangle1, rectangle2]);
|
|
||||||
API.setSelectedElements([rectangle2], "group1");
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle2.x + 5, rectangle2.y + 5);
|
|
||||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: frame.id },
|
|
||||||
{ id: rectangle1.id, frameId: frame.id },
|
|
||||||
{ id: rectangle2.id, frameId: frame.id },
|
|
||||||
{ [ORIG_ID]: rectangle2.id, selected: true, groupIds: [], frameId: null },
|
|
||||||
]);
|
|
||||||
expect(h.state.editingGroupId).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("duplication z-order", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplication z order with Cmd+D for the lowest z-ordered element should be +1 for the clone", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 20,
|
|
||||||
y: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
|
||||||
API.setSelectedElements([rectangle1]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ id: rectangle3.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplication z order with Cmd+D for the highest z-ordered element should be +1 for the clone", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 20,
|
|
||||||
y: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
|
||||||
API.setSelectedElements([rectangle3]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ id: rectangle3.id },
|
|
||||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 20,
|
|
||||||
y: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
|
||||||
|
|
||||||
mouse.select(rectangle1);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
|
||||||
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ id: rectangle3.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplication z order with alt+drag for the highest z-ordered element should be +1 for the clone", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 20,
|
|
||||||
y: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
|
||||||
|
|
||||||
mouse.select(rectangle3);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle3.x + 5, rectangle3.y + 5);
|
|
||||||
mouse.up(rectangle3.x + 5, rectangle3.y + 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ id: rectangle3.id },
|
|
||||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplication z order with alt+drag for the lowest z-ordered element should be +1 for the clone", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 20,
|
|
||||||
y: 20,
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
|
||||||
|
|
||||||
mouse.select(rectangle1);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
|
||||||
mouse.up(rectangle1.x + 5, rectangle1.y + 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ id: rectangle3.id },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplication z order with alt+drag with grouped elements should consider the group together when determining z-index", () => {
|
|
||||||
const rectangle1 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
const rectangle2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
const rectangle3 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x: 20,
|
|
||||||
y: 20,
|
|
||||||
groupIds: ["group1"],
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rectangle1, rectangle2, rectangle3]);
|
|
||||||
|
|
||||||
mouse.select(rectangle1);
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle1.x + 5, rectangle1.y + 5);
|
|
||||||
mouse.up(rectangle1.x + 15, rectangle1.y + 15);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle1.id },
|
|
||||||
{ id: rectangle2.id },
|
|
||||||
{ id: rectangle3.id },
|
|
||||||
{ [ORIG_ID]: rectangle1.id, selected: true },
|
|
||||||
{ [ORIG_ID]: rectangle2.id, selected: true },
|
|
||||||
{ [ORIG_ID]: rectangle3.id, selected: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating text container (in-order)", async () => {
|
|
||||||
const [rectangle, text] = API.createTextContainer();
|
|
||||||
API.setElements([rectangle, text]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
|
||||||
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating text container (out-of-order)", async () => {
|
|
||||||
const [rectangle, text] = API.createTextContainer();
|
|
||||||
API.setElements([text, rectangle]);
|
|
||||||
API.setSelectedElements([rectangle]);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(rectangle.x + 5, rectangle.y + 5);
|
|
||||||
mouse.up(rectangle.x + 15, rectangle.y + 15);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: rectangle.id },
|
|
||||||
{ id: text.id, containerId: rectangle.id },
|
|
||||||
{ [ORIG_ID]: rectangle.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(rectangle.id)?.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating labeled arrows (in-order)", async () => {
|
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
|
||||||
|
|
||||||
API.setElements([arrow, text]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
|
||||||
mouse.up(arrow.x + 15, arrow.y + 15);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: arrow.id },
|
|
||||||
{ id: text.id, containerId: arrow.id },
|
|
||||||
{ [ORIG_ID]: arrow.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(h.state.selectedLinearElement).toEqual(
|
|
||||||
expect.objectContaining({ elementId: getCloneByOrigId(arrow.id)?.id }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating labeled arrows (out-of-order)", async () => {
|
|
||||||
const [arrow, text] = API.createLabeledArrow();
|
|
||||||
|
|
||||||
API.setElements([text, arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(arrow.x + 5, arrow.y + 5);
|
|
||||||
mouse.up(arrow.x + 15, arrow.y + 15);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{ id: arrow.id },
|
|
||||||
{ id: text.id, containerId: arrow.id },
|
|
||||||
{ [ORIG_ID]: arrow.id, selected: true },
|
|
||||||
{
|
|
||||||
[ORIG_ID]: text.id,
|
|
||||||
containerId: getCloneByOrigId(arrow.id)?.id,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("alt-duplicating bindable element with bound arrow should keep the arrow on the duplicate", async () => {
|
|
||||||
const rect = UI.createElement("rectangle", {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const arrow = UI.createElement("arrow", {
|
|
||||||
x: -100,
|
|
||||||
y: 50,
|
|
||||||
width: 95,
|
|
||||||
height: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
|
||||||
mouse.down(5, 5);
|
|
||||||
mouse.up(15, 15);
|
|
||||||
});
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
|
||||||
{
|
|
||||||
id: rect.id,
|
|
||||||
boundElements: expect.arrayContaining([
|
|
||||||
expect.objectContaining({ id: arrow.id }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ [ORIG_ID]: rect.id, boundElements: [], selected: true },
|
|
||||||
{
|
|
||||||
id: arrow.id,
|
|
||||||
endBinding: expect.objectContaining({ elementId: rect.id }),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { getEmbedLink } from "../src/embeddable";
|
|
||||||
|
|
||||||
describe("YouTube timestamp parsing", () => {
|
|
||||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
|
||||||
expectedStart: 90,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
|
||||||
expectedStart: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
|
||||||
expectedStart: 150,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach(({ url, expectedStart }) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain(`start=${expectedStart}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should parse YouTube URLs with timestamp in time format", () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
|
||||||
expectedStart: 90, // 1*60 + 30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
|
||||||
expectedStart: 165, // 2*60 + 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
|
||||||
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
|
||||||
expectedStart: 45,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
|
||||||
expectedStart: 300, // 5*60
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
|
||||||
expectedStart: 7200, // 2*3600
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach(({ url, expectedStart }) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain(`start=${expectedStart}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle YouTube URLs without timestamps", () => {
|
|
||||||
const testCases = [
|
|
||||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
||||||
"https://youtu.be/dQw4w9WgXcQ",
|
|
||||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach((url) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).not.toContain("start=");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle YouTube shorts URLs with timestamps", () => {
|
|
||||||
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain("start=30");
|
|
||||||
}
|
|
||||||
// Shorts should have portrait aspect ratio
|
|
||||||
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle playlist URLs with timestamps", () => {
|
|
||||||
const url =
|
|
||||||
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain("start=60");
|
|
||||||
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle malformed or edge case timestamps", () => {
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
|
||||||
expectedStart: 0, // Invalid timestamp should default to 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
|
||||||
expectedStart: 0, // Empty timestamp should default to 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
|
||||||
expectedStart: 0, // Zero timestamp should be handled
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
testCases.forEach(({ url, expectedStart }) => {
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
if (expectedStart === 0) {
|
|
||||||
expect(result.link).not.toContain("start=");
|
|
||||||
} else {
|
|
||||||
expect(result.link).toContain(`start=${expectedStart}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve other URL parameters", () => {
|
|
||||||
const url =
|
|
||||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
|
||||||
const result = getEmbedLink(url);
|
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
|
||||||
expect(result?.type).toBe("video");
|
|
||||||
if (result?.type === "video" || result?.type === "generic") {
|
|
||||||
expect(result.link).toContain("start=90");
|
|
||||||
expect(result.link).toContain("enablejsapi=1");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user