Compare commits
71 Commits
dwelle/pas
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55ecb96cc | ||
|
|
a6a32b9b29 | ||
|
|
ac0d3059dc | ||
|
|
1161f1b8ba | ||
|
|
204e06b77b | ||
|
|
414182f599 | ||
|
|
b9d27d308e | ||
|
|
3bdaafe4b5 | ||
|
|
ae89608985 | ||
|
|
3085f4af81 | ||
|
|
531f3e5524 | ||
|
|
90ec2739ae | ||
|
|
f29e9df72d | ||
|
|
b5ad7ae4e3 | ||
|
|
c78e4aab7f | ||
|
|
b4903a7eab | ||
|
|
c6f8ef9ad2 | ||
|
|
2535d73054 | ||
|
|
dda3affcb0 | ||
|
|
54c148f390 | ||
|
|
cc8e490c75 | ||
|
|
9036812b6d | ||
|
|
df25de7e68 | ||
|
|
a3763648fe | ||
|
|
178eca5828 | ||
|
|
cb33de25f4 | ||
|
|
37ad85cbaf | ||
|
|
d6a934ed19 | ||
|
|
416da62138 | ||
|
|
f38f381989 | ||
|
|
e5e07260c6 | ||
|
|
8492b144b0 | ||
|
|
e46f038132 | ||
|
|
678dff25ed | ||
|
|
0cfa53b764 | ||
|
|
cde46793f8 | ||
|
|
2d127f8c22 | ||
|
|
4eadb891f8 | ||
|
|
258605d1d5 | ||
|
|
c141500400 | ||
|
|
8e27de2cdc | ||
|
|
0a19c93509 | ||
|
|
958597dfaa | ||
|
|
058918f8e5 | ||
|
|
3f194918e6 | ||
|
|
93c92d13e9 | ||
|
|
84e96e9393 | ||
|
|
320af405e9 | ||
|
|
60512f13d5 | ||
|
|
f0458cc216 | ||
|
|
9f3fdf5505 | ||
|
|
f42e1ab64e | ||
|
|
18808481fd | ||
|
|
a7b64f02b3 | ||
|
|
0d4abd1ddc | ||
|
|
9e77373c81 | ||
|
|
d108053351 | ||
|
|
d4e85a9480 | ||
|
|
08cd4c4f9a | ||
|
|
469caadb87 | ||
|
|
ca1a4f25e7 | ||
|
|
56c05b3099 | ||
|
|
6c0ff7fc5c | ||
|
|
7cad3645a0 | ||
|
|
5921ebc416 | ||
|
|
864353be5f | ||
|
|
db2911c6c4 | ||
|
|
fc3e062074 | ||
|
|
87c87a9fb1 | ||
|
|
4dc205537c | ||
|
|
cc571c4681 |
45
.github/copilot-instructions.md
vendored
Normal file
45
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Project coding standards
|
||||||
|
|
||||||
|
## Generic Communication Guidelines
|
||||||
|
|
||||||
|
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||||
|
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||||
|
- Stop apologising if corrected, just provide the correct information or code
|
||||||
|
- Prefer code unless asked for explanation
|
||||||
|
- Stop summarizing what you've changed after modifications unless asked for
|
||||||
|
|
||||||
|
## TypeScript Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript for all new code
|
||||||
|
- Where possible, prefer implementations without allocation
|
||||||
|
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||||
|
- Prefer immutable data (const, readonly)
|
||||||
|
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||||
|
|
||||||
|
## React Guidelines
|
||||||
|
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Follow the React hooks rules (no conditional hooks)
|
||||||
|
- Keep components small and focused
|
||||||
|
- Use CSS modules for component styling
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
- Use PascalCase for component names, interfaces, and type aliases
|
||||||
|
- Use camelCase for variables, functions, and methods
|
||||||
|
- Use ALL_CAPS for constants
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Use try/catch blocks for async operations
|
||||||
|
- Implement proper error boundaries in React components
|
||||||
|
- Always log errors with contextual information
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Always attempt to fix #problems
|
||||||
|
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
||||||
2
.github/workflows/autorelease-excalidraw.yml
vendored
2
.github/workflows/autorelease-excalidraw.yml
vendored
@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: Auto release
|
- name: Auto release
|
||||||
run: |
|
run: |
|
||||||
yarn add @actions/core -W
|
yarn add @actions/core -W
|
||||||
yarn autorelease
|
yarn release --tag=next --non-interactive
|
||||||
|
|||||||
55
.github/workflows/autorelease-preview.yml
vendored
55
.github/workflows/autorelease-preview.yml
vendored
@ -1,55 +0,0 @@
|
|||||||
name: Auto release excalidraw preview
|
|
||||||
on:
|
|
||||||
issue_comment:
|
|
||||||
types: [created, edited]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
Auto-release-excalidraw-preview:
|
|
||||||
name: Auto release preview
|
|
||||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: React to release comment
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
comment-id: ${{ github.event.comment.id }}
|
|
||||||
reactions: "+1"
|
|
||||||
- name: Get PR SHA
|
|
||||||
id: sha
|
|
||||||
uses: actions/github-script@v4
|
|
||||||
with:
|
|
||||||
result-encoding: string
|
|
||||||
script: |
|
|
||||||
const { owner, repo, number } = context.issue;
|
|
||||||
const pr = await github.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: number,
|
|
||||||
});
|
|
||||||
return pr.data.head.sha
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
ref: ${{ steps.sha.outputs.result }}
|
|
||||||
fetch-depth: 2
|
|
||||||
- name: Setup Node.js 18.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: 18.x
|
|
||||||
- name: Set up publish access
|
|
||||||
run: |
|
|
||||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
|
||||||
env:
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
- name: Auto release preview
|
|
||||||
id: "autorelease"
|
|
||||||
run: |
|
|
||||||
yarn add @actions/core -W
|
|
||||||
yarn autorelease preview ${{ github.event.issue.number }}
|
|
||||||
- name: Post comment post release
|
|
||||||
if: always()
|
|
||||||
uses: peter-evans/create-or-update-comment@v1
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
|
||||||
issue-number: ${{ github.event.issue.number }}
|
|
||||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
|
||||||
7
.github/workflows/publish-docker.yml
vendored
7
.github/workflows/publish-docker.yml
vendored
@ -17,9 +17,14 @@ 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@v3
|
uses: docker/build-push-action@v5
|
||||||
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
.gitignore
vendored
1
.gitignore
vendored
@ -26,3 +26,4 @@ coverage
|
|||||||
dev-dist
|
dev-dist
|
||||||
html
|
html
|
||||||
meta*.json
|
meta*.json
|
||||||
|
.claude
|
||||||
|
|||||||
34
CLAUDE.md
Normal file
34
CLAUDE.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||||
|
|
||||||
|
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||||
|
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||||
|
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||||
|
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. **Package Development**: Work in `packages/*` for editor features
|
||||||
|
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||||
|
3. **Testing**: Always run `yarn test:update` before committing
|
||||||
|
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn test:typecheck # TypeScript type checking
|
||||||
|
yarn test:update # Run all tests (with snapshot updates)
|
||||||
|
yarn fix # Auto-fix formatting and linting issues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
### Package System
|
||||||
|
|
||||||
|
- Uses Yarn workspaces for monorepo management
|
||||||
|
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||||
|
- Build system uses esbuild for packages, Vite for the app
|
||||||
|
- TypeScript throughout with strict configuration
|
||||||
@ -1,4 +1,4 @@
|
|||||||
FROM node:18 AS build
|
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||||
|
|
||||||
WORKDIR /opt/node_app
|
WORKDIR /opt/node_app
|
||||||
|
|
||||||
@ -6,13 +6,14 @@ 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 yarn --network-timeout 600000
|
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||||
|
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@ -363,13 +363,7 @@ 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 },
|
||||||
) => {};
|
) => {};
|
||||||
@ -377,7 +371,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. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
|
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
|
||||||
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
| `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
|
||||||
|
|||||||
@ -28,32 +28,12 @@ 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 prerelease:excalidraw
|
yarn release --tag=latest --version=0.19.0
|
||||||
```
|
```
|
||||||
|
|
||||||
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
|
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.
|
||||||
|
|
||||||
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,6 +38,8 @@ 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,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -33,6 +33,7 @@ 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;
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
"build:packages": "yarn --cwd ../../ build:packages",
|
||||||
|
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
"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",
|
||||||
|
|||||||
@ -17,6 +17,6 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5002",
|
"preview": "vite preview --port 5002",
|
||||||
"build:preview": "yarn build && yarn preview",
|
"build:preview": "yarn build && yarn preview",
|
||||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
"build:packages": "yarn --cwd ../../ build:packages"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"outputDirectory": "dist",
|
"outputDirectory": "dist",
|
||||||
"installCommand": "yarn install",
|
"installCommand": "yarn install",
|
||||||
"buildCommand": "yarn build:package && yarn build"
|
"buildCommand": "yarn build:packages && yarn build"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,6 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
TITLE_TIMEOUT,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@ -499,11 +498,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const titleTimeout = setTimeout(
|
|
||||||
() => (document.title = APP_NAME),
|
|
||||||
TITLE_TIMEOUT,
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@ -594,7 +588,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
clearTimeout(titleTimeout);
|
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,8 @@ 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
|
||||||
|
|
||||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||||
|
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||||
import { throttleRAF } from "@excalidraw/common";
|
import { throttleRAF } from "@excalidraw/common";
|
||||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isLineSegment,
|
isLineSegment,
|
||||||
@ -18,10 +18,12 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
import React from "react";
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
const renderLine = (
|
const renderLine = (
|
||||||
@ -113,10 +115,6 @@ const _debugRenderer = (
|
|||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
const context = bootstrapCanvas({
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
@ -314,19 +312,12 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
|||||||
interface DebugCanvasProps {
|
interface DebugCanvasProps {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
scale: number;
|
scale: number;
|
||||||
ref?: React.Ref<HTMLCanvasElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||||
|
({ appState, scale }, ref) => {
|
||||||
const { width, height } = appState;
|
const { width, height } = appState;
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
|
||||||
ref,
|
|
||||||
() => canvasRef.current,
|
|
||||||
[canvasRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
style={{
|
style={{
|
||||||
@ -338,11 +329,12 @@ const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
|||||||
}}
|
}}
|
||||||
width={width * scale}
|
width={width * scale}
|
||||||
height={height * scale}
|
height={height * scale}
|
||||||
ref={canvasRef}
|
ref={ref}
|
||||||
>
|
>
|
||||||
Debug Canvas
|
Debug Canvas
|
||||||
</canvas>
|
</canvas>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default DebugCanvas;
|
export default DebugCanvas;
|
||||||
|
|||||||
@ -259,7 +259,9 @@ 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) {
|
||||||
|
|||||||
@ -258,11 +258,16 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
<title>Excalidraw Whiteboard</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="Excalidraw — Collaborative whiteboarding made easy"
|
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
|
|||||||
},
|
},
|
||||||
"isTouchScreen": false,
|
"isTouchScreen": false,
|
||||||
"viewport": {
|
"viewport": {
|
||||||
"isLandscape": false,
|
"isLandscape": true,
|
||||||
"isMobile": true,
|
"isMobile": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -205,6 +205,7 @@ describe("collaboration", () => {
|
|||||||
// 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 }),
|
||||||
@ -247,79 +248,5 @@ 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 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
15
package.json
15
package.json
@ -52,13 +52,17 @@
|
|||||||
"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:package": "yarn --cwd ./packages/excalidraw build:esm",
|
"build:common": "yarn --cwd ./packages/common build:esm",
|
||||||
|
"build:element": "yarn --cwd ./packages/element build:esm",
|
||||||
|
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
||||||
|
"build:math": "yarn --cwd ./packages/math build:esm",
|
||||||
|
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
"build: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:package && yarn --cwd ./examples/with-script-in-browser start",
|
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
"test: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 .",
|
||||||
@ -76,9 +80,10 @@
|
|||||||
"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",
|
||||||
"autorelease": "node scripts/autorelease.js",
|
"release": "node scripts/release.js",
|
||||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
"release:test": "node scripts/release.js --tag=test",
|
||||||
"release:excalidraw": "node scripts/release.js",
|
"release:next": "node scripts/release.js --tag=next",
|
||||||
|
"release:latest": "node scripts/release.js --tag=latest",
|
||||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
"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"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@excalidraw/common",
|
"name": "@excalidraw/common",
|
||||||
"version": "0.1.0",
|
"version": "0.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/types/common/src/index.d.ts",
|
"types": "./dist/types/common/src/index.d.ts",
|
||||||
"main": "./dist/prod/index.js",
|
"main": "./dist/prod/index.js",
|
||||||
@ -13,7 +13,10 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./dist/types/common/src/*.d.ts"
|
"types": "./dist/types/common/src/*.d.ts",
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"production": "./dist/prod/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -18,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|||||||
export const isSafari =
|
export const isSafari =
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
export const isIOS =
|
export const isIOS =
|
||||||
/iPad|iPhone/.test(navigator.platform) ||
|
/iPad|iPhone/i.test(navigator.platform) ||
|
||||||
// iPadOS 13+
|
// iPadOS 13+
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
// keeping function so it can be mocked in test
|
// keeping function so it can be mocked in test
|
||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "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);
|
||||||
|
|
||||||
export const supportsResizeObserver =
|
export const supportsResizeObserver =
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
@ -36,6 +43,7 @@ export const APP_NAME = "Excalidraw";
|
|||||||
// (happens a lot with fast clicks with the text tool)
|
// (happens a lot with fast clicks with the text tool)
|
||||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
|
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
@ -121,6 +129,7 @@ export const CLASSES = {
|
|||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
|
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@ -147,19 +156,49 @@ export const FONT_FAMILY = {
|
|||||||
Assistant: 10,
|
Assistant: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
|
||||||
|
// so we need to have generic font fallback before it
|
||||||
|
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
|
||||||
|
export const MONOSPACE_GENERIC_FONT = "monospace";
|
||||||
|
|
||||||
|
export const FONT_FAMILY_GENERIC_FALLBACKS = {
|
||||||
|
[SANS_SERIF_GENERIC_FONT]: 998,
|
||||||
|
[MONOSPACE_GENERIC_FONT]: 999,
|
||||||
|
};
|
||||||
|
|
||||||
export const FONT_FAMILY_FALLBACKS = {
|
export const FONT_FAMILY_FALLBACKS = {
|
||||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||||
|
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||||
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getGenericFontFamilyFallback(
|
||||||
|
fontFamily: number,
|
||||||
|
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
|
||||||
|
switch (fontFamily) {
|
||||||
|
case FONT_FAMILY.Cascadia:
|
||||||
|
case FONT_FAMILY["Comic Shanns"]:
|
||||||
|
return MONOSPACE_GENERIC_FONT;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return SANS_SERIF_GENERIC_FONT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getFontFamilyFallbacks = (
|
export const getFontFamilyFallbacks = (
|
||||||
fontFamily: number,
|
fontFamily: number,
|
||||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||||
|
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||||
|
|
||||||
switch (fontFamily) {
|
switch (fontFamily) {
|
||||||
case FONT_FAMILY.Excalifont:
|
case FONT_FAMILY.Excalifont:
|
||||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
return [
|
||||||
|
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||||
|
genericFallbackFont,
|
||||||
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
|
];
|
||||||
default:
|
default:
|
||||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -221,13 +260,17 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const STRING_MIME_TYPES = {
|
||||||
text: "text/plain",
|
text: "text/plain",
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
...STRING_MIME_TYPES,
|
||||||
// image-encoded excalidraw data
|
// image-encoded excalidraw data
|
||||||
"excalidraw.svg": "image/svg+xml",
|
"excalidraw.svg": "image/svg+xml",
|
||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
@ -304,10 +347,17 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
|
|
||||||
// breakpoints
|
// breakpoints
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// md screen
|
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
// mobile: up to 699px
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_MOBILE = 599;
|
||||||
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
|
// sidebar
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@ -477,3 +527,12 @@ export enum UserIdleState {
|
|||||||
AWAY = "away",
|
AWAY = "away",
|
||||||
IDLE = "idle",
|
IDLE = "idle",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* distance at which we merge points instead of adding a new merge-point
|
||||||
|
* when converting a line to a polygon (merge currently means overlaping
|
||||||
|
* the start and end points)
|
||||||
|
*/
|
||||||
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
|
||||||
|
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
import { average } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
FontString,
|
FontString,
|
||||||
ExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -22,6 +21,8 @@ import {
|
|||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
isDarwin,
|
isDarwin,
|
||||||
|
isAndroid,
|
||||||
|
isIOS,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@ -101,7 +102,6 @@ export const getFontFamilyString = ({
|
|||||||
}) => {
|
}) => {
|
||||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||||
if (id === fontFamily) {
|
if (id === fontFamily) {
|
||||||
// TODO: we should fallback first to generic family names first
|
|
||||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||||
.map((x) => `, ${x}`)
|
.map((x) => `, ${x}`)
|
||||||
.join("")}`;
|
.join("")}`;
|
||||||
@ -712,8 +712,8 @@ export const arrayToObject = <T>(
|
|||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
groupBy?: (value: T) => string | number,
|
groupBy?: (value: T) => string | number,
|
||||||
) =>
|
) =>
|
||||||
array.reduce((acc, value) => {
|
array.reduce((acc, value, idx) => {
|
||||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
acc[groupBy ? groupBy(value) : idx] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as { [key: string]: T });
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
@ -1238,20 +1238,6 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
Array.isArray(value) ? value : [value];
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
export const elementCenterPoint = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
xOffset: number = 0,
|
|
||||||
yOffset: number = 0,
|
|
||||||
) => {
|
|
||||||
const { x, y, width, height } = element;
|
|
||||||
|
|
||||||
const centerXPoint = x + width / 2 + xOffset;
|
|
||||||
|
|
||||||
const centerYPoint = y + height / 2 + yOffset;
|
|
||||||
|
|
||||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
@ -1294,3 +1280,59 @@ export const reduceToCommonValue = <T, R = T>(
|
|||||||
|
|
||||||
return commonValue;
|
return commonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@excalidraw/element",
|
"name": "@excalidraw/element",
|
||||||
"version": "0.1.0",
|
"version": "0.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"types": "./dist/types/element/src/index.d.ts",
|
"types": "./dist/types/element/src/index.d.ts",
|
||||||
"main": "./dist/prod/index.js",
|
"main": "./dist/prod/index.js",
|
||||||
@ -13,7 +13,10 @@
|
|||||||
"default": "./dist/prod/index.js"
|
"default": "./dist/prod/index.js"
|
||||||
},
|
},
|
||||||
"./*": {
|
"./*": {
|
||||||
"types": "./dist/types/element/src/*.d.ts"
|
"types": "./dist/types/element/src/*.d.ts",
|
||||||
|
"development": "./dist/dev/index.js",
|
||||||
|
"production": "./dist/prod/index.js",
|
||||||
|
"default": "./dist/prod/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -52,5 +55,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"gen:types": "rimraf types && tsc",
|
"gen:types": "rimraf types && tsc",
|
||||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@excalidraw/common": "0.18.0",
|
||||||
|
"@excalidraw/math": "0.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,9 +164,14 @@ export class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(elements: ElementsMapOrArray | null = null) {
|
constructor(
|
||||||
|
elements: ElementsMapOrArray | null = null,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (elements) {
|
if (elements) {
|
||||||
this.replaceAllElements(elements);
|
this.replaceAllElements(elements, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,12 +268,19 @@ export class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
replaceAllElements(
|
||||||
|
nextElements: ElementsMapOrArray,
|
||||||
|
options?: {
|
||||||
|
skipValidation?: true;
|
||||||
|
},
|
||||||
|
) {
|
||||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||||
const _nextElements = toArray(nextElements);
|
const _nextElements = toArray(nextElements);
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
|
if (!options?.skipValidation) {
|
||||||
validateIndicesThrottled(_nextElements);
|
validateIndicesThrottled(_nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(_nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
|
|
||||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AppState,
|
|
||||||
EmbedsValidationStatus,
|
|
||||||
} from "@excalidraw/excalidraw/types";
|
|
||||||
import type {
|
|
||||||
ElementShape,
|
|
||||||
ElementShapes,
|
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
|
||||||
|
|
||||||
import { _generateElementShape } from "./Shape";
|
|
||||||
|
|
||||||
import { elementWithCanvasCache } from "./renderElement";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
|
||||||
|
|
||||||
import type { Drawable } from "roughjs/bin/core";
|
|
||||||
|
|
||||||
export class ShapeCache {
|
|
||||||
private static rg = new RoughGenerator();
|
|
||||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves shape from cache if available. Use this only if shape
|
|
||||||
* is optional and you have a fallback in case it's not cached.
|
|
||||||
*/
|
|
||||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
|
||||||
return ShapeCache.cache.get(
|
|
||||||
element,
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]] | undefined
|
|
||||||
: ElementShape | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
public static set = <T extends ExcalidrawElement>(
|
|
||||||
element: T,
|
|
||||||
shape: T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable,
|
|
||||||
) => ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
public static delete = (element: ExcalidrawElement) =>
|
|
||||||
ShapeCache.cache.delete(element);
|
|
||||||
|
|
||||||
public static destroy = () => {
|
|
||||||
ShapeCache.cache = new WeakMap();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates & caches shape for element if not already cached, otherwise
|
|
||||||
* returns cached shape.
|
|
||||||
*/
|
|
||||||
public static generateElementShape = <
|
|
||||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
|
||||||
>(
|
|
||||||
element: T,
|
|
||||||
renderConfig: {
|
|
||||||
isExporting: boolean;
|
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
|
||||||
} | null,
|
|
||||||
) => {
|
|
||||||
// when exporting, always regenerated to guarantee the latest shape
|
|
||||||
const cachedShape = renderConfig?.isExporting
|
|
||||||
? undefined
|
|
||||||
: ShapeCache.get(element);
|
|
||||||
|
|
||||||
// `null` indicates no rc shape applicable for this element type,
|
|
||||||
// but it's considered a valid cache value (= do not regenerate)
|
|
||||||
if (cachedShape !== undefined) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
elementWithCanvasCache.delete(element);
|
|
||||||
|
|
||||||
const shape = _generateElementShape(
|
|
||||||
element,
|
|
||||||
ShapeCache.rg,
|
|
||||||
renderConfig || {
|
|
||||||
isExporting: false,
|
|
||||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
|
||||||
embedsValidationStatus: null,
|
|
||||||
},
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable | null;
|
|
||||||
|
|
||||||
ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { updateBoundElements } from "./binding";
|
import { updateBoundElements } from "./binding";
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getSelectedElementsByGroup } from "./groups";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
@ -16,11 +18,12 @@ export const alignElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
alignment: Alignment,
|
alignment: Alignment,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
|
||||||
selectedElements,
|
selectedElements,
|
||||||
elementsMap,
|
scene.getNonDeletedElementsMap(),
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
elementCenterPoint,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -27,8 +26,6 @@ import {
|
|||||||
PRECISION,
|
PRECISION,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@ -36,12 +33,12 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
doBoundsIntersect,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
doBoundsIntersect,
|
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { intersectElementWithLineSegment } from "./collision";
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
headingForPointFromElement,
|
headingForPointFromElement,
|
||||||
headingIsHorizontal,
|
headingIsHorizontal,
|
||||||
@ -63,7 +60,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
@ -109,7 +106,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
|
|||||||
|
|
||||||
export const FIXED_BINDING_DISTANCE = 5;
|
export const FIXED_BINDING_DISTANCE = 5;
|
||||||
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
export const BINDING_HIGHLIGHT_THICKNESS = 10;
|
||||||
export const BINDING_HIGHLIGHT_OFFSET = 4;
|
|
||||||
|
|
||||||
const getNonDeletedElements = (
|
const getNonDeletedElements = (
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
@ -131,6 +127,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
endBindingElement: ExcalidrawBindableElement | null | "keep",
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
): void => {
|
): void => {
|
||||||
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||||
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
|
||||||
bindOrUnbindLinearElementEdge(
|
bindOrUnbindLinearElementEdge(
|
||||||
@ -141,6 +138,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
scene,
|
scene,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
bindOrUnbindLinearElementEdge(
|
bindOrUnbindLinearElementEdge(
|
||||||
linearElement,
|
linearElement,
|
||||||
@ -150,6 +148,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
scene,
|
scene,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||||
@ -176,6 +175,7 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
// Is mutated
|
// Is mutated
|
||||||
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): void => {
|
): void => {
|
||||||
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
// "keep" is for method chaining convenience, a "no-op", so just bail out
|
||||||
if (bindableElement === "keep") {
|
if (bindableElement === "keep") {
|
||||||
@ -216,12 +216,12 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
edge: "start" | "end",
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
): NonDeleted<ExcalidrawElement> | null => {
|
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||||
|
(["start", "end"] as const).map((edge) => {
|
||||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||||
const elementId =
|
const elementId =
|
||||||
edge === "start"
|
edge === "start"
|
||||||
@ -238,21 +238,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
});
|
||||||
|
|
||||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
zoom?: AppState["zoom"],
|
|
||||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
|
||||||
["start", "end"].map((edge) =>
|
|
||||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
|
||||||
linearElement,
|
|
||||||
edge as "start" | "end",
|
|
||||||
elementsMap,
|
|
||||||
zoom,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getBindingStrategyForDraggingArrowEndpoints = (
|
const getBindingStrategyForDraggingArrowEndpoints = (
|
||||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -268,7 +254,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||||
const start = startDragged
|
const start = startDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -279,7 +265,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
: "keep";
|
: "keep";
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -311,7 +297,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
);
|
);
|
||||||
const start = startIsClose
|
const start = startIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -322,7 +308,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
: null;
|
: null;
|
||||||
const end = endIsClose
|
const end = endIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -398,6 +384,48 @@ export const getSuggestedBindingsForArrows = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const maybeSuggestBindingsForLinearElementAtCoords = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
/** scene coords */
|
||||||
|
pointerCoords: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[],
|
||||||
|
scene: Scene,
|
||||||
|
zoom: AppState["zoom"],
|
||||||
|
// During line creation the start binding hasn't been written yet
|
||||||
|
// into `linearElement`
|
||||||
|
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||||
|
): ExcalidrawBindableElement[] =>
|
||||||
|
Array.from(
|
||||||
|
pointerCoords.reduce(
|
||||||
|
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
|
||||||
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
|
coords,
|
||||||
|
scene.getNonDeletedElements(),
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
zoom,
|
||||||
|
isElbowArrow(linearElement),
|
||||||
|
isElbowArrow(linearElement),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hoveredBindableElement != null &&
|
||||||
|
!isLinearElementSimpleAndAlreadyBound(
|
||||||
|
linearElement,
|
||||||
|
oppositeBindingBoundElement?.id,
|
||||||
|
hoveredBindableElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
acc.add(hoveredBindableElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const maybeBindLinearElement = (
|
export const maybeBindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -441,22 +469,13 @@ export const maybeBindLinearElement = (
|
|||||||
const normalizePointBinding = (
|
const normalizePointBinding = (
|
||||||
binding: { focus: number; gap: number },
|
binding: { focus: number; gap: number },
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
) => {
|
) => ({
|
||||||
let gap = binding.gap;
|
|
||||||
const maxGap = maxBindingGap(
|
|
||||||
hoveredElement,
|
|
||||||
hoveredElement.width,
|
|
||||||
hoveredElement.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (gap > maxGap) {
|
|
||||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...binding,
|
...binding,
|
||||||
gap,
|
gap: Math.min(
|
||||||
};
|
binding.gap,
|
||||||
};
|
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const bindLinearElement = (
|
export const bindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -488,6 +507,7 @@ export const bindLinearElement = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -535,7 +555,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
|||||||
|
|
||||||
const isLinearElementSimple = (
|
const isLinearElementSimple = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
): boolean => linearElement.points.length < 3;
|
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||||
|
|
||||||
const unbindLinearElement = (
|
const unbindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -703,8 +723,13 @@ const calculateFocusAndGap = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
|
focus: determineFocusDistance(
|
||||||
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
|
hoveredElement,
|
||||||
|
elementsMap,
|
||||||
|
adjacentPoint,
|
||||||
|
edgePoint,
|
||||||
|
),
|
||||||
|
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -874,6 +899,7 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
bindableElement: ExcalidrawBindableElement | undefined | null,
|
bindableElement: ExcalidrawBindableElement | undefined | null,
|
||||||
aabb: Bounds | undefined | null,
|
aabb: Bounds | undefined | null,
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
): Heading => {
|
): Heading => {
|
||||||
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
|
||||||
@ -882,11 +908,16 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
return otherPointHeading;
|
return otherPointHeading;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
|
const distance = getDistanceForBinding(
|
||||||
|
origPoint,
|
||||||
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
|
zoom,
|
||||||
|
);
|
||||||
|
|
||||||
if (!distance) {
|
if (!distance) {
|
||||||
return vectorToHeading(
|
return vectorToHeading(
|
||||||
vectorFromPoint(p, elementCenterPoint(bindableElement)),
|
vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -896,9 +927,10 @@ export const getHeadingForElbowArrowSnap = (
|
|||||||
const getDistanceForBinding = (
|
const getDistanceForBinding = (
|
||||||
point: Readonly<GlobalPoint>,
|
point: Readonly<GlobalPoint>,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
) => {
|
) => {
|
||||||
const distance = distanceToBindableElement(bindableElement, point);
|
const distance = distanceToElement(bindableElement, elementsMap, point);
|
||||||
const bindDistance = maxBindingGap(
|
const bindDistance = maxBindingGap(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
bindableElement.width,
|
bindableElement.width,
|
||||||
@ -913,12 +945,13 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
arrow: ExcalidrawElbowArrowElement,
|
arrow: ExcalidrawElbowArrowElement,
|
||||||
bindableElement: ExcalidrawBindableElement,
|
bindableElement: ExcalidrawBindableElement,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDevEnv() || isTestEnv()) {
|
if (isDevEnv() || isTestEnv()) {
|
||||||
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
|
||||||
}
|
}
|
||||||
|
|
||||||
const aabb = aabbForElement(bindableElement);
|
const aabb = aabbForElement(bindableElement, elementsMap);
|
||||||
const localP =
|
const localP =
|
||||||
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
|
||||||
const globalP = pointFrom<GlobalPoint>(
|
const globalP = pointFrom<GlobalPoint>(
|
||||||
@ -926,7 +959,7 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
arrow.y + localP[1],
|
arrow.y + localP[1],
|
||||||
);
|
);
|
||||||
const edgePoint = isRectanguloidElement(bindableElement)
|
const edgePoint = isRectanguloidElement(bindableElement)
|
||||||
? avoidRectangularCorner(bindableElement, globalP)
|
? avoidRectangularCorner(bindableElement, elementsMap, globalP)
|
||||||
: globalP;
|
: globalP;
|
||||||
const elbowed = isElbowArrow(arrow);
|
const elbowed = isElbowArrow(arrow);
|
||||||
const center = getCenterForBounds(aabb);
|
const center = getCenterForBounds(aabb);
|
||||||
@ -945,26 +978,31 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
const isHorizontal = headingIsHorizontal(
|
const isHorizontal = headingIsHorizontal(
|
||||||
headingForPointFromElement(bindableElement, aabb, globalP),
|
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||||
);
|
);
|
||||||
|
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
|
||||||
const otherPoint = pointFrom<GlobalPoint>(
|
const otherPoint = pointFrom<GlobalPoint>(
|
||||||
isHorizontal ? center[0] : edgePoint[0],
|
isHorizontal ? center[0] : snapPoint[0],
|
||||||
!isHorizontal ? center[1] : edgePoint[1],
|
!isHorizontal ? center[1] : snapPoint[1],
|
||||||
);
|
);
|
||||||
intersection = intersectElementWithLineSegment(
|
const intersector = lineSegment(
|
||||||
bindableElement,
|
|
||||||
lineSegment(
|
|
||||||
otherPoint,
|
otherPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
vectorScale(
|
vectorScale(
|
||||||
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
|
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
|
||||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
),
|
),
|
||||||
otherPoint,
|
otherPoint,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
)[0];
|
intersection = intersectElementWithLineSegment(
|
||||||
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
|
intersector,
|
||||||
|
FIXED_BINDING_DISTANCE,
|
||||||
|
).sort(pointDistanceSq)[0];
|
||||||
} else {
|
} else {
|
||||||
intersection = intersectElementWithLineSegment(
|
intersection = intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
lineSegment(
|
lineSegment(
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
@ -991,31 +1029,15 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
return edgePoint;
|
return edgePoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elbowed) {
|
return elbowed ? intersection : edgePoint;
|
||||||
const scalar =
|
|
||||||
pointDistanceSq(edgePoint, center) -
|
|
||||||
pointDistanceSq(intersection, center) >
|
|
||||||
0
|
|
||||||
? FIXED_BINDING_DISTANCE
|
|
||||||
: -FIXED_BINDING_DISTANCE;
|
|
||||||
|
|
||||||
return pointFromVector(
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
|
|
||||||
scalar,
|
|
||||||
),
|
|
||||||
intersection,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return edgePoint;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const avoidRectangularCorner = (
|
export const avoidRectangularCorner = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
|
|
||||||
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
|
||||||
@ -1108,35 +1130,34 @@ export const avoidRectangularCorner = (
|
|||||||
|
|
||||||
export const snapToMid = (
|
export const snapToMid = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
tolerance: number = 0.05,
|
tolerance: number = 0.05,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const { x, y, width, height, angle } = element;
|
const { x, y, width, height, angle } = element;
|
||||||
|
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
|
||||||
const center = elementCenterPoint(element, -0.1, -0.1);
|
|
||||||
|
|
||||||
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
const nonRotated = pointRotateRads(p, center, -angle as Radians);
|
||||||
|
|
||||||
// snap-to-center point is adaptive to element size, but we don't want to go
|
// snap-to-center point is adaptive to element size, but we don't want to go
|
||||||
// above and below certain px distance
|
// above and below certain px distance
|
||||||
const verticalThrehsold = clamp(tolerance * height, 5, 80);
|
const verticalThreshold = clamp(tolerance * height, 5, 80);
|
||||||
const horizontalThrehsold = clamp(tolerance * width, 5, 80);
|
const horizontalThreshold = clamp(tolerance * width, 5, 80);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
nonRotated[0] <= x + width / 2 &&
|
nonRotated[0] <= x + width / 2 &&
|
||||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
nonRotated[1] > center[1] - verticalThreshold &&
|
||||||
nonRotated[1] < center[1] + verticalThrehsold
|
nonRotated[1] < center[1] + verticalThreshold
|
||||||
) {
|
) {
|
||||||
// LEFT
|
// LEFT
|
||||||
return pointRotateRads(
|
return pointRotateRads<GlobalPoint>(
|
||||||
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
|
||||||
center,
|
center,
|
||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[1] <= y + height / 2 &&
|
nonRotated[1] <= y + height / 2 &&
|
||||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||||
nonRotated[0] < center[0] + horizontalThrehsold
|
nonRotated[0] < center[0] + horizontalThreshold
|
||||||
) {
|
) {
|
||||||
// TOP
|
// TOP
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@ -1146,8 +1167,8 @@ export const snapToMid = (
|
|||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[0] >= x + width / 2 &&
|
nonRotated[0] >= x + width / 2 &&
|
||||||
nonRotated[1] > center[1] - verticalThrehsold &&
|
nonRotated[1] > center[1] - verticalThreshold &&
|
||||||
nonRotated[1] < center[1] + verticalThrehsold
|
nonRotated[1] < center[1] + verticalThreshold
|
||||||
) {
|
) {
|
||||||
// RIGHT
|
// RIGHT
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@ -1157,8 +1178,8 @@ export const snapToMid = (
|
|||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
nonRotated[1] >= y + height / 2 &&
|
nonRotated[1] >= y + height / 2 &&
|
||||||
nonRotated[0] > center[0] - horizontalThrehsold &&
|
nonRotated[0] > center[0] - horizontalThreshold &&
|
||||||
nonRotated[0] < center[0] + horizontalThrehsold
|
nonRotated[0] < center[0] + horizontalThreshold
|
||||||
) {
|
) {
|
||||||
// DOWN
|
// DOWN
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@ -1167,7 +1188,7 @@ export const snapToMid = (
|
|||||||
angle,
|
angle,
|
||||||
);
|
);
|
||||||
} else if (element.type === "diamond") {
|
} else if (element.type === "diamond") {
|
||||||
const distance = FIXED_BINDING_DISTANCE - 1;
|
const distance = FIXED_BINDING_DISTANCE;
|
||||||
const topLeft = pointFrom<GlobalPoint>(
|
const topLeft = pointFrom<GlobalPoint>(
|
||||||
x + width / 4 - distance,
|
x + width / 4 - distance,
|
||||||
y + height / 4 - distance,
|
y + height / 4 - distance,
|
||||||
@ -1184,27 +1205,28 @@ export const snapToMid = (
|
|||||||
x + (3 * width) / 4 + distance,
|
x + (3 * width) / 4 + distance,
|
||||||
y + (3 * height) / 4 + distance,
|
y + (3 * height) / 4 + distance,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
pointDistance(topLeft, nonRotated) <
|
pointDistance(topLeft, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(topLeft, center, angle);
|
return pointRotateRads(topLeft, center, angle);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pointDistance(topRight, nonRotated) <
|
pointDistance(topRight, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(topRight, center, angle);
|
return pointRotateRads(topRight, center, angle);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pointDistance(bottomLeft, nonRotated) <
|
pointDistance(bottomLeft, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(bottomLeft, center, angle);
|
return pointRotateRads(bottomLeft, center, angle);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
pointDistance(bottomRight, nonRotated) <
|
pointDistance(bottomRight, nonRotated) <
|
||||||
Math.max(horizontalThrehsold, verticalThrehsold)
|
Math.max(horizontalThreshold, verticalThreshold)
|
||||||
) {
|
) {
|
||||||
return pointRotateRads(bottomRight, center, angle);
|
return pointRotateRads(bottomRight, center, angle);
|
||||||
}
|
}
|
||||||
@ -1239,8 +1261,9 @@ const updateBoundPoint = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
bindableElement,
|
bindableElement,
|
||||||
startOrEnd === "startBinding" ? "start" : "end",
|
startOrEnd === "startBinding" ? "start" : "end",
|
||||||
|
elementsMap,
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
const globalMidPoint = elementCenterPoint(bindableElement);
|
const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
|
||||||
const global = pointFrom<GlobalPoint>(
|
const global = pointFrom<GlobalPoint>(
|
||||||
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
bindableElement.x + fixedPoint[0] * bindableElement.width,
|
||||||
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
bindableElement.y + fixedPoint[1] * bindableElement.height,
|
||||||
@ -1266,6 +1289,7 @@ const updateBoundPoint = (
|
|||||||
);
|
);
|
||||||
const focusPointAbsolute = determineFocusPoint(
|
const focusPointAbsolute = determineFocusPoint(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
binding.focus,
|
binding.focus,
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
);
|
);
|
||||||
@ -1284,7 +1308,7 @@ const updateBoundPoint = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const center = elementCenterPoint(bindableElement);
|
const center = elementCenterPoint(bindableElement, elementsMap);
|
||||||
const interceptorLength =
|
const interceptorLength =
|
||||||
pointDistance(adjacentPoint, edgePointAbsolute) +
|
pointDistance(adjacentPoint, edgePointAbsolute) +
|
||||||
pointDistance(adjacentPoint, center) +
|
pointDistance(adjacentPoint, center) +
|
||||||
@ -1292,6 +1316,7 @@ const updateBoundPoint = (
|
|||||||
const intersections = [
|
const intersections = [
|
||||||
...intersectElementWithLineSegment(
|
...intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
lineSegment<GlobalPoint>(
|
lineSegment<GlobalPoint>(
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
@ -1342,6 +1367,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): { fixedPoint: FixedPoint } => {
|
): { fixedPoint: FixedPoint } => {
|
||||||
const bounds = [
|
const bounds = [
|
||||||
hoveredElement.x,
|
hoveredElement.x,
|
||||||
@ -1353,6 +1379,7 @@ export const calculateFixedPointForElbowArrowBinding = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
const globalMidPoint = pointFrom(
|
const globalMidPoint = pointFrom(
|
||||||
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
bounds[0] + (bounds[2] - bounds[0]) / 2,
|
||||||
@ -1396,7 +1423,7 @@ const maybeCalculateNewGapWhenScaling = (
|
|||||||
return { ...currentBinding, gap: newGap };
|
return { ...currentBinding, gap: newGap };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElligibleElementForBindingElement = (
|
const getEligibleElementForBindingElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
@ -1548,14 +1575,38 @@ export const bindingBorderTest = (
|
|||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
fullShape?: boolean,
|
fullShape?: boolean,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
|
const p = pointFrom<GlobalPoint>(x, y);
|
||||||
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||||
|
const shouldTestInside =
|
||||||
|
// disable fullshape snapping for frame elements so we
|
||||||
|
// can bind to frame children
|
||||||
|
(fullShape || !isBindingFallthroughEnabled(element)) &&
|
||||||
|
!isFrameLikeElement(element);
|
||||||
|
|
||||||
const shape = getElementShape(element, elementsMap);
|
// PERF: Run a cheap test to see if the binding element
|
||||||
return (
|
// is even close to the element
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
const bounds = [
|
||||||
(fullShape === true &&
|
x - threshold,
|
||||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
y - threshold,
|
||||||
|
x + threshold,
|
||||||
|
y + threshold,
|
||||||
|
] as Bounds;
|
||||||
|
const elementBounds = getElementBounds(element, elementsMap);
|
||||||
|
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the intersection test against the element since it's close enough
|
||||||
|
const intersections = intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||||
);
|
);
|
||||||
|
const distance = distanceToElement(element, elementsMap, p);
|
||||||
|
|
||||||
|
return shouldTestInside
|
||||||
|
? intersections.length === 0 || distance <= threshold
|
||||||
|
: intersections.length > 0 && distance <= threshold;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maxBindingGap = (
|
export const maxBindingGap = (
|
||||||
@ -1575,7 +1626,7 @@ export const maxBindingGap = (
|
|||||||
// bigger bindable boundary for bigger elements
|
// bigger bindable boundary for bigger elements
|
||||||
Math.min(0.25 * smallerDimension, 32),
|
Math.min(0.25 * smallerDimension, 32),
|
||||||
// keep in sync with the zoomed highlight
|
// keep in sync with the zoomed highlight
|
||||||
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
|
BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1586,12 +1637,13 @@ export const maxBindingGap = (
|
|||||||
// of the element.
|
// of the element.
|
||||||
const determineFocusDistance = (
|
const determineFocusDistance = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
// Point on the line, in absolute coordinates
|
// Point on the line, in absolute coordinates
|
||||||
a: GlobalPoint,
|
a: GlobalPoint,
|
||||||
// Another point on the line, in absolute coordinates (closer to element)
|
// Another point on the line, in absolute coordinates (closer to element)
|
||||||
b: GlobalPoint,
|
b: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
if (pointsEqual(a, b)) {
|
if (pointsEqual(a, b)) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -1716,12 +1768,13 @@ const determineFocusDistance = (
|
|||||||
|
|
||||||
const determineFocusPoint = (
|
const determineFocusPoint = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
// The oriented, relative distance from the center of `element` of the
|
// The oriented, relative distance from the center of `element` of the
|
||||||
// returned focusPoint
|
// returned focusPoint
|
||||||
focus: number,
|
focus: number,
|
||||||
adjacentPoint: GlobalPoint,
|
adjacentPoint: GlobalPoint,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
if (focus === 0) {
|
if (focus === 0) {
|
||||||
return center;
|
return center;
|
||||||
@ -2144,6 +2197,7 @@ export class BindableElement {
|
|||||||
export const getGlobalFixedPointForBindableElement = (
|
export const getGlobalFixedPointForBindableElement = (
|
||||||
fixedPointRatio: [number, number],
|
fixedPointRatio: [number, number],
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawBindableElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
|
||||||
|
|
||||||
@ -2152,7 +2206,7 @@ export const getGlobalFixedPointForBindableElement = (
|
|||||||
element.x + element.width * fixedX,
|
element.x + element.width * fixedX,
|
||||||
element.y + element.height * fixedY,
|
element.y + element.height * fixedY,
|
||||||
),
|
),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -2176,6 +2230,7 @@ export const getGlobalFixedPoints = (
|
|||||||
? getGlobalFixedPointForBindableElement(
|
? getGlobalFixedPointForBindableElement(
|
||||||
arrow.startBinding.fixedPoint,
|
arrow.startBinding.fixedPoint,
|
||||||
startElement as ExcalidrawBindableElement,
|
startElement as ExcalidrawBindableElement,
|
||||||
|
elementsMap,
|
||||||
)
|
)
|
||||||
: pointFrom<GlobalPoint>(
|
: pointFrom<GlobalPoint>(
|
||||||
arrow.x + arrow.points[0][0],
|
arrow.x + arrow.points[0][0],
|
||||||
@ -2186,6 +2241,7 @@ export const getGlobalFixedPoints = (
|
|||||||
? getGlobalFixedPointForBindableElement(
|
? getGlobalFixedPointForBindableElement(
|
||||||
arrow.endBinding.fixedPoint,
|
arrow.endBinding.fixedPoint,
|
||||||
endElement as ExcalidrawBindableElement,
|
endElement as ExcalidrawBindableElement,
|
||||||
|
elementsMap,
|
||||||
)
|
)
|
||||||
: pointFrom<GlobalPoint>(
|
: pointFrom<GlobalPoint>(
|
||||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||||
|
|||||||
@ -33,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { generateRoughOptions } from "./Shape";
|
import { generateRoughOptions } from "./shape";
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -45,7 +45,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shapes";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
@ -102,9 +102,23 @@ export class ElementBounds {
|
|||||||
version: ExcalidrawElement["version"];
|
version: ExcalidrawElement["version"];
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
private static nonRotatedBoundsCache = new WeakMap<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{
|
||||||
|
bounds: Bounds;
|
||||||
|
version: ExcalidrawElement["version"];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
static getBounds(
|
||||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
nonRotated: boolean = false,
|
||||||
|
) {
|
||||||
|
const cachedBounds =
|
||||||
|
nonRotated && element.angle !== 0
|
||||||
|
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||||
|
: ElementBounds.boundsCache.get(element);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cachedBounds?.version &&
|
cachedBounds?.version &&
|
||||||
@ -115,6 +129,23 @@ export class ElementBounds {
|
|||||||
) {
|
) {
|
||||||
return cachedBounds.bounds;
|
return cachedBounds.bounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nonRotated && element.angle !== 0) {
|
||||||
|
const nonRotatedBounds = ElementBounds.calculateBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
ElementBounds.nonRotatedBoundsCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
bounds: nonRotatedBounds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nonRotatedBounds;
|
||||||
|
}
|
||||||
|
|
||||||
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
const bounds = ElementBounds.calculateBounds(element, elementsMap);
|
||||||
|
|
||||||
ElementBounds.boundsCache.set(element, {
|
ElementBounds.boundsCache.set(element, {
|
||||||
@ -553,7 +584,7 @@ const solveQuadratic = (
|
|||||||
return [s1, s2];
|
return [s1, s2];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCubicBezierCurveBound = (
|
export const getCubicBezierCurveBound = (
|
||||||
p0: GlobalPoint,
|
p0: GlobalPoint,
|
||||||
p1: GlobalPoint,
|
p1: GlobalPoint,
|
||||||
p2: GlobalPoint,
|
p2: GlobalPoint,
|
||||||
@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
|
|||||||
export const getElementBounds = (
|
export const getElementBounds = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
nonRotated: boolean = false,
|
||||||
): Bounds => {
|
): Bounds => {
|
||||||
return ElementBounds.getBounds(element, elementsMap);
|
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCommonBounds = (
|
export const getCommonBounds = (
|
||||||
@ -1135,6 +1167,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
|||||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the axis-aligned bounding box for a given element
|
||||||
|
*/
|
||||||
|
export const aabbForElement = (
|
||||||
|
element: Readonly<ExcalidrawElement>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
offset?: [number, number, number, number],
|
||||||
|
) => {
|
||||||
|
const bbox = {
|
||||||
|
minX: element.x,
|
||||||
|
minY: element.y,
|
||||||
|
maxX: element.x + element.width,
|
||||||
|
maxY: element.y + element.height,
|
||||||
|
midX: element.x + element.width / 2,
|
||||||
|
midY: element.y + element.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [topRightX, topRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bounds = [
|
||||||
|
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (offset) {
|
||||||
|
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||||
|
return [
|
||||||
|
bounds[0] - leftOffset,
|
||||||
|
bounds[1] - topOffset,
|
||||||
|
bounds[2] + rightOffset,
|
||||||
|
bounds[3] + downOffset,
|
||||||
|
] as Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p: P,
|
||||||
|
bounds: Bounds,
|
||||||
|
): boolean =>
|
||||||
|
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||||
|
|
||||||
export const doBoundsIntersect = (
|
export const doBoundsIntersect = (
|
||||||
bounds1: Bounds | null,
|
bounds1: Bounds | null,
|
||||||
bounds2: Bounds | null,
|
bounds2: Bounds | null,
|
||||||
@ -1148,3 +1245,14 @@ export const doBoundsIntersect = (
|
|||||||
|
|
||||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const elementCenterPoint = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
xOffset: number = 0,
|
||||||
|
yOffset: number = 0,
|
||||||
|
) => {
|
||||||
|
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,52 +1,68 @@
|
|||||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
import { isTransparent } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
line,
|
|
||||||
lineSegment,
|
lineSegment,
|
||||||
lineSegmentIntersectionPoints,
|
lineSegmentIntersectionPoints,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
|
pointFromVector,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
pointsEqual,
|
pointsEqual,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
vectorScale,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ellipse,
|
ellipse,
|
||||||
ellipseLineIntersectionPoints,
|
ellipseSegmentInterceptPoints,
|
||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Curve,
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
LineSegment,
|
LineSegment,
|
||||||
LocalPoint,
|
|
||||||
Polygon,
|
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
import { isPathALoop } from "./utils";
|
||||||
import { getElementBounds } from "./bounds";
|
import {
|
||||||
|
type Bounds,
|
||||||
|
doBoundsIntersect,
|
||||||
|
elementCenterPoint,
|
||||||
|
getCenterForBounds,
|
||||||
|
getCubicBezierCurveBound,
|
||||||
|
getElementBounds,
|
||||||
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isFreeDrawElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
|
||||||
|
import { distanceToElement } from "./distance";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -72,45 +88,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
|||||||
return isDraggableFromInside || isImageElement(element);
|
return isDraggableFromInside || isImageElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
export type HitTestArgs = {
|
||||||
x: number;
|
point: GlobalPoint;
|
||||||
y: number;
|
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
shape: GeometricShape<Point>;
|
threshold: number;
|
||||||
threshold?: number;
|
elementsMap: ElementsMap;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
export const hitElementItself = ({
|
||||||
x,
|
point,
|
||||||
y,
|
|
||||||
element,
|
element,
|
||||||
shape,
|
threshold,
|
||||||
threshold = 10,
|
elementsMap,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
}: HitTestArgs<Point>) => {
|
}: HitTestArgs) => {
|
||||||
let hit = shouldTestInside(element)
|
// Hit test against a frame's name
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
const hitFrameName = frameNameBound
|
||||||
// we would need `onShape` as well to include the "borders"
|
? isPointWithinBounds(
|
||||||
isPointInShape(pointFrom(x, y), shape) ||
|
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
point,
|
||||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
pointFrom(
|
||||||
|
frameNameBound.x + frameNameBound.width + threshold,
|
||||||
|
frameNameBound.y + frameNameBound.height + threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
// hit test against a frame's name
|
// Hit test against the extended, rotated bounding box of the element first
|
||||||
if (!hit && frameNameBound) {
|
const bounds = getElementBounds(element, elementsMap, true);
|
||||||
hit = isPointInShape(pointFrom(x, y), {
|
const hitBounds = isPointWithinBounds(
|
||||||
type: "polygon",
|
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
pointRotateRads(
|
||||||
.data as Polygon<Point>,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hit;
|
// 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 = (
|
export const hitElementBoundingBox = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
tolerance = 0,
|
tolerance = 0,
|
||||||
@ -120,37 +155,42 @@ export const hitElementBoundingBox = (
|
|||||||
y1 -= tolerance;
|
y1 -= tolerance;
|
||||||
x2 += tolerance;
|
x2 += tolerance;
|
||||||
y2 += tolerance;
|
y2 += tolerance;
|
||||||
return isPointWithinBounds(
|
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||||
pointFrom(x1, y1),
|
|
||||||
pointFrom(x, y),
|
|
||||||
pointFrom(x2, y2),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = <
|
export const hitElementBoundingBoxOnly = (
|
||||||
Point extends GlobalPoint | LocalPoint,
|
hitArgs: HitTestArgs,
|
||||||
>(
|
|
||||||
hitArgs: HitTestArgs<Point>,
|
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) =>
|
||||||
return (
|
|
||||||
!hitElementItself(hitArgs) &&
|
!hitElementItself(hitArgs) &&
|
||||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||||
!hitElementBoundText(
|
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||||
hitArgs.x,
|
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||||
hitArgs.y,
|
|
||||||
getBoundTextShape(hitArgs.element, elementsMap),
|
|
||||||
) &&
|
|
||||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
export const hitElementBoundText = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
element: ExcalidrawElement,
|
||||||
textShape: GeometricShape<Point> | null,
|
elementsMap: ElementsMap,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,9 +203,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
*/
|
*/
|
||||||
export const intersectElementWithLineSegment = (
|
export const intersectElementWithLineSegment = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
line: LineSegment<GlobalPoint>,
|
line: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): 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) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
@ -173,67 +230,196 @@ export const intersectElementWithLineSegment = (
|
|||||||
case "iframe":
|
case "iframe":
|
||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
|
case "selection":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
return intersectRectanguloidWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return intersectDiamondWithLineSegment(element, line, offset);
|
return intersectDiamondWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return intersectEllipseWithLineSegment(element, line, offset);
|
return intersectEllipseWithLineSegment(
|
||||||
default:
|
element,
|
||||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
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 = (
|
const intersectRectanguloidWithLineSegment = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
l: LineSegment<GlobalPoint>,
|
elementsMap: ElementsMap,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||||
l[0],
|
segment[0],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||||
l[1],
|
segment[1],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
// Get the element's building components we can test against
|
// Get the element's building components we can test against
|
||||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||||
|
|
||||||
return (
|
const intersections: GlobalPoint[] = [];
|
||||||
// Test intersection against the sides, keep only the valid
|
|
||||||
// intersection points and rotate them back to scene space
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((x) => x != null)
|
|
||||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
|
|
||||||
// Test intersection against the corners which are cubic bezier curves,
|
|
||||||
// keep only the valid intersection points and rotate them back to scene
|
|
||||||
// space
|
|
||||||
.concat(
|
|
||||||
corners
|
|
||||||
.flatMap((t) =>
|
|
||||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((i) => i != null)
|
|
||||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -245,43 +431,45 @@ const intersectRectanguloidWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectDiamondWithLineSegment = (
|
const intersectDiamondWithLineSegment = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||||
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
return (
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((p): p is GlobalPoint => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
|
|
||||||
.concat(
|
|
||||||
curves
|
|
||||||
.flatMap((p) =>
|
|
||||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((p) => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -293,16 +481,76 @@ const intersectDiamondWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectEllipseWithLineSegment = (
|
const intersectEllipseWithLineSegment = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
|
||||||
return ellipseLineIntersectionPoints(
|
return ellipseSegmentInterceptPoints(
|
||||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||||
line(rotatedA, rotatedB),
|
lineSegment(rotatedA, rotatedB),
|
||||||
).map((p) => pointRotateRads(p, center, element.angle));
|
).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;
|
||||||
|
};
|
||||||
|
|||||||
@ -14,9 +14,8 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Point } from "points-on-curve";
|
import { type Point } from "points-on-curve";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
elementCenterPoint,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
|
|||||||
|
|
||||||
export const cropElement = (
|
export const cropElement = (
|
||||||
element: ExcalidrawImageElement,
|
element: ExcalidrawImageElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
transformHandle: TransformHandleType,
|
transformHandle: TransformHandleType,
|
||||||
naturalWidth: number,
|
naturalWidth: number,
|
||||||
naturalHeight: number,
|
naturalHeight: number,
|
||||||
@ -63,7 +63,7 @@ export const cropElement = (
|
|||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
pointFrom(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -6,27 +6,33 @@ import {
|
|||||||
|
|
||||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "./bounds";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToElement = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
|
case "selection":
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
@ -34,11 +40,15 @@ export const distanceToBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return distanceToRectanguloidElement(element, p);
|
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return distanceToDiamondElement(element, p);
|
return distanceToDiamondElement(element, elementsMap, p);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return distanceToEllipseElement(element, p);
|
return distanceToEllipseElement(element, elementsMap, p);
|
||||||
|
case "line":
|
||||||
|
case "arrow":
|
||||||
|
case "freedraw":
|
||||||
|
return distanceToLinearOrFreeDraElement(element, p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,9 +62,10 @@ export const distanceToBindableElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToRectanguloidElement = (
|
const distanceToRectanguloidElement = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToDiamondElement = (
|
const distanceToDiamondElement = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
@ -108,12 +120,24 @@ const distanceToDiamondElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToEllipseElement = (
|
const distanceToEllipseElement = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
return ellipseDistanceFromPoint(
|
return ellipseDistanceFromPoint(
|
||||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||||
pointRotateRads(p, center, -element.angle as Radians),
|
pointRotateRads(p, center, -element.angle as Radians),
|
||||||
ellipse(center, element.width / 2, element.height / 2),
|
ellipse(center, element.width / 2, element.height / 2),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const distanceToLinearOrFreeDraElement = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
p: GlobalPoint,
|
||||||
|
) => {
|
||||||
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
return Math.min(
|
||||||
|
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||||
|
...curves.map((a) => curvePointDistance(a, p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCommonBoundingBox } from "./bounds";
|
import { getCommonBoundingBox } from "./bounds";
|
||||||
import { newElementWith } from "./mutateElement";
|
import { newElementWith } from "./mutateElement";
|
||||||
|
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getSelectedElementsByGroup } from "./groups";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
@ -14,6 +16,7 @@ export const distributeElements = (
|
|||||||
selectedElements: ExcalidrawElement[],
|
selectedElements: ExcalidrawElement[],
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
distribution: Distribution,
|
distribution: Distribution,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
const [start, mid, end, extent] =
|
const [start, mid, end, extent] =
|
||||||
distribution.axis === "x"
|
distribution.axis === "x"
|
||||||
@ -21,7 +24,11 @@ export const distributeElements = (
|
|||||||
: (["minY", "midY", "maxY", "height"] as const);
|
: (["minY", "midY", "maxY", "height"] as const);
|
||||||
|
|
||||||
const bounds = getCommonBoundingBox(selectedElements);
|
const bounds = getCommonBoundingBox(selectedElements);
|
||||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
const groups = getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
elementsMap,
|
||||||
|
appState,
|
||||||
|
)
|
||||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
arrayToMap,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@ -29,10 +30,9 @@ import {
|
|||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
getHeadingForElbowArrowSnap,
|
getHeadingForElbowArrowSnap,
|
||||||
getGlobalFixedPointForBindableElement,
|
getGlobalFixedPointForBindableElement,
|
||||||
snapToMid,
|
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
compareHeading,
|
||||||
flipHeading,
|
flipHeading,
|
||||||
@ -52,7 +52,7 @@ import {
|
|||||||
type NonDeletedSceneElementsMap,
|
type NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
@ -359,6 +359,12 @@ const handleSegmentRelease = (
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!restoredPoints || restoredPoints.length < 2) {
|
||||||
|
throw new Error(
|
||||||
|
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const nextPoints: GlobalPoint[] = [];
|
const nextPoints: GlobalPoint[] = [];
|
||||||
|
|
||||||
// First part of the arrow are the old points
|
// First part of the arrow are the old points
|
||||||
@ -706,7 +712,7 @@ const handleEndpointDrag = (
|
|||||||
endGlobalPoint: GlobalPoint,
|
endGlobalPoint: GlobalPoint,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
) => {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||||
@ -741,8 +747,15 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second point connection and add the start point
|
// Calculate the moving second point connection and add the start point
|
||||||
{
|
{
|
||||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||||
|
|
||||||
|
if (!secondPoint || !thirdPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||||
const secondIsHorizontal = headingIsHorizontal(
|
const secondIsHorizontal = headingIsHorizontal(
|
||||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||||
@ -801,10 +814,19 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second to last point connection
|
// Calculate the moving second to last point connection
|
||||||
{
|
{
|
||||||
const secondToLastPoint =
|
const secondToLastPoint = globalUpdatedPoints.at(
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||||
const thirdToLastPoint =
|
);
|
||||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||||
|
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||||
thirdToLastPoint,
|
thirdToLastPoint,
|
||||||
@ -898,50 +920,6 @@ export const updateElbowArrowPoints = (
|
|||||||
return { points: updates.points ?? arrow.points };
|
return { points: updates.points ?? arrow.points };
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
|
|
||||||
// arrow size is valid. This check will be removed once the issue is identified
|
|
||||||
if (
|
|
||||||
arrow.x < -MAX_POS ||
|
|
||||||
arrow.x > MAX_POS ||
|
|
||||||
arrow.y < -MAX_POS ||
|
|
||||||
arrow.y > MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
|
|
||||||
MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
|
|
||||||
-MAX_POS ||
|
|
||||||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
|
|
||||||
) {
|
|
||||||
console.error(
|
|
||||||
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
|
|
||||||
{
|
|
||||||
arrow,
|
|
||||||
updates,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
|
|
||||||
// @ts-ignore See above note
|
|
||||||
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
|
|
||||||
if (updates.points) {
|
|
||||||
updates.points = updates.points.map(([x, y]) =>
|
|
||||||
pointFrom<LocalPoint>(
|
|
||||||
clamp(x, -MAX_POS, MAX_POS),
|
|
||||||
clamp(y, -MAX_POS, MAX_POS),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!import.meta.env.PROD) {
|
if (!import.meta.env.PROD) {
|
||||||
invariant(
|
invariant(
|
||||||
!updates.points || updates.points.length >= 2,
|
!updates.points || updates.points.length >= 2,
|
||||||
@ -1273,6 +1251,7 @@ const getElbowArrowData = (
|
|||||||
arrow.startBinding?.fixedPoint,
|
arrow.startBinding?.fixedPoint,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const endGlobalPoint = getGlobalPoint(
|
const endGlobalPoint = getGlobalPoint(
|
||||||
@ -1286,6 +1265,7 @@ const getElbowArrowData = (
|
|||||||
arrow.endBinding?.fixedPoint,
|
arrow.endBinding?.fixedPoint,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
options?.isDragging,
|
options?.isDragging,
|
||||||
);
|
);
|
||||||
const startHeading = getBindPointHeading(
|
const startHeading = getBindPointHeading(
|
||||||
@ -1293,12 +1273,14 @@ const getElbowArrowData = (
|
|||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
origStartGlobalPoint,
|
origStartGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
const endHeading = getBindPointHeading(
|
const endHeading = getBindPointHeading(
|
||||||
endGlobalPoint,
|
endGlobalPoint,
|
||||||
startGlobalPoint,
|
startGlobalPoint,
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
origEndGlobalPoint,
|
origEndGlobalPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
const startPointBounds = [
|
const startPointBounds = [
|
||||||
startGlobalPoint[0] - 2,
|
startGlobalPoint[0] - 2,
|
||||||
@ -1315,6 +1297,7 @@ const getElbowArrowData = (
|
|||||||
const startElementBounds = hoveredStartElement
|
const startElementBounds = hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
startHeading,
|
startHeading,
|
||||||
arrow.startArrowhead
|
arrow.startArrowhead
|
||||||
@ -1327,6 +1310,7 @@ const getElbowArrowData = (
|
|||||||
const endElementBounds = hoveredEndElement
|
const endElementBounds = hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
endHeading,
|
endHeading,
|
||||||
arrow.endArrowhead
|
arrow.endArrowhead
|
||||||
@ -1342,6 +1326,7 @@ const getElbowArrowData = (
|
|||||||
hoveredEndElement
|
hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
||||||
)
|
)
|
||||||
: endPointBounds,
|
: endPointBounds,
|
||||||
@ -1351,6 +1336,7 @@ const getElbowArrowData = (
|
|||||||
hoveredStartElement
|
hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
||||||
)
|
)
|
||||||
: startPointBounds,
|
: startPointBounds,
|
||||||
@ -1397,8 +1383,8 @@ const getElbowArrowData = (
|
|||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap,
|
boundsOverlap,
|
||||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
|
||||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
|
||||||
);
|
);
|
||||||
const startDonglePosition = getDonglePosition(
|
const startDonglePosition = getDonglePosition(
|
||||||
dynamicAABBs[0],
|
dynamicAABBs[0],
|
||||||
@ -2107,16 +2093,7 @@ const normalizeArrowElementUpdate = (
|
|||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): {
|
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||||
points: LocalPoint[];
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
fixedSegments: readonly FixedSegment[] | null;
|
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
|
||||||
} => {
|
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
let points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
@ -2229,35 +2206,28 @@ const getGlobalPoint = (
|
|||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: GlobalPoint,
|
initialPoint: GlobalPoint,
|
||||||
element?: ExcalidrawBindableElement | null,
|
element?: ExcalidrawBindableElement | null,
|
||||||
|
elementsMap?: ElementsMap,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
if (element) {
|
if (element && elementsMap) {
|
||||||
const snapPoint = bindPointToSnapToElementOutline(
|
return bindPointToSnapToElementOutline(
|
||||||
arrow,
|
arrow,
|
||||||
element,
|
element,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
return snapToMid(element, snapPoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
return getGlobalFixedPointForBindableElement(
|
||||||
fixedPointRatio || [0, 0],
|
fixedPointRatio || [0, 0],
|
||||||
element,
|
element,
|
||||||
|
elementsMap ?? arrayToMap([element]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
|
||||||
return Math.abs(
|
|
||||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
) > 0.01
|
|
||||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
|
||||||
: fixedGlobalPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
@ -2268,6 +2238,7 @@ const getBindPointHeading = (
|
|||||||
otherPoint: GlobalPoint,
|
otherPoint: GlobalPoint,
|
||||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||||
origPoint: GlobalPoint,
|
origPoint: GlobalPoint,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
): Heading =>
|
): Heading =>
|
||||||
getHeadingForElbowArrowSnap(
|
getHeadingForElbowArrowSnap(
|
||||||
p,
|
p,
|
||||||
@ -2276,7 +2247,8 @@ const getBindPointHeading = (
|
|||||||
hoveredElement &&
|
hoveredElement &&
|
||||||
aabbForElement(
|
aabbForElement(
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
elementsMap,
|
||||||
|
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
number,
|
number,
|
||||||
@ -2284,6 +2256,7 @@ const getBindPointHeading = (
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
origPoint,
|
origPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getHoveredElement = (
|
const getHoveredElement = (
|
||||||
|
|||||||
@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
|||||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||||
|
|
||||||
const RE_YOUTUBE =
|
const RE_YOUTUBE =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||||
|
|
||||||
const RE_VIMEO =
|
const RE_VIMEO =
|
||||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||||
@ -56,6 +56,35 @@ const RE_REDDIT =
|
|||||||
const RE_REDDIT_EMBED =
|
const RE_REDDIT_EMBED =
|
||||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||||
|
|
||||||
|
const parseYouTubeTimestamp = (url: string): number => {
|
||||||
|
let timeParam: string | null | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||||
|
timeParam =
|
||||||
|
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
||||||
|
} catch (error) {
|
||||||
|
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
||||||
|
timeParam = timeMatch?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeParam) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(timeParam)) {
|
||||||
|
return parseInt(timeParam, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||||
|
if (!timeMatch) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
||||||
|
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
const ALLOWED_DOMAINS = new Set([
|
const ALLOWED_DOMAINS = new Set([
|
||||||
"youtube.com",
|
"youtube.com",
|
||||||
"youtu.be",
|
"youtu.be",
|
||||||
@ -113,7 +142,8 @@ export const getEmbedLink = (
|
|||||||
let aspectRatio = { w: 560, h: 840 };
|
let aspectRatio = { w: 560, h: 840 };
|
||||||
const ytLink = link.match(RE_YOUTUBE);
|
const ytLink = link.match(RE_YOUTUBE);
|
||||||
if (ytLink?.[2]) {
|
if (ytLink?.[2]) {
|
||||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
const startTime = parseYouTubeTimestamp(originalLink);
|
||||||
|
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||||
const isPortrait = link.includes("shorts");
|
const isPortrait = link.includes("shorts");
|
||||||
type = "video";
|
type = "video";
|
||||||
switch (ytLink[1]) {
|
switch (ytLink[1]) {
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import {
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import { aabbForElement } from "./shapes";
|
import { aabbForElement } from "./bounds";
|
||||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||||
import {
|
import {
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
@ -95,10 +95,11 @@ const getNodeRelatives = (
|
|||||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||||
) as Readonly<LocalPoint>;
|
) as Readonly<LocalPoint>;
|
||||||
|
|
||||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
const heading = headingForPointFromElement(
|
||||||
edgePoint[0] + el.x,
|
node,
|
||||||
edgePoint[1] + el.y,
|
aabbForElement(node, elementsMap),
|
||||||
] as Readonly<GlobalPoint>);
|
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||||
|
);
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
relative,
|
relative,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
|||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { hasBoundTextElement } from "./typeChecks";
|
import { hasBoundTextElement } from "./typeChecks";
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class InvalidFractionalIndexError extends Error {
|
export class InvalidFractionalIndexError extends Error {
|
||||||
@ -161,9 +162,15 @@ export const syncMovedIndices = (
|
|||||||
|
|
||||||
// try generatating indices, throws on invalid movedElements
|
// try generatating indices, throws on invalid movedElements
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
const elementsCandidates = elements.map((x) =>
|
const elementsCandidates = elements.map((x) => {
|
||||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
const elementUpdates = elementsUpdates.get(x);
|
||||||
);
|
|
||||||
|
if (elementUpdates) {
|
||||||
|
return { ...x, index: elementUpdates.index };
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
// ensure next indices are valid before mutation, throws on invalid ones
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
validateFractionalIndices(
|
validateFractionalIndices(
|
||||||
@ -177,8 +184,8 @@ export const syncMovedIndices = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// split mutation so we don't end up in an incosistent state
|
// split mutation so we don't end up in an incosistent state
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fallback to default sync
|
// fallback to default sync
|
||||||
@ -189,7 +196,7 @@ export const syncMovedIndices = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||||
*
|
*
|
||||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
*/
|
*/
|
||||||
@ -200,13 +207,32 @@ export const syncInvalidIndices = (
|
|||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements as OrderedExcalidrawElement[];
|
return elements as OrderedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
|
||||||
|
*
|
||||||
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
|
*/
|
||||||
|
export const syncInvalidIndicesImmutable = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): SceneElementsMap | undefined => {
|
||||||
|
const syncedElements = arrayToMap(elements);
|
||||||
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
|
syncedElements.set(element.id, newElementWith(element, { index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncedElements as SceneElementsMap;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get contiguous groups of indices of passed moved elements.
|
* Get contiguous groups of indices of passed moved elements.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
|||||||
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { isBoundToContainer } from "./typeChecks";
|
||||||
|
|
||||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
|||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// given a list of selected elements, return the element grouped by their immediate group selected state
|
||||||
|
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
||||||
|
export const getSelectedElementsByGroup = (
|
||||||
|
selectedElements: ExcalidrawElement[],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
appState: Readonly<AppState>,
|
||||||
|
): ExcalidrawElement[][] => {
|
||||||
|
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||||
|
const unboundElements = selectedElements.filter(
|
||||||
|
(element) => !isBoundToContainer(element),
|
||||||
|
);
|
||||||
|
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
||||||
|
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
||||||
|
|
||||||
|
// helper function to add an element to the elements map
|
||||||
|
const addToElementsMap = (element: ExcalidrawElement) => {
|
||||||
|
// elements
|
||||||
|
const currentElementMembers = elements.get(element.id) || [];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
currentElementMembers.push(boundTextElement);
|
||||||
|
}
|
||||||
|
elements.set(element.id, [...currentElementMembers, element]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to add an element to the groups map
|
||||||
|
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
||||||
|
// groups
|
||||||
|
const currentGroupMembers = groups.get(groupId) || [];
|
||||||
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (boundTextElement) {
|
||||||
|
currentGroupMembers.push(boundTextElement);
|
||||||
|
}
|
||||||
|
groups.set(groupId, [...currentGroupMembers, element]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// helper function to handle the case where a single group is selected
|
||||||
|
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
||||||
|
// their nested grouping order
|
||||||
|
const handleSingleSelectedGroupCase = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
selectedGroupId: GroupId,
|
||||||
|
) => {
|
||||||
|
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
||||||
|
const nestedGroupCount = element.groupIds.slice(
|
||||||
|
0,
|
||||||
|
indexOfSelectedGroupId,
|
||||||
|
).length;
|
||||||
|
return nestedGroupCount > 0
|
||||||
|
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
||||||
|
: addToElementsMap(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllInSameGroup = selectedElements.every((element) =>
|
||||||
|
isSelectedViaGroup(appState, element),
|
||||||
|
);
|
||||||
|
|
||||||
|
unboundElements.forEach((element) => {
|
||||||
|
const selectedGroupId = getSelectedGroupIdForElement(
|
||||||
|
element,
|
||||||
|
appState.selectedGroupIds,
|
||||||
|
);
|
||||||
|
if (!selectedGroupId) {
|
||||||
|
addToElementsMap(element);
|
||||||
|
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
||||||
|
handleSingleSelectedGroupCase(element, selectedGroupId);
|
||||||
|
} else {
|
||||||
|
addToGroupsMap(element, selectedGroupId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
||||||
|
};
|
||||||
|
|||||||
@ -97,14 +97,13 @@ export * from "./image";
|
|||||||
export * from "./linearElementEditor";
|
export * from "./linearElementEditor";
|
||||||
export * from "./mutateElement";
|
export * from "./mutateElement";
|
||||||
export * from "./newElement";
|
export * from "./newElement";
|
||||||
|
export * from "./positionElementsOnGrid";
|
||||||
export * from "./renderElement";
|
export * from "./renderElement";
|
||||||
export * from "./resizeElements";
|
export * from "./resizeElements";
|
||||||
export * from "./resizeTest";
|
export * from "./resizeTest";
|
||||||
export * from "./Scene";
|
export * from "./Scene";
|
||||||
export * from "./selection";
|
export * from "./selection";
|
||||||
export * from "./Shape";
|
export * from "./shape";
|
||||||
export * from "./ShapeCache";
|
|
||||||
export * from "./shapes";
|
|
||||||
export * from "./showSelectedShapeActions";
|
export * from "./showSelectedShapeActions";
|
||||||
export * from "./sizeHelpers";
|
export * from "./sizeHelpers";
|
||||||
export * from "./sortElements";
|
export * from "./sortElements";
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
vectorFromPoint,
|
vectorFromPoint,
|
||||||
|
curveLength,
|
||||||
|
curvePointAtLength,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||||
@ -18,9 +20,14 @@ import {
|
|||||||
getGridPoint,
|
getGridPoint,
|
||||||
invariant,
|
invariant,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/element";
|
import {
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
|
isPathALoop,
|
||||||
|
type Store,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -39,6 +46,7 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
|
maybeSuggestBindingsForLinearElementAtCoords,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@ -55,18 +63,12 @@ import {
|
|||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||||
|
|
||||||
import {
|
|
||||||
isPathALoop,
|
|
||||||
getBezierCurveLength,
|
|
||||||
getControlPointsForBezierCurve,
|
|
||||||
mapIntervalToBezierT,
|
|
||||||
getBezierXY,
|
|
||||||
} from "./shapes";
|
|
||||||
|
|
||||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||||
|
|
||||||
|
import { isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type { Scene } from "./Scene";
|
import type { Scene } from "./Scene";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
@ -85,6 +87,35 @@ import type {
|
|||||||
PointsPositionUpdates,
|
PointsPositionUpdates,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes line points so that the start point is at [0,0]. This is
|
||||||
|
* expected in various parts of the codebase.
|
||||||
|
*
|
||||||
|
* Also returns the offsets - [0,0] if no normalization needed.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
const getNormalizedPoints = ({
|
||||||
|
points,
|
||||||
|
}: {
|
||||||
|
points: ExcalidrawLinearElement["points"];
|
||||||
|
}): {
|
||||||
|
points: LocalPoint[];
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
} => {
|
||||||
|
const offsetX = points[0][0];
|
||||||
|
const offsetY = points[0][1];
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: points.map((p) => {
|
||||||
|
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||||
|
}),
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export class LinearElementEditor {
|
export class LinearElementEditor {
|
||||||
public readonly elementId: ExcalidrawElement["id"] & {
|
public readonly elementId: ExcalidrawElement["id"] & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
@ -117,17 +148,24 @@ export class LinearElementEditor {
|
|||||||
public readonly hoverPointIndex: number;
|
public readonly hoverPointIndex: number;
|
||||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||||
public readonly elbowed: boolean;
|
public readonly elbowed: boolean;
|
||||||
|
public readonly customLineAngle: number | null;
|
||||||
|
public readonly isEditing: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
isEditing: boolean = false,
|
||||||
) {
|
) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
_brand: "excalidrawLinearElementId";
|
_brand: "excalidrawLinearElementId";
|
||||||
};
|
};
|
||||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||||
console.error("Linear element is not normalized", Error().stack);
|
console.error("Linear element is not normalized", Error().stack);
|
||||||
LinearElementEditor.normalizePoints(element, elementsMap);
|
mutateElement(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.selectedPointsIndices = null;
|
this.selectedPointsIndices = null;
|
||||||
this.lastUncommittedPoint = null;
|
this.lastUncommittedPoint = null;
|
||||||
@ -150,6 +188,8 @@ export class LinearElementEditor {
|
|||||||
this.hoverPointIndex = -1;
|
this.hoverPointIndex = -1;
|
||||||
this.segmentMidPointHoveredCoords = null;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||||
|
this.customLineAngle = null;
|
||||||
|
this.isEditing = isEditing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -157,6 +197,7 @@ export class LinearElementEditor {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
static POINT_HANDLE_SIZE = 10;
|
static POINT_HANDLE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id the `elementId` from the instance of this class (so that we can
|
* @param id the `elementId` from the instance of this class (so that we can
|
||||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||||
@ -178,11 +219,14 @@ export class LinearElementEditor {
|
|||||||
setState: React.Component<any, AppState>["setState"],
|
setState: React.Component<any, AppState>["setState"],
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
) {
|
) {
|
||||||
if (!appState.editingLinearElement || !appState.selectionElement) {
|
if (
|
||||||
|
!appState.selectedLinearElement?.isEditing ||
|
||||||
|
!appState.selectionElement
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const { editingLinearElement } = appState;
|
const { selectedLinearElement } = appState;
|
||||||
const { selectedPointsIndices, elementId } = editingLinearElement;
|
const { selectedPointsIndices, elementId } = selectedLinearElement;
|
||||||
|
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@ -223,8 +267,8 @@ export class LinearElementEditor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setState({
|
setState({
|
||||||
editingLinearElement: {
|
selectedLinearElement: {
|
||||||
...editingLinearElement,
|
...selectedLinearElement,
|
||||||
selectedPointsIndices: nextSelectedPoints.length
|
selectedPointsIndices: nextSelectedPoints.length
|
||||||
? nextSelectedPoints
|
? nextSelectedPoints
|
||||||
: null,
|
: null,
|
||||||
@ -240,19 +284,15 @@ export class LinearElementEditor {
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
pointSceneCoords: { x: number; y: number }[],
|
|
||||||
) => void,
|
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
scene: Scene,
|
): Pick<AppState, keyof AppState> | null {
|
||||||
): LinearElementEditor | null {
|
|
||||||
if (!linearElementEditor) {
|
if (!linearElementEditor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
let customLineAngle = linearElementEditor.customLineAngle;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -293,6 +333,12 @@ export class LinearElementEditor {
|
|||||||
const selectedIndex = selectedPointsIndices[0];
|
const selectedIndex = selectedPointsIndices[0];
|
||||||
const referencePoint =
|
const referencePoint =
|
||||||
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||||
|
customLineAngle =
|
||||||
|
linearElementEditor.customLineAngle ??
|
||||||
|
Math.atan2(
|
||||||
|
element.points[selectedIndex][1] - referencePoint[1],
|
||||||
|
element.points[selectedIndex][0] - referencePoint[0],
|
||||||
|
);
|
||||||
|
|
||||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||||
element,
|
element,
|
||||||
@ -300,11 +346,12 @@ export class LinearElementEditor {
|
|||||||
referencePoint,
|
referencePoint,
|
||||||
pointFrom(scenePointerX, scenePointerY),
|
pointFrom(scenePointerX, scenePointerY),
|
||||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||||
|
customLineAngle,
|
||||||
);
|
);
|
||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@ -332,7 +379,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
new Map(
|
new Map(
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
const newPointPosition: LocalPoint =
|
const newPointPosition: LocalPoint =
|
||||||
@ -364,15 +411,22 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
handleBindTextResize(element, scene, false);
|
handleBindTextResize(element, app.scene, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// suggest bindings for first and last point if selected
|
// suggest bindings for first and last point if selected
|
||||||
|
let suggestedBindings: ExcalidrawBindableElement[] = [];
|
||||||
if (isBindingElement(element, false)) {
|
if (isBindingElement(element, false)) {
|
||||||
|
const firstSelectedIndex = selectedPointsIndices[0] === 0;
|
||||||
|
const lastSelectedIndex =
|
||||||
|
selectedPointsIndices[selectedPointsIndices.length - 1] ===
|
||||||
|
element.points.length - 1;
|
||||||
const coords: { x: number; y: number }[] = [];
|
const coords: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
const firstSelectedIndex = selectedPointsIndices[0];
|
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||||
if (firstSelectedIndex === 0) {
|
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||||
|
} else {
|
||||||
|
if (firstSelectedIndex) {
|
||||||
coords.push(
|
coords.push(
|
||||||
tupleToCoors(
|
tupleToCoors(
|
||||||
LinearElementEditor.getPointGlobalCoordinates(
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
@ -384,26 +438,32 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSelectedIndex =
|
if (lastSelectedIndex) {
|
||||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
|
||||||
if (lastSelectedIndex === element.points.length - 1) {
|
|
||||||
coords.push(
|
coords.push(
|
||||||
tupleToCoors(
|
tupleToCoors(
|
||||||
LinearElementEditor.getPointGlobalCoordinates(
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
element.points[lastSelectedIndex],
|
element.points[
|
||||||
|
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||||
|
],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (coords.length) {
|
if (coords.length) {
|
||||||
maybeSuggestBinding(element, coords);
|
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||||
|
element,
|
||||||
|
coords,
|
||||||
|
app.scene,
|
||||||
|
app.state.zoom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const newLinearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
segmentMidPointHoveredCoords:
|
segmentMidPointHoveredCoords:
|
||||||
@ -421,6 +481,13 @@ export class LinearElementEditor {
|
|||||||
? lastClickedPoint
|
? lastClickedPoint
|
||||||
: -1,
|
: -1,
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
|
customLineAngle,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...app.state,
|
||||||
|
selectedLinearElement: newLinearElementEditor,
|
||||||
|
suggestedBindings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -435,6 +502,7 @@ export class LinearElementEditor {
|
|||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
|
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||||
|
|
||||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||||
editingLinearElement;
|
editingLinearElement;
|
||||||
@ -459,6 +527,18 @@ export class LinearElementEditor {
|
|||||||
selectedPoint === element.points.length - 1
|
selectedPoint === element.points.length - 1
|
||||||
) {
|
) {
|
||||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||||
|
if (isLineElement(element)) {
|
||||||
|
scene.mutateElement(
|
||||||
|
element,
|
||||||
|
{
|
||||||
|
...toggleLinePolygonState(element, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
informMutation: false,
|
||||||
|
isDragging: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
scene,
|
||||||
@ -478,13 +558,15 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
? getHoveredElementForBinding(
|
? getHoveredElementForBinding(
|
||||||
tupleToCoors(
|
(selectedPointsIndices?.length ?? 0) > 1
|
||||||
|
? tupleToCoors(
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
selectedPoint!,
|
selectedPoint!,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
: pointerCoords,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
@ -503,6 +585,8 @@ export class LinearElementEditor {
|
|||||||
return {
|
return {
|
||||||
...editingLinearElement,
|
...editingLinearElement,
|
||||||
...bindings,
|
...bindings,
|
||||||
|
segmentMidPointHoveredCoords: null,
|
||||||
|
hoverPointIndex: -1,
|
||||||
// if clicking without previously dragging a point(s), and not holding
|
// if clicking without previously dragging a point(s), and not holding
|
||||||
// shift, deselect all points except the one clicked. If holding shift,
|
// shift, deselect all points except the one clicked. If holding shift,
|
||||||
// toggle the point.
|
// toggle the point.
|
||||||
@ -524,6 +608,7 @@ export class LinearElementEditor {
|
|||||||
: selectedPointsIndices,
|
: selectedPointsIndices,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
pointerOffset: { x: 0, y: 0 },
|
pointerOffset: { x: 0, y: 0 },
|
||||||
|
customLineAngle: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,7 +622,7 @@ export class LinearElementEditor {
|
|||||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||||
if (
|
if (
|
||||||
!isElbowArrow(element) &&
|
!isElbowArrow(element) &&
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
element.points.length > 2 &&
|
element.points.length > 2 &&
|
||||||
!boundText
|
!boundText
|
||||||
) {
|
) {
|
||||||
@ -567,10 +652,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
element,
|
element,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
midpoints.push(segmentMidPoint);
|
midpoints.push(segmentMidPoint);
|
||||||
index++;
|
index++;
|
||||||
@ -606,7 +688,7 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
points.length >= 3 &&
|
points.length >= 3 &&
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
!isElbowArrow(element)
|
!isElbowArrow(element)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@ -672,7 +754,18 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
let distance = pointDistance(startPoint, endPoint);
|
let distance = pointDistance(startPoint, endPoint);
|
||||||
if (element.points.length > 2 && element.roundness) {
|
if (element.points.length > 2 && element.roundness) {
|
||||||
distance = getBezierCurveLength(element, endPoint);
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
lines.length === 0 && curves.length > 0,
|
||||||
|
"Only linears built out of curves are supported",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
lines.length + curves.length >= index,
|
||||||
|
"Invalid segment index while calculating mid point",
|
||||||
|
);
|
||||||
|
|
||||||
|
distance = curveLength<GlobalPoint>(curves[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||||
@ -680,39 +773,42 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static getSegmentMidPoint(
|
static getSegmentMidPoint(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: GlobalPoint,
|
index: number,
|
||||||
endPoint: GlobalPoint,
|
|
||||||
endPointIndex: number,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GlobalPoint {
|
): GlobalPoint {
|
||||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
if (isElbowArrow(element)) {
|
||||||
if (element.points.length > 2 && element.roundness) {
|
invariant(
|
||||||
const controlPoints = getControlPointsForBezierCurve(
|
element.points.length >= index,
|
||||||
element,
|
"Invalid segment index while calculating elbow arrow mid point",
|
||||||
element.points[endPointIndex],
|
|
||||||
);
|
|
||||||
if (controlPoints) {
|
|
||||||
const t = mapIntervalToBezierT(
|
|
||||||
element,
|
|
||||||
element.points[endPointIndex],
|
|
||||||
0.5,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||||
element,
|
|
||||||
getBezierXY(
|
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
),
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segmentMidPoint;
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
(lines.length === 0 && curves.length > 0) ||
|
||||||
|
(lines.length > 0 && curves.length === 0),
|
||||||
|
"Only linears built out of either segments or curves are supported",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
lines.length + curves.length >= index,
|
||||||
|
"Invalid segment index while calculating mid point",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
const segment = lines[index - 1];
|
||||||
|
return pointCenter(segment[0], segment[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curves.length) {
|
||||||
|
const segment = curves[index - 1];
|
||||||
|
return curvePointAtLength(segment, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant(false, "Invalid segment type while calculating mid point");
|
||||||
}
|
}
|
||||||
|
|
||||||
static getSegmentMidPointIndex(
|
static getSegmentMidPointIndex(
|
||||||
@ -789,7 +885,7 @@ export class LinearElementEditor {
|
|||||||
segmentMidpoint,
|
segmentMidpoint,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
} else if (event.altKey && appState.editingLinearElement) {
|
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
|
||||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
points: [
|
points: [
|
||||||
@ -931,14 +1027,14 @@ export class LinearElementEditor {
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): LinearElementEditor | null {
|
): LinearElementEditor | null {
|
||||||
const appState = app.state;
|
const appState = app.state;
|
||||||
if (!appState.editingLinearElement) {
|
if (!appState.selectedLinearElement?.isEditing) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return appState.editingLinearElement;
|
return appState.selectedLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
@ -946,14 +1042,14 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
if (!event.altKey) {
|
if (!event.altKey) {
|
||||||
if (lastPoint === lastUncommittedPoint) {
|
if (lastPoint === lastUncommittedPoint) {
|
||||||
LinearElementEditor.deletePoints(element, app.scene, [
|
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||||
points.length - 1,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
return {
|
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||||
...appState.editingLinearElement,
|
? {
|
||||||
|
...appState.selectedLinearElement,
|
||||||
lastUncommittedPoint: null,
|
lastUncommittedPoint: null,
|
||||||
};
|
}
|
||||||
|
: appState.selectedLinearElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newPoint: LocalPoint;
|
let newPoint: LocalPoint;
|
||||||
@ -977,8 +1073,8 @@ export class LinearElementEditor {
|
|||||||
newPoint = LinearElementEditor.createPointAt(
|
newPoint = LinearElementEditor.createPointAt(
|
||||||
element,
|
element,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||||
? null
|
? null
|
||||||
: app.getEffectiveGridSize(),
|
: app.getEffectiveGridSize(),
|
||||||
@ -999,10 +1095,10 @@ export class LinearElementEditor {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...appState.editingLinearElement,
|
...appState.selectedLinearElement,
|
||||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1142,48 +1238,31 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes line points so that the start point is at [0,0]. This is
|
* Normalizes line points so that the start point is at [0,0]. This is
|
||||||
* expected in various parts of the codebase. Also returns new x/y to account
|
* expected in various parts of the codebase.
|
||||||
* for the potential normalization.
|
*
|
||||||
|
* Also returns normalized x and y coords to account for the normalization
|
||||||
|
* of the points.
|
||||||
*/
|
*/
|
||||||
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
|
||||||
points: LocalPoint[];
|
const { points, offsetX, offsetY } = getNormalizedPoints(element);
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} {
|
|
||||||
const { points } = element;
|
|
||||||
|
|
||||||
const offsetX = points[0][0];
|
|
||||||
const offsetY = points[0][1];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
points: points.map((p) => {
|
points,
|
||||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
|
||||||
}),
|
|
||||||
x: element.x + offsetX,
|
x: element.x + offsetX,
|
||||||
y: element.y + offsetY,
|
y: element.y + offsetY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// element-mutating methods
|
// element-mutating methods
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
static normalizePoints(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
) {
|
|
||||||
mutateElement(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
LinearElementEditor.getNormalizedPoints(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||||
invariant(
|
invariant(
|
||||||
appState.editingLinearElement,
|
appState.selectedLinearElement?.isEditing,
|
||||||
"Not currently editing a linear element",
|
"Not currently editing a linear element",
|
||||||
);
|
);
|
||||||
|
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
invariant(
|
invariant(
|
||||||
@ -1245,8 +1324,8 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement: {
|
selectedLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.selectedLinearElement,
|
||||||
selectedPointsIndices: nextSelectedIndices,
|
selectedPointsIndices: nextSelectedIndices,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -1254,41 +1333,43 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static deletePoints(
|
static deletePoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
scene: Scene,
|
app: AppClassProperties,
|
||||||
pointIndices: readonly number[],
|
pointIndices: readonly number[],
|
||||||
) {
|
) {
|
||||||
let offsetX = 0;
|
const isUncommittedPoint =
|
||||||
let offsetY = 0;
|
app.state.selectedLinearElement?.isEditing &&
|
||||||
|
app.state.selectedLinearElement?.lastUncommittedPoint ===
|
||||||
|
element.points[element.points.length - 1];
|
||||||
|
|
||||||
const isDeletingOriginPoint = pointIndices.includes(0);
|
const nextPoints = element.points.filter((_, idx) => {
|
||||||
|
|
||||||
// if deleting first point, make the next to be [0,0] and recalculate
|
|
||||||
// positions of the rest with respect to it
|
|
||||||
if (isDeletingOriginPoint) {
|
|
||||||
const firstNonDeletedPoint = element.points.find((point, idx) => {
|
|
||||||
return !pointIndices.includes(idx);
|
return !pointIndices.includes(idx);
|
||||||
});
|
});
|
||||||
if (firstNonDeletedPoint) {
|
|
||||||
offsetX = firstNonDeletedPoint[0];
|
|
||||||
offsetY = firstNonDeletedPoint[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
const isPolygon = isLineElement(element) && element.polygon;
|
||||||
if (!pointIndices.includes(idx)) {
|
|
||||||
acc.push(
|
// keep polygon intact if deleting start/end point or uncommitted point
|
||||||
!acc.length
|
if (
|
||||||
? pointFrom(0, 0)
|
isPolygon &&
|
||||||
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
(isUncommittedPoint ||
|
||||||
|
pointIndices.includes(0) ||
|
||||||
|
pointIndices.includes(element.points.length - 1))
|
||||||
|
) {
|
||||||
|
nextPoints[0] = pointFrom(
|
||||||
|
nextPoints[nextPoints.length - 1][0],
|
||||||
|
nextPoints[nextPoints.length - 1][1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return acc;
|
|
||||||
}, []);
|
const {
|
||||||
|
points: normalizedPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
} = getNormalizedPoints({ points: nextPoints });
|
||||||
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
nextPoints,
|
normalizedPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
);
|
);
|
||||||
@ -1297,16 +1378,27 @@ export class LinearElementEditor {
|
|||||||
static addPoints(
|
static addPoints(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
targetPoints: { point: LocalPoint }[],
|
addedPoints: LocalPoint[],
|
||||||
) {
|
) {
|
||||||
const offsetX = 0;
|
const nextPoints = [...element.points, ...addedPoints];
|
||||||
const offsetY = 0;
|
|
||||||
|
if (isLineElement(element) && element.polygon) {
|
||||||
|
nextPoints[0] = pointFrom(
|
||||||
|
nextPoints[nextPoints.length - 1][0],
|
||||||
|
nextPoints[nextPoints.length - 1][1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
points: normalizedPoints,
|
||||||
|
offsetX,
|
||||||
|
offsetY,
|
||||||
|
} = getNormalizedPoints({ points: nextPoints });
|
||||||
|
|
||||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
|
||||||
LinearElementEditor._updatePoints(
|
LinearElementEditor._updatePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
scene,
|
||||||
nextPoints,
|
normalizedPoints,
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetY,
|
offsetY,
|
||||||
);
|
);
|
||||||
@ -1323,17 +1415,37 @@ export class LinearElementEditor {
|
|||||||
) {
|
) {
|
||||||
const { points } = element;
|
const { points } = element;
|
||||||
|
|
||||||
|
// if polygon, move start and end points together
|
||||||
|
if (isLineElement(element) && element.polygon) {
|
||||||
|
const firstPointUpdate = pointUpdates.get(0);
|
||||||
|
const lastPointUpdate = pointUpdates.get(points.length - 1);
|
||||||
|
|
||||||
|
if (firstPointUpdate) {
|
||||||
|
pointUpdates.set(points.length - 1, {
|
||||||
|
point: pointFrom(
|
||||||
|
firstPointUpdate.point[0],
|
||||||
|
firstPointUpdate.point[1],
|
||||||
|
),
|
||||||
|
isDragging: firstPointUpdate.isDragging,
|
||||||
|
});
|
||||||
|
} else if (lastPointUpdate) {
|
||||||
|
pointUpdates.set(0, {
|
||||||
|
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
|
||||||
|
isDragging: lastPointUpdate.isDragging,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// in case we're moving start point, instead of modifying its position
|
// in case we're moving start point, instead of modifying its position
|
||||||
// which would break the invariant of it being at [0,0], we move
|
// which would break the invariant of it being at [0,0], we move
|
||||||
// all the other points in the opposite direction by delta to
|
// all the other points in the opposite direction by delta to
|
||||||
// offset it. We do the same with actual element.x/y position, so
|
// offset it. We do the same with actual element.x/y position, so
|
||||||
// this hacks are completely transparent to the user.
|
// this hacks are completely transparent to the user.
|
||||||
const [deltaX, deltaY] =
|
|
||||||
|
const updatedOriginPoint =
|
||||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
|
||||||
deltaX - points[0][0],
|
const [offsetX, offsetY] = updatedOriginPoint;
|
||||||
deltaY - points[0][1],
|
|
||||||
);
|
|
||||||
|
|
||||||
const nextPoints = isElbowArrow(element)
|
const nextPoints = isElbowArrow(element)
|
||||||
? [
|
? [
|
||||||
@ -1400,7 +1512,7 @@ export class LinearElementEditor {
|
|||||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@ -1503,6 +1615,7 @@ export class LinearElementEditor {
|
|||||||
isDragging: options?.isDragging ?? false,
|
isDragging: options?.isDragging ?? false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// TODO do we need to get precise coords here just to calc centers?
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
const prevCoords = getElementPointsCoords(element, element.points);
|
const prevCoords = getElementPointsCoords(element, element.points);
|
||||||
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
||||||
@ -1511,7 +1624,7 @@ export class LinearElementEditor {
|
|||||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||||
const dX = prevCenterX - nextCenterX;
|
const dX = prevCenterX - nextCenterX;
|
||||||
const dY = prevCenterY - nextCenterY;
|
const dY = prevCenterY - nextCenterY;
|
||||||
const rotated = pointRotateRads(
|
const rotatedOffset = pointRotateRads(
|
||||||
pointFrom(offsetX, offsetY),
|
pointFrom(offsetX, offsetY),
|
||||||
pointFrom(dX, dY),
|
pointFrom(dX, dY),
|
||||||
element.angle,
|
element.angle,
|
||||||
@ -1519,8 +1632,8 @@ export class LinearElementEditor {
|
|||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
...otherUpdates,
|
...otherUpdates,
|
||||||
points: nextPoints,
|
points: nextPoints,
|
||||||
x: element.x + rotated[0],
|
x: element.x + rotatedOffset[0],
|
||||||
y: element.y + rotated[1],
|
y: element.y + rotatedOffset[1],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1531,6 +1644,7 @@ export class LinearElementEditor {
|
|||||||
referencePoint: LocalPoint,
|
referencePoint: LocalPoint,
|
||||||
scenePointer: GlobalPoint,
|
scenePointer: GlobalPoint,
|
||||||
gridSize: NullableGridSize,
|
gridSize: NullableGridSize,
|
||||||
|
customLineAngle?: number,
|
||||||
) {
|
) {
|
||||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
@ -1556,6 +1670,7 @@ export class LinearElementEditor {
|
|||||||
referencePointCoords[1],
|
referencePointCoords[1],
|
||||||
gridX,
|
gridX,
|
||||||
gridY,
|
gridY,
|
||||||
|
customLineAngle,
|
||||||
);
|
);
|
||||||
|
|
||||||
return pointRotateRads(
|
return pointRotateRads(
|
||||||
@ -1592,10 +1707,7 @@ export class LinearElementEditor {
|
|||||||
const index = element.points.length / 2 - 1;
|
const index = element.points.length / 2 - 1;
|
||||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
element,
|
element,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ import type {
|
|||||||
|
|
||||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
"id" | "version" | "versionNonce" | "updated"
|
"id" | "updated"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.version++;
|
element.version = updates.version ?? element.version + 1;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
...updates,
|
...updates,
|
||||||
|
version: updates.version ?? element.version + 1,
|
||||||
|
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||||
updated: getUpdatedTimestamp(),
|
updated: getUpdatedTimestamp(),
|
||||||
version: element.version + 1,
|
|
||||||
versionNonce: randomInteger(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
|
|||||||
import { normalizeText, measureText } from "./textMeasurements";
|
import { normalizeText, measureText } from "./textMeasurements";
|
||||||
import { wrapText } from "./textWrapping";
|
import { wrapText } from "./textWrapping";
|
||||||
|
|
||||||
|
import { isLineElement } from "./typeChecks";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
@ -45,6 +47,7 @@ import type {
|
|||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type ElementConstructorOpts = MarkOptional<
|
export type ElementConstructorOpts = MarkOptional<
|
||||||
@ -457,9 +460,10 @@ export const newLinearElement = (
|
|||||||
opts: {
|
opts: {
|
||||||
type: ExcalidrawLinearElement["type"];
|
type: ExcalidrawLinearElement["type"];
|
||||||
points?: ExcalidrawLinearElement["points"];
|
points?: ExcalidrawLinearElement["points"];
|
||||||
|
polygon?: ExcalidrawLineElement["polygon"];
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawLinearElement> => {
|
): NonDeleted<ExcalidrawLinearElement> => {
|
||||||
return {
|
const element = {
|
||||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||||
points: opts.points || [],
|
points: opts.points || [],
|
||||||
lastCommittedPoint: null,
|
lastCommittedPoint: null,
|
||||||
@ -468,6 +472,17 @@ export const newLinearElement = (
|
|||||||
startArrowhead: null,
|
startArrowhead: null,
|
||||||
endArrowhead: null,
|
endArrowhead: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLineElement(element)) {
|
||||||
|
const lineElement: NonDeleted<ExcalidrawLineElement> = {
|
||||||
|
...element,
|
||||||
|
polygon: opts.polygon ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return lineElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newArrowElement = <T extends boolean>(
|
export const newArrowElement = <T extends boolean>(
|
||||||
|
|||||||
112
packages/element/src/positionElementsOnGrid.ts
Normal file
112
packages/element/src/positionElementsOnGrid.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@ -1,7 +1,14 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { getStroke } from "perfect-freehand";
|
import { getStroke } from "perfect-freehand";
|
||||||
|
|
||||||
import { isRightAngleRads } from "@excalidraw/math";
|
import {
|
||||||
|
type GlobalPoint,
|
||||||
|
isRightAngleRads,
|
||||||
|
lineSegment,
|
||||||
|
pointFrom,
|
||||||
|
pointRotateRads,
|
||||||
|
type Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
@ -14,6 +21,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
isRTL,
|
isRTL,
|
||||||
getVerticalOffset,
|
getVerticalOffset,
|
||||||
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -32,7 +40,7 @@ import type {
|
|||||||
InteractiveCanvasRenderConfig,
|
InteractiveCanvasRenderConfig,
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords } from "./bounds";
|
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||||
import { getUncroppedImageElement } from "./cropElement";
|
import { getUncroppedImageElement } from "./cropElement";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import {
|
import {
|
||||||
@ -54,9 +62,9 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { getCornerRadius } from "./shapes";
|
import { getCornerRadius } from "./utils";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -106,6 +114,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
|||||||
return element.strokeWidth * 12;
|
return element.strokeWidth * 12;
|
||||||
case "text":
|
case "text":
|
||||||
return element.fontSize / 2;
|
return element.fontSize / 2;
|
||||||
|
case "arrow":
|
||||||
|
if (element.endArrowhead || element.endArrowhead) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
return 20;
|
||||||
default:
|
default:
|
||||||
return 20;
|
return 20;
|
||||||
}
|
}
|
||||||
@ -1034,6 +1047,66 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||||
|
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlineAsSegments(
|
||||||
|
element: ExcalidrawFreeDrawElement,
|
||||||
|
points: [number, number][],
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) {
|
||||||
|
const bounds = getElementBounds(
|
||||||
|
{
|
||||||
|
...element,
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
const center = pointFrom<GlobalPoint>(
|
||||||
|
(bounds[0] + bounds[2]) / 2,
|
||||||
|
(bounds[1] + bounds[3]) / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
|
||||||
|
|
||||||
|
return points.slice(2).reduce(
|
||||||
|
(acc, curr) => {
|
||||||
|
acc.push(
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
acc[acc.length - 1][1],
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[0][0] + element.x,
|
||||||
|
points[0][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
points[1][0] + element.x,
|
||||||
|
points[1][1] + element.y,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
|
||||||
// If input points are empty (should they ever be?) return a dot
|
// If input points are empty (should they ever be?) return a dot
|
||||||
const inputPoints = element.simulatePressure
|
const inputPoints = element.simulatePressure
|
||||||
? element.points
|
? element.points
|
||||||
@ -1052,7 +1125,7 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
|||||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||||
};
|
};
|
||||||
|
|
||||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
return getStroke(inputPoints as number[][], options) as [number, number][];
|
||||||
}
|
}
|
||||||
|
|
||||||
function med(A: number[], B: number[]) {
|
function med(A: number[], B: number[]) {
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
pointCenter,
|
pointCenter,
|
||||||
normalizeRadians,
|
normalizeRadians,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
@ -36,6 +35,7 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
getMinTextElementWidth,
|
getMinTextElementWidth,
|
||||||
@ -104,18 +104,6 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
updateBoundElements(element, scene);
|
updateBoundElements(element, scene);
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element) && transformHandleType) {
|
|
||||||
resizeSingleTextElement(
|
|
||||||
originalElements,
|
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
transformHandleType,
|
|
||||||
shouldResizeFromCenter,
|
|
||||||
pointerX,
|
|
||||||
pointerY,
|
|
||||||
);
|
|
||||||
updateBoundElements(element, scene);
|
|
||||||
return true;
|
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
const elementId = selectedElements[0].id;
|
const elementId = selectedElements[0].id;
|
||||||
const latestElement = elementsMap.get(elementId);
|
const latestElement = elementsMap.get(elementId);
|
||||||
@ -150,6 +138,9 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
updateBoundElements(element, scene);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (selectedElements.length > 1) {
|
} else if (selectedElements.length > 1) {
|
||||||
if (transformHandleType === "rotation") {
|
if (transformHandleType === "rotation") {
|
||||||
@ -235,7 +226,16 @@ const rotateSingleElement = (
|
|||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
|
|
||||||
if (textElement && !isArrowElement(element)) {
|
if (textElement && !isArrowElement(element)) {
|
||||||
scene.mutateElement(textElement, { angle });
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
textElement,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
scene.mutateElement(textElement, {
|
||||||
|
angle,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -282,151 +282,50 @@ export const measureFontSizeFromWidth = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
export const resizeSingleTextElement = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
transformHandleType: TransformHandleDirection,
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
nextWidth: number,
|
||||||
pointerY: number,
|
nextHeight: number,
|
||||||
) => {
|
) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
// rotation pointer with reverse angle
|
|
||||||
const [rotatedX, rotatedY] = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
-element.angle as Radians,
|
|
||||||
);
|
|
||||||
let scaleX = 0;
|
|
||||||
let scaleY = 0;
|
|
||||||
|
|
||||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
const metricsWidth = element.width * (nextHeight / element.height);
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("w")) {
|
|
||||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("n")) {
|
|
||||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("s")) {
|
|
||||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = Math.max(scaleX, scaleY);
|
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||||
|
|
||||||
if (scale > 0) {
|
|
||||||
const nextWidth = element.width * scale;
|
|
||||||
const nextHeight = element.height * scale;
|
|
||||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
|
||||||
if (metrics === null) {
|
if (metrics === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTopLeft = [x1, y1];
|
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||||
const startBottomRight = [x2, y2];
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
const startCenter = [cx, cy];
|
|
||||||
|
|
||||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
const newOrigin = getResizedOrigin(
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
previousOrigin,
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
origElement.width,
|
||||||
startBottomRight[0] - Math.abs(nextWidth),
|
origElement.height,
|
||||||
startBottomRight[1] - Math.abs(nextHeight),
|
metricsWidth,
|
||||||
|
nextHeight,
|
||||||
|
origElement.angle,
|
||||||
|
transformHandleType,
|
||||||
|
false,
|
||||||
|
shouldResizeFromCenter,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (transformHandleType === "ne") {
|
|
||||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
bottomLeft[0],
|
|
||||||
bottomLeft[1] - Math.abs(nextHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (transformHandleType === "sw") {
|
|
||||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
topRight[0] - Math.abs(nextWidth),
|
|
||||||
topRight[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["s", "n"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[0] = startCenter[0] - nextWidth / 2;
|
|
||||||
}
|
|
||||||
if (["e", "w"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[1] = startCenter[1] - nextHeight / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldResizeFromCenter) {
|
|
||||||
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
|
|
||||||
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angle = element.angle;
|
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
newTopLeft,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
const newCenter = pointFrom<GlobalPoint>(
|
|
||||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(
|
|
||||||
newCenter,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
|
||||||
const [nextX, nextY] = newTopLeft;
|
|
||||||
|
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fontSize: metrics.size,
|
fontSize: metrics.size,
|
||||||
width: nextWidth,
|
width: metricsWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: nextX,
|
x: newOrigin.x,
|
||||||
y: nextY,
|
y: newOrigin.y,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transformHandleType === "e" || transformHandleType === "w") {
|
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
|
||||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
|
||||||
stateAtResizeStart.width,
|
|
||||||
stateAtResizeStart.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
|
||||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
|
||||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
|
||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
startCenter,
|
|
||||||
-stateAtResizeStart.angle as Radians,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
element.width,
|
|
||||||
element.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
|
||||||
|
|
||||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
|
||||||
const minWidth = getMinTextElementWidth(
|
const minWidth = getMinTextElementWidth(
|
||||||
getFontString({
|
getFontString({
|
||||||
fontSize: element.fontSize,
|
fontSize: element.fontSize,
|
||||||
@ -435,17 +334,7 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
const newWidth = Math.max(minWidth, nextWidth);
|
||||||
|
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("w")) {
|
|
||||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWidth =
|
|
||||||
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
|
||||||
|
|
||||||
const text = wrapText(
|
const text = wrapText(
|
||||||
element.originalText,
|
element.originalText,
|
||||||
@ -458,49 +347,27 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
const eleNewHeight = metrics.height;
|
const newHeight = metrics.height;
|
||||||
|
|
||||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
const newOrigin = getResizedOrigin(
|
||||||
|
previousOrigin,
|
||||||
|
origElement.width,
|
||||||
|
origElement.height,
|
||||||
newWidth,
|
newWidth,
|
||||||
eleNewHeight,
|
newHeight,
|
||||||
true,
|
element.angle,
|
||||||
);
|
transformHandleType,
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
false,
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
shouldResizeFromCenter,
|
||||||
|
|
||||||
let newTopLeft = [...startTopLeft] as [number, number];
|
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
|
||||||
newTopLeft = [
|
|
||||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
|
||||||
startTopLeft[1],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjust topLeft to new rotation point
|
|
||||||
const angle = stateAtResizeStart.angle;
|
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
pointFromPair(newTopLeft),
|
|
||||||
startCenter,
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
const newCenter = pointFrom(
|
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||||
width: Math.abs(newWidth),
|
width: Math.abs(newWidth),
|
||||||
height: Math.abs(metrics.height),
|
height: Math.abs(metrics.height),
|
||||||
x: newTopLeft[0],
|
x: newOrigin.x,
|
||||||
y: newTopLeft[1],
|
y: newOrigin.y,
|
||||||
text,
|
text,
|
||||||
autoResize: false,
|
autoResize: false,
|
||||||
};
|
};
|
||||||
@ -559,9 +426,15 @@ const rotateMultipleElements = (
|
|||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
|
const { x, y } = computeBoundTextPosition(
|
||||||
|
element,
|
||||||
|
boundText,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
scene.mutateElement(boundText, {
|
scene.mutateElement(boundText, {
|
||||||
x: boundText.x + (rotatedCX - cx),
|
x,
|
||||||
y: boundText.y + (rotatedCY - cy),
|
y,
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -821,6 +694,18 @@ export const resizeSingleElement = (
|
|||||||
shouldInformMutation?: boolean;
|
shouldInformMutation?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||||
|
return resizeSingleTextElement(
|
||||||
|
origElement,
|
||||||
|
latestElement,
|
||||||
|
scene,
|
||||||
|
handleDirection,
|
||||||
|
shouldResizeFromCenter,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
@ -1518,11 +1403,7 @@ export const resizeMultipleElements = (
|
|||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { width, height, angle } = update;
|
const { width, height, angle } = update;
|
||||||
|
|
||||||
scene.mutateElement(element, update, {
|
scene.mutateElement(element, update);
|
||||||
informMutation: true,
|
|
||||||
// needed for the fixed binding point udpate to take effect
|
|
||||||
isDragging: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
|
|||||||
@ -1,26 +1,65 @@
|
|||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
|
|
||||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
import {
|
||||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
type GeometricShape,
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
} from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
|
import {
|
||||||
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
|
type LocalPoint,
|
||||||
|
pointRotateRads,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
import {
|
||||||
|
ROUGHNESS,
|
||||||
|
isTransparent,
|
||||||
|
assertNever,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
|
|
||||||
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
import type {
|
||||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
AppState,
|
||||||
|
EmbedsValidationStatus,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import type {
|
||||||
|
ElementShape,
|
||||||
|
ElementShapes,
|
||||||
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
|
import { elementWithCanvasCache } from "./renderElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canBecomePolygon,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isEmbeddableElement,
|
isEmbeddableElement,
|
||||||
isIframeElement,
|
isIframeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
import { getCornerRadius, isPathALoop } from "./utils";
|
||||||
import { headingForPointIsHorizontal } from "./heading";
|
import { headingForPointIsHorizontal } from "./heading";
|
||||||
|
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
import { generateFreeDrawShape } from "./renderElement";
|
import { generateFreeDrawShape } from "./renderElement";
|
||||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
import {
|
||||||
|
getArrowheadPoints,
|
||||||
|
getCenterForBounds,
|
||||||
|
getDiamondPoints,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
} from "./bounds";
|
||||||
|
import { shouldTestInside } from "./collision";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -28,12 +67,89 @@ import type {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
|
||||||
|
export class ShapeCache {
|
||||||
|
private static rg = new RoughGenerator();
|
||||||
|
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves shape from cache if available. Use this only if shape
|
||||||
|
* is optional and you have a fallback in case it's not cached.
|
||||||
|
*/
|
||||||
|
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||||
|
return ShapeCache.cache.get(
|
||||||
|
element,
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]] | undefined
|
||||||
|
: ElementShape | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
public static set = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
shape: T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable,
|
||||||
|
) => ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
public static delete = (element: ExcalidrawElement) =>
|
||||||
|
ShapeCache.cache.delete(element);
|
||||||
|
|
||||||
|
public static destroy = () => {
|
||||||
|
ShapeCache.cache = new WeakMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates & caches shape for element if not already cached, otherwise
|
||||||
|
* returns cached shape.
|
||||||
|
*/
|
||||||
|
public static generateElementShape = <
|
||||||
|
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
>(
|
||||||
|
element: T,
|
||||||
|
renderConfig: {
|
||||||
|
isExporting: boolean;
|
||||||
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
} | null,
|
||||||
|
) => {
|
||||||
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
|
const cachedShape = renderConfig?.isExporting
|
||||||
|
? undefined
|
||||||
|
: ShapeCache.get(element);
|
||||||
|
|
||||||
|
// `null` indicates no rc shape applicable for this element type,
|
||||||
|
// but it's considered a valid cache value (= do not regenerate)
|
||||||
|
if (cachedShape !== undefined) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
|
const shape = generateElementShape(
|
||||||
|
element,
|
||||||
|
ShapeCache.rg,
|
||||||
|
renderConfig || {
|
||||||
|
isExporting: false,
|
||||||
|
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
embedsValidationStatus: null,
|
||||||
|
},
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable | null;
|
||||||
|
|
||||||
|
ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||||
@ -303,6 +419,182 @@ const getArrowheadShapes = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateLinearCollisionShape = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
) => {
|
||||||
|
const generator = new RoughGenerator();
|
||||||
|
const options: Options = {
|
||||||
|
seed: element.seed,
|
||||||
|
disableMultiStroke: true,
|
||||||
|
disableMultiStrokeFill: true,
|
||||||
|
roughness: 0,
|
||||||
|
preserveVertices: true,
|
||||||
|
};
|
||||||
|
const center = getCenterForBounds(
|
||||||
|
// Need a non-rotated center point
|
||||||
|
element.points.reduce(
|
||||||
|
(acc, point) => {
|
||||||
|
return [
|
||||||
|
Math.min(element.x + point[0], acc[0]),
|
||||||
|
Math.min(element.y + point[1], acc[1]),
|
||||||
|
Math.max(element.x + point[0], acc[2]),
|
||||||
|
Math.max(element.y + point[1], acc[3]),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[Infinity, Infinity, -Infinity, -Infinity],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case "line":
|
||||||
|
case "arrow": {
|
||||||
|
// points array can be empty in the beginning, so it is important to add
|
||||||
|
// initial position to it
|
||||||
|
const points = element.points.length
|
||||||
|
? element.points
|
||||||
|
: [pointFrom<LocalPoint>(0, 0)];
|
||||||
|
|
||||||
|
if (isElbowArrow(element)) {
|
||||||
|
return generator.path(generateElbowArrowShape(points, 16), options)
|
||||||
|
.sets[0].ops;
|
||||||
|
} else if (!element.roundness) {
|
||||||
|
return points.map((point, idx) => {
|
||||||
|
const p = pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: idx === 0 ? "move" : "lineTo",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(points as unknown as RoughPoint[], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "freedraw": {
|
||||||
|
if (element.points.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplifiedPoints = simplify(
|
||||||
|
element.points as Mutable<LocalPoint[]>,
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(simplifiedPoints as [number, number][], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the roughjs shape for given element.
|
* Generates the roughjs shape for given element.
|
||||||
*
|
*
|
||||||
@ -310,7 +602,7 @@ const getArrowheadShapes = (
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
export const _generateElementShape = (
|
const generateElementShape = (
|
||||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
{
|
{
|
||||||
@ -611,3 +903,103 @@ const generateElbowArrowShape = (
|
|||||||
|
|
||||||
return d.join(" ");
|
return d.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the pure geometric shape of an excalidraw elementw
|
||||||
|
* which is then used for hit detection
|
||||||
|
*/
|
||||||
|
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): GeometricShape<Point> => {
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "image":
|
||||||
|
case "iframe":
|
||||||
|
case "text":
|
||||||
|
case "selection":
|
||||||
|
return getPolygonShape(element);
|
||||||
|
case "arrow":
|
||||||
|
case "line": {
|
||||||
|
const roughShape =
|
||||||
|
ShapeCache.get(element)?.[0] ??
|
||||||
|
ShapeCache.generateElementShape(element, null)[0];
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
|
return shouldTestInside(element)
|
||||||
|
? getClosedCurveShape<Point>(
|
||||||
|
element,
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
)
|
||||||
|
: getCurveShape<Point>(
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ellipse":
|
||||||
|
return getEllipseShape(element);
|
||||||
|
|
||||||
|
case "freedraw": {
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
return getFreedrawShape(
|
||||||
|
element,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
shouldTestInside(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleLinePolygonState = (
|
||||||
|
element: ExcalidrawLineElement,
|
||||||
|
nextPolygonState: boolean,
|
||||||
|
): {
|
||||||
|
polygon: ExcalidrawLineElement["polygon"];
|
||||||
|
points: ExcalidrawLineElement["points"];
|
||||||
|
} | null => {
|
||||||
|
const updatedPoints = [...element.points];
|
||||||
|
|
||||||
|
if (nextPolygonState) {
|
||||||
|
if (!canBecomePolygon(element.points)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPoint = updatedPoints[0];
|
||||||
|
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||||
|
|
||||||
|
const distance = Math.hypot(
|
||||||
|
firstPoint[0] - lastPoint[0],
|
||||||
|
firstPoint[1] - lastPoint[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||||
|
updatedPoints.length < 4
|
||||||
|
) {
|
||||||
|
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||||
|
} else {
|
||||||
|
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||||
|
firstPoint[0],
|
||||||
|
firstPoint[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||||
|
const ret = {
|
||||||
|
polygon: nextPolygonState,
|
||||||
|
points: updatedPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
@ -1,398 +0,0 @@
|
|||||||
import {
|
|
||||||
DEFAULT_ADAPTIVE_RADIUS,
|
|
||||||
DEFAULT_PROPORTIONAL_RADIUS,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
|
||||||
ROUNDNESS,
|
|
||||||
invariant,
|
|
||||||
elementCenterPoint,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
import {
|
|
||||||
isPoint,
|
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
|
||||||
pointsEqual,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
import {
|
|
||||||
getClosedCurveShape,
|
|
||||||
getCurvePathOps,
|
|
||||||
getCurveShape,
|
|
||||||
getEllipseShape,
|
|
||||||
getFreedrawShape,
|
|
||||||
getPolygonShape,
|
|
||||||
type GeometricShape,
|
|
||||||
} from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { shouldTestInside } from "./collision";
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
NonDeleted,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get the pure geometric shape of an excalidraw elementw
|
|
||||||
* which is then used for hit detection
|
|
||||||
*/
|
|
||||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> => {
|
|
||||||
switch (element.type) {
|
|
||||||
case "rectangle":
|
|
||||||
case "diamond":
|
|
||||||
case "frame":
|
|
||||||
case "magicframe":
|
|
||||||
case "embeddable":
|
|
||||||
case "image":
|
|
||||||
case "iframe":
|
|
||||||
case "text":
|
|
||||||
case "selection":
|
|
||||||
return getPolygonShape(element);
|
|
||||||
case "arrow":
|
|
||||||
case "line": {
|
|
||||||
const roughShape =
|
|
||||||
ShapeCache.get(element)?.[0] ??
|
|
||||||
ShapeCache.generateElementShape(element, null)[0];
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
|
|
||||||
return shouldTestInside(element)
|
|
||||||
? getClosedCurveShape<Point>(
|
|
||||||
element,
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
)
|
|
||||||
: getCurveShape<Point>(
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ellipse":
|
|
||||||
return getEllipseShape(element);
|
|
||||||
|
|
||||||
case "freedraw": {
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
return getFreedrawShape(
|
|
||||||
element,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
shouldTestInside(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> | null => {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
if (element.type === "arrow") {
|
|
||||||
return getElementShape(
|
|
||||||
{
|
|
||||||
...boundTextElement,
|
|
||||||
// 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,
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return getElementShape(boundTextElement, elementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getControlPointsForBezierCurve = <
|
|
||||||
P extends GlobalPoint | LocalPoint,
|
|
||||||
>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const shape = ShapeCache.generateElementShape(element, null);
|
|
||||||
if (!shape) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ops = getCurvePathOps(shape[0]);
|
|
||||||
let currentP = pointFrom<P>(0, 0);
|
|
||||||
let index = 0;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
let controlPoints: P[] | null = null;
|
|
||||||
|
|
||||||
while (index < ops.length) {
|
|
||||||
const { op, data } = ops[index];
|
|
||||||
if (op === "move") {
|
|
||||||
invariant(
|
|
||||||
isPoint(data),
|
|
||||||
"The returned ops is not compatible with a point",
|
|
||||||
);
|
|
||||||
currentP = pointFromPair(data);
|
|
||||||
}
|
|
||||||
if (op === "bcurveTo") {
|
|
||||||
const p0 = currentP;
|
|
||||||
const p1 = pointFrom<P>(data[0], data[1]);
|
|
||||||
const p2 = pointFrom<P>(data[2], data[3]);
|
|
||||||
const p3 = pointFrom<P>(data[4], data[5]);
|
|
||||||
const distance = pointDistance(p3, endPoint);
|
|
||||||
if (distance < minDistance) {
|
|
||||||
minDistance = distance;
|
|
||||||
controlPoints = [p0, p1, p2, p3];
|
|
||||||
}
|
|
||||||
currentP = p3;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return controlPoints;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p0: P,
|
|
||||||
p1: P,
|
|
||||||
p2: P,
|
|
||||||
p3: P,
|
|
||||||
t: number,
|
|
||||||
): P => {
|
|
||||||
const equation = (t: number, idx: number) =>
|
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
const tx = equation(t, 0);
|
|
||||||
const ty = equation(t, 1);
|
|
||||||
return pointFrom(tx, ty);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
|
||||||
if (!controlPoints) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const pointsOnCurve: P[] = [];
|
|
||||||
let t = 1;
|
|
||||||
// Take 20 points on curve for better accuracy
|
|
||||||
while (t > 0) {
|
|
||||||
const p = getBezierXY(
|
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
|
||||||
t -= 0.05;
|
|
||||||
}
|
|
||||||
if (pointsOnCurve.length) {
|
|
||||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
|
||||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pointsOnCurve;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths: number[] = [];
|
|
||||||
arcLengths[0] = 0;
|
|
||||||
const points = getPointsInBezierCurve(element, endPoint);
|
|
||||||
let index = 0;
|
|
||||||
let distance = 0;
|
|
||||||
while (index < points.length - 1) {
|
|
||||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
|
||||||
distance += segmentDistance;
|
|
||||||
arcLengths.push(distance);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return arcLengths;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
return arcLengths.at(-1) as number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
|
||||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
const pointsCount = arcLengths.length - 1;
|
|
||||||
const curveLength = arcLengths.at(-1) as number;
|
|
||||||
const targetLength = interval * curveLength;
|
|
||||||
let low = 0;
|
|
||||||
let high = pointsCount;
|
|
||||||
let index = 0;
|
|
||||||
// Doing a binary search to find the largest length that is less than the target length
|
|
||||||
while (low < high) {
|
|
||||||
index = Math.floor(low + (high - low) / 2);
|
|
||||||
if (arcLengths[index] < targetLength) {
|
|
||||||
low = index + 1;
|
|
||||||
} else {
|
|
||||||
high = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (arcLengths[index] > targetLength) {
|
|
||||||
index--;
|
|
||||||
}
|
|
||||||
if (arcLengths[index] === targetLength) {
|
|
||||||
return index / pointsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
1 -
|
|
||||||
(index +
|
|
||||||
(targetLength - arcLengths[index]) /
|
|
||||||
(arcLengths[index + 1] - arcLengths[index])) /
|
|
||||||
pointsCount
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the axis-aligned bounding box for a given element
|
|
||||||
*/
|
|
||||||
export const aabbForElement = (
|
|
||||||
element: Readonly<ExcalidrawElement>,
|
|
||||||
offset?: [number, number, number, number],
|
|
||||||
) => {
|
|
||||||
const bbox = {
|
|
||||||
minX: element.x,
|
|
||||||
minY: element.y,
|
|
||||||
maxX: element.x + element.width,
|
|
||||||
maxY: element.y + element.height,
|
|
||||||
midX: element.x + element.width / 2,
|
|
||||||
midY: element.y + element.height / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [topRightX, topRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bounds = [
|
|
||||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
if (offset) {
|
|
||||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
|
||||||
return [
|
|
||||||
bounds[0] - leftOffset,
|
|
||||||
bounds[1] - topOffset,
|
|
||||||
bounds[2] + rightOffset,
|
|
||||||
bounds[3] + downOffset,
|
|
||||||
] as Bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
return bounds;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p: P,
|
|
||||||
bounds: Bounds,
|
|
||||||
): boolean =>
|
|
||||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
|
||||||
|
|
||||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
};
|
|
||||||
@ -2,14 +2,28 @@ import {
|
|||||||
SHIFT_LOCKING_ANGLE,
|
SHIFT_LOCKING_ANGLE,
|
||||||
viewportCoordsToSceneCoords,
|
viewportCoordsToSceneCoords,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
import {
|
||||||
|
normalizeRadians,
|
||||||
|
radiansBetweenAngles,
|
||||||
|
radiansDifference,
|
||||||
|
type Radians,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
|
import { pointsEqual } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
import {
|
||||||
|
isArrowElement,
|
||||||
|
isFreeDrawElement,
|
||||||
|
isLinearElement,
|
||||||
|
} from "./typeChecks";
|
||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||||
|
|
||||||
|
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||||
|
|
||||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||||
// - could also be part of `_clearElements`
|
// - could also be part of `_clearElements`
|
||||||
@ -17,8 +31,18 @@ export const isInvisiblySmallElement = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
return element.points.length < 2;
|
return (
|
||||||
|
element.points.length < 2 ||
|
||||||
|
(element.points.length === 2 &&
|
||||||
|
isArrowElement(element) &&
|
||||||
|
pointsEqual(
|
||||||
|
element.points[0],
|
||||||
|
element.points[element.points.length - 1],
|
||||||
|
INVISIBLY_SMALL_ELEMENT_SIZE,
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return element.width === 0 && element.height === 0;
|
return element.width === 0 && element.height === 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -134,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
|
|||||||
originY: number,
|
originY: number,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
|
customAngle?: number,
|
||||||
) => {
|
) => {
|
||||||
let width = x - originX;
|
let width = x - originX;
|
||||||
let height = y - originY;
|
let height = y - originY;
|
||||||
|
|
||||||
const lockedAngle =
|
const angle = Math.atan2(height, width) as Radians;
|
||||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
|
||||||
SHIFT_LOCKING_ANGLE;
|
SHIFT_LOCKING_ANGLE) as Radians;
|
||||||
|
|
||||||
|
if (customAngle) {
|
||||||
|
// If custom angle is provided, we check if the angle is close to the
|
||||||
|
// custom angle, snap to that if close engough, otherwise snap to the
|
||||||
|
// higher or lower angle depending on the current angle vs custom angle.
|
||||||
|
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
|
||||||
|
SHIFT_LOCKING_ANGLE) as Radians;
|
||||||
|
if (
|
||||||
|
radiansBetweenAngles(
|
||||||
|
angle,
|
||||||
|
lower,
|
||||||
|
(lower + SHIFT_LOCKING_ANGLE) as Radians,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
radiansDifference(angle, customAngle as Radians) <
|
||||||
|
SHIFT_LOCKING_ANGLE / 6
|
||||||
|
) {
|
||||||
|
lockedAngle = customAngle as Radians;
|
||||||
|
} else if (
|
||||||
|
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
|
||||||
|
) {
|
||||||
|
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
|
||||||
|
} else {
|
||||||
|
lockedAngle = lower;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (lockedAngle === 0) {
|
if (lockedAngle === 0) {
|
||||||
height = 0;
|
height = 0;
|
||||||
|
|||||||
@ -19,9 +19,21 @@ import { newElementWith } from "./mutateElement";
|
|||||||
|
|
||||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||||
|
|
||||||
import { hashElementsVersion, hashString } from "./index";
|
import {
|
||||||
|
syncInvalidIndicesImmutable,
|
||||||
|
hashElementsVersion,
|
||||||
|
hashString,
|
||||||
|
isInitializedImageElement,
|
||||||
|
isImageElement,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
import type { ApplyToOptions } from "./delta";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const CaptureUpdateAction = {
|
export const CaptureUpdateAction = {
|
||||||
/**
|
/**
|
||||||
@ -64,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
|||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
// internally used by history
|
// for internal use by history
|
||||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
|
// for public use as part of onIncrement API
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[DurableIncrement | EphemeralIncrement]
|
[DurableIncrement | EphemeralIncrement]
|
||||||
>();
|
>();
|
||||||
@ -105,7 +118,7 @@ export class Store {
|
|||||||
params:
|
params:
|
||||||
| {
|
| {
|
||||||
action: CaptureUpdateActionType;
|
action: CaptureUpdateActionType;
|
||||||
elements: SceneElementsMap | undefined;
|
elements: readonly ExcalidrawElement[] | undefined;
|
||||||
appState: AppState | ObservedAppState | undefined;
|
appState: AppState | ObservedAppState | undefined;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -129,13 +142,21 @@ export class Store {
|
|||||||
} else {
|
} else {
|
||||||
// immediately create an immutable change of the scheduled updates,
|
// immediately create an immutable change of the scheduled updates,
|
||||||
// compared to the current state, so that they won't mutate later on during batching
|
// compared to the current state, so that they won't mutate later on during batching
|
||||||
|
// also, we have to compare against the current state,
|
||||||
|
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
|
||||||
const currentSnapshot = StoreSnapshot.create(
|
const currentSnapshot = StoreSnapshot.create(
|
||||||
this.app.scene.getElementsMapIncludingDeleted(),
|
this.app.scene.getElementsMapIncludingDeleted(),
|
||||||
this.app.state,
|
this.app.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduledSnapshot = currentSnapshot.maybeClone(
|
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||||
action,
|
action,
|
||||||
params.elements,
|
// let's sync invalid indices first, so that we could detect this change
|
||||||
|
// also have the synced elements immutable, so that we don't mutate elements,
|
||||||
|
// that are already in the scene, otherwise we wouldn't see any change
|
||||||
|
params.elements
|
||||||
|
? syncInvalidIndicesImmutable(params.elements)
|
||||||
|
: undefined,
|
||||||
params.appState,
|
params.appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -213,22 +234,12 @@ export class Store {
|
|||||||
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
|
||||||
storeDelta = delta;
|
storeDelta = delta;
|
||||||
} else {
|
} else {
|
||||||
// calculate the deltas based on the previous and next snapshot
|
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
|
||||||
const elementsDelta = snapshot.metadata.didElementsChange
|
|
||||||
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
|
|
||||||
: ElementsDelta.empty();
|
|
||||||
|
|
||||||
const appStateDelta = snapshot.metadata.didAppStateChange
|
|
||||||
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
|
|
||||||
: AppStateDelta.empty();
|
|
||||||
|
|
||||||
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
// Notify listeners with the increment
|
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
@ -505,6 +516,24 @@ export class StoreDelta {
|
|||||||
return new this(opts.id, elements, appState);
|
return new this(opts.id, elements, appState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the delta between the previous and next snapshot.
|
||||||
|
*/
|
||||||
|
public static calculate(
|
||||||
|
prevSnapshot: StoreSnapshot,
|
||||||
|
nextSnapshot: StoreSnapshot,
|
||||||
|
) {
|
||||||
|
const elementsDelta = nextSnapshot.metadata.didElementsChange
|
||||||
|
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
|
||||||
|
: ElementsDelta.empty();
|
||||||
|
|
||||||
|
const appStateDelta = nextSnapshot.metadata.didAppStateChange
|
||||||
|
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
|
||||||
|
: AppStateDelta.empty();
|
||||||
|
|
||||||
|
return this.create(elementsDelta, appStateDelta);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore a store delta instance from a DTO.
|
* Restore a store delta instance from a DTO.
|
||||||
*/
|
*/
|
||||||
@ -523,38 +552,35 @@ export class StoreDelta {
|
|||||||
public static load({
|
public static load({
|
||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
|
appState: { delta: appStateDelta },
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated, {
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
shouldRedistribute: false,
|
const appState = AppStateDelta.create(appStateDelta);
|
||||||
});
|
|
||||||
|
|
||||||
return new this(id, elements, AppStateDelta.empty());
|
return new this(id, elements, appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squash the passed deltas into the aggregated delta instance.
|
||||||
|
*/
|
||||||
|
public static squash(...deltas: StoreDelta[]) {
|
||||||
|
const aggregatedDelta = StoreDelta.empty();
|
||||||
|
|
||||||
|
for (const delta of deltas) {
|
||||||
|
aggregatedDelta.elements.squash(delta.elements);
|
||||||
|
aggregatedDelta.appState.squash(delta.appState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregatedDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||||
*/
|
*/
|
||||||
public static inverse(delta: StoreDelta): StoreDelta {
|
public static inverse(delta: StoreDelta) {
|
||||||
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
return this.create(delta.elements.inverse(), delta.appState.inverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
|
||||||
*/
|
|
||||||
public static applyLatestChanges(
|
|
||||||
delta: StoreDelta,
|
|
||||||
elements: SceneElementsMap,
|
|
||||||
modifierOptions: "deleted" | "inserted",
|
|
||||||
): StoreDelta {
|
|
||||||
return this.create(
|
|
||||||
delta.elements.applyLatestChanges(elements, modifierOptions),
|
|
||||||
delta.appState,
|
|
||||||
{
|
|
||||||
id: delta.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
* Apply the delta to the passed elements and appState, does not modify the snapshot.
|
||||||
*/
|
*/
|
||||||
@ -562,11 +588,12 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
options?: ApplyToOptions,
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
elements,
|
elements,
|
||||||
prevSnapshot.elements,
|
StoreSnapshot.empty().elements,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [nextAppState, appStateContainsVisibleChange] =
|
const [nextAppState, appStateContainsVisibleChange] =
|
||||||
@ -578,6 +605,32 @@ export class StoreDelta {
|
|||||||
return [nextElements, nextAppState, appliedVisibleChanges];
|
return [nextElements, nextAppState, appliedVisibleChanges];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
|
||||||
|
*/
|
||||||
|
public static applyLatestChanges(
|
||||||
|
delta: StoreDelta,
|
||||||
|
prevElements: SceneElementsMap,
|
||||||
|
nextElements: SceneElementsMap,
|
||||||
|
modifierOptions?: "deleted" | "inserted",
|
||||||
|
): StoreDelta {
|
||||||
|
return this.create(
|
||||||
|
delta.elements.applyLatestChanges(
|
||||||
|
prevElements,
|
||||||
|
nextElements,
|
||||||
|
modifierOptions,
|
||||||
|
),
|
||||||
|
delta.appState,
|
||||||
|
{
|
||||||
|
id: delta.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static empty() {
|
||||||
|
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||||
|
}
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
}
|
}
|
||||||
@ -687,11 +740,10 @@ export class StoreSnapshot {
|
|||||||
nextElements.set(id, changedElement);
|
nextElements.set(id, changedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAppState = Object.assign(
|
const nextAppState = getObservedAppState({
|
||||||
{},
|
...this.appState,
|
||||||
this.appState,
|
...change.appState,
|
||||||
change.appState,
|
});
|
||||||
) as ObservedAppState;
|
|
||||||
|
|
||||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||||
// by default we assume that change is different from what we have in the snapshot
|
// by default we assume that change is different from what we have in the snapshot
|
||||||
@ -847,7 +899,7 @@ export class StoreSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if there any changed elements.
|
* Detect if there are any changed elements.
|
||||||
*/
|
*/
|
||||||
private detectChangedElements(
|
private detectChangedElements(
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
@ -882,6 +934,14 @@ export class StoreSnapshot {
|
|||||||
!prevElement || // element was added
|
!prevElement || // element was added
|
||||||
prevElement.version < nextElement.version // element was updated
|
prevElement.version < nextElement.version // element was updated
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
isImageElement(nextElement) &&
|
||||||
|
!isInitializedImageElement(nextElement)
|
||||||
|
) {
|
||||||
|
// ignore any updates on uninitialized image elements
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
changedElements.set(nextElement.id, nextElement);
|
changedElements.set(nextElement.id, nextElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -936,26 +996,31 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
|||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
selectedLinearElementId: null,
|
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
export const getObservedAppState = (
|
||||||
|
appState: AppState | ObservedAppState,
|
||||||
|
): ObservedAppState => {
|
||||||
const observedAppState = {
|
const observedAppState = {
|
||||||
name: appState.name,
|
name: appState.name,
|
||||||
editingGroupId: appState.editingGroupId,
|
editingGroupId: appState.editingGroupId,
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
selectedGroupIds: appState.selectedGroupIds,
|
selectedGroupIds: appState.selectedGroupIds,
|
||||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
|
||||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
|
||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
|
selectedLinearElement: appState.selectedLinearElement
|
||||||
|
? {
|
||||||
|
elementId: appState.selectedLinearElement.elementId,
|
||||||
|
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
|||||||
@ -10,12 +10,12 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
|
|||||||
x =
|
x =
|
||||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||||
}
|
}
|
||||||
|
const angle = (container.angle ?? 0) as Radians;
|
||||||
|
|
||||||
|
if (angle !== 0) {
|
||||||
|
const contentCenter = pointFrom(
|
||||||
|
containerCoords.x + maxContainerWidth / 2,
|
||||||
|
containerCoords.y + maxContainerHeight / 2,
|
||||||
|
);
|
||||||
|
const textCenter = pointFrom(
|
||||||
|
x + boundTextElement.width / 2,
|
||||||
|
y + boundTextElement.height / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: rx - boundTextElement.width / 2,
|
||||||
|
y: ry - boundTextElement.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return { x, y };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -326,10 +346,7 @@ export const getContainerCenter = (
|
|||||||
if (!midSegmentMidpoint) {
|
if (!midSegmentMidpoint) {
|
||||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
container,
|
container,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||||
|
|||||||
@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
) => {
|
) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (elements.length > 1) {
|
if (elements.length > 1) {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointsEqual } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||||
@ -25,6 +27,7 @@ import type {
|
|||||||
ExcalidrawMagicFrameElement,
|
ExcalidrawMagicFrameElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
ExcalidrawLineElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
FixedPointBinding,
|
FixedPointBinding,
|
||||||
ExcalidrawFlowchartNodeElement,
|
ExcalidrawFlowchartNodeElement,
|
||||||
@ -108,6 +111,12 @@ export const isLinearElement = (
|
|||||||
return element != null && isLinearElementType(element.type);
|
return element != null && isLinearElementType(element.type);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isLineElement = (
|
||||||
|
element?: ExcalidrawElement | null,
|
||||||
|
): element is ExcalidrawLineElement => {
|
||||||
|
return element != null && element.type === "line";
|
||||||
|
};
|
||||||
|
|
||||||
export const isArrowElement = (
|
export const isArrowElement = (
|
||||||
element?: ExcalidrawElement | null,
|
element?: ExcalidrawElement | null,
|
||||||
): element is ExcalidrawArrowElement => {
|
): element is ExcalidrawArrowElement => {
|
||||||
@ -120,6 +129,15 @@ export const isElbowArrow = (
|
|||||||
return isArrowElement(element) && element.elbowed;
|
return isArrowElement(element) && element.elbowed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sharp or curved arrow, but not elbow
|
||||||
|
*/
|
||||||
|
export const isSimpleArrow = (
|
||||||
|
element?: ExcalidrawElement,
|
||||||
|
): element is ExcalidrawArrowElement => {
|
||||||
|
return isArrowElement(element) && !element.elbowed;
|
||||||
|
};
|
||||||
|
|
||||||
export const isSharpArrow = (
|
export const isSharpArrow = (
|
||||||
element?: ExcalidrawElement,
|
element?: ExcalidrawElement,
|
||||||
): element is ExcalidrawArrowElement => {
|
): element is ExcalidrawArrowElement => {
|
||||||
@ -372,3 +390,26 @@ export const getLinearElementSubType = (
|
|||||||
}
|
}
|
||||||
return "line";
|
return "line";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if current element points meet all the conditions for polygon=true
|
||||||
|
* (this isn't a element type check, for that use isLineElement).
|
||||||
|
*
|
||||||
|
* If you want to check if points *can* be turned into a polygon, use
|
||||||
|
* canBecomePolygon(points).
|
||||||
|
*/
|
||||||
|
export const isValidPolygon = (
|
||||||
|
points: ExcalidrawLineElement["points"],
|
||||||
|
): boolean => {
|
||||||
|
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canBecomePolygon = (
|
||||||
|
points: ExcalidrawLineElement["points"],
|
||||||
|
): boolean => {
|
||||||
|
return (
|
||||||
|
points.length > 3 ||
|
||||||
|
// 3-point polygons can't have all points in a single line
|
||||||
|
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
|
|||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawIframeLikeElement
|
| ExcalidrawIframeLikeElement
|
||||||
| ExcalidrawFrameLikeElement
|
| ExcalidrawFrameLikeElement
|
||||||
| ExcalidrawEmbeddableElement;
|
| ExcalidrawEmbeddableElement
|
||||||
|
| ExcalidrawSelectionElement;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||||
@ -296,8 +297,10 @@ export type FixedPointBinding = Merge<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type Index = number;
|
||||||
|
|
||||||
export type PointsPositionUpdates = Map<
|
export type PointsPositionUpdates = Map<
|
||||||
number,
|
Index,
|
||||||
{ point: LocalPoint; isDragging?: boolean }
|
{ point: LocalPoint; isDragging?: boolean }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@ -326,10 +329,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
|||||||
endArrowhead: Arrowhead | null;
|
endArrowhead: Arrowhead | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||||
|
Readonly<{
|
||||||
|
type: "line";
|
||||||
|
polygon: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type FixedSegment = {
|
export type FixedSegment = {
|
||||||
start: LocalPoint;
|
start: LocalPoint;
|
||||||
end: LocalPoint;
|
end: LocalPoint;
|
||||||
index: number;
|
index: Index;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||||
|
|||||||
@ -1,259 +1,346 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ADAPTIVE_RADIUS,
|
||||||
|
DEFAULT_PROPORTIONAL_RADIUS,
|
||||||
|
LINE_CONFIRM_THRESHOLD,
|
||||||
|
ROUNDNESS,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
curve,
|
curve,
|
||||||
|
curveCatmullRomCubicApproxPoints,
|
||||||
|
curveOffsetPoints,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromVector,
|
pointFromArray,
|
||||||
rectangle,
|
rectangle,
|
||||||
vectorFromPoint,
|
|
||||||
vectorNormalize,
|
|
||||||
vectorScale,
|
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCornerRadius } from "./shapes";
|
|
||||||
|
|
||||||
import { getDiamondPoints } from "./bounds";
|
import { getDiamondPoints } from "./bounds";
|
||||||
|
|
||||||
|
import { generateLinearCollisionShape } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} 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
|
* Get the building components of a rectanguloid element in the form of
|
||||||
* line segments and curves.
|
* line segments and curves **unrotated**.
|
||||||
*
|
*
|
||||||
* @param element Target rectanguloid element
|
* @param element Target rectanguloid element
|
||||||
* @param offset Optional offset to expand the rectanguloid shape
|
* @param offset Optional offset to expand the rectanguloid shape
|
||||||
* @returns Tuple of line segments (0) and curves (1)
|
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||||
*/
|
*/
|
||||||
export function deconstructRectanguloidElement(
|
export function deconstructRectanguloidElement(
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const roundness = getCornerRadius(
|
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||||
|
|
||||||
|
if (cachedShape) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = getCornerRadius(
|
||||||
Math.min(element.width, element.height),
|
Math.min(element.width, element.height),
|
||||||
element,
|
element,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (roundness <= 0) {
|
if (radius === 0) {
|
||||||
const r = rectangle(
|
radius = 0.01;
|
||||||
pointFrom(element.x - offset, element.y - offset),
|
|
||||||
pointFrom(
|
|
||||||
element.x + element.width + offset,
|
|
||||||
element.y + element.height + offset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
|
||||||
);
|
|
||||||
const right = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
|
||||||
);
|
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
|
||||||
);
|
|
||||||
const left = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
|
||||||
);
|
|
||||||
const sides = [top, right, bottom, left];
|
|
||||||
|
|
||||||
return [sides, []];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
|
|
||||||
const r = rectangle(
|
const r = rectangle(
|
||||||
pointFrom(element.x, element.y),
|
pointFrom(element.x, element.y),
|
||||||
pointFrom(element.x + element.width, element.y + element.height),
|
pointFrom(element.x + element.width, element.y + element.height),
|
||||||
);
|
);
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
const top = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||||
);
|
);
|
||||||
const right = lineSegment<GlobalPoint>(
|
const right = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||||
);
|
);
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
const bottom = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||||
);
|
);
|
||||||
const left = lineSegment<GlobalPoint>(
|
const left = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||||
);
|
);
|
||||||
|
|
||||||
const offsets = [
|
const baseCorners = [
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // TOP LEFT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), //TOP RIGHT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // BOTTOM RIGHT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // BOTTOM LEFT
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[0], left[1]),
|
left[1],
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||||
),
|
),
|
||||||
),
|
top[0],
|
||||||
pointFromVector(offsets[0], top[0]),
|
|
||||||
), // TOP LEFT
|
), // TOP LEFT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[1], top[1]),
|
top[1],
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||||
),
|
),
|
||||||
),
|
right[0],
|
||||||
pointFromVector(offsets[1], right[0]),
|
|
||||||
), // TOP RIGHT
|
), // TOP RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[2], right[1]),
|
right[1],
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||||
),
|
),
|
||||||
),
|
bottom[1],
|
||||||
pointFromVector(offsets[2], bottom[1]),
|
|
||||||
), // BOTTOM RIGHT
|
), // BOTTOM RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[3], bottom[0]),
|
bottom[0],
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||||
),
|
),
|
||||||
),
|
left[0],
|
||||||
pointFromVector(offsets[3], left[0]),
|
|
||||||
), // BOTTOM LEFT
|
), // BOTTOM LEFT
|
||||||
];
|
];
|
||||||
|
|
||||||
const sides = [
|
const corners =
|
||||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
offset > 0
|
||||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
? baseCorners.map(
|
||||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
(corner) =>
|
||||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
curveCatmullRomCubicApproxPoints(
|
||||||
|
curveOffsetPoints(corner, offset),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
[baseCorners[0]],
|
||||||
|
[baseCorners[1]],
|
||||||
|
[baseCorners[2]],
|
||||||
|
[baseCorners[3]],
|
||||||
];
|
];
|
||||||
|
|
||||||
return [sides, corners];
|
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 building components of a diamond element in the form of
|
* Get the **unrotated** building components of a diamond element
|
||||||
* line segments and curves as a tuple, in this order.
|
* in the form of line segments and curves as a tuple, in this order.
|
||||||
*
|
*
|
||||||
* @param element The element to deconstruct
|
* @param element The element to deconstruct
|
||||||
* @param offset An optional offset
|
* @param offset An optional offset
|
||||||
* @returns Tuple of line segments (0) and curves (1)
|
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||||
*/
|
*/
|
||||||
export function deconstructDiamondElement(
|
export function deconstructDiamondElement(
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||||
getDiamondPoints(element);
|
|
||||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
|
||||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
|
||||||
|
|
||||||
if (element.roundness?.type == null) {
|
if (cachedShape) {
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
return cachedShape;
|
||||||
pointFrom(element.x + topX, element.y + topY - offset),
|
|
||||||
pointFrom(element.x + rightX + offset, element.y + rightY),
|
|
||||||
pointFrom(element.x + bottomX, element.y + bottomY + offset),
|
|
||||||
pointFrom(element.x + leftX - offset, element.y + leftY),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create the line segment parts of the diamond
|
|
||||||
// NOTE: Horizontal and vertical seems to be flipped here
|
|
||||||
const topRight = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
|
|
||||||
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
|
|
||||||
);
|
|
||||||
const bottomRight = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
|
|
||||||
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
|
|
||||||
);
|
|
||||||
const bottomLeft = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
|
|
||||||
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
|
|
||||||
);
|
|
||||||
const topLeft = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
|
|
||||||
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
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[] = [
|
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||||
pointFrom(element.x + topX, element.y + topY),
|
pointFrom(element.x + topX, element.y + topY),
|
||||||
@ -262,94 +349,135 @@ export function deconstructDiamondElement(
|
|||||||
pointFrom(element.x + leftX, element.y + leftY),
|
pointFrom(element.x + leftX, element.y + leftY),
|
||||||
];
|
];
|
||||||
|
|
||||||
const offsets = [
|
const baseCorners = [
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[0] - verticalRadius,
|
right[0] - verticalRadius,
|
||||||
right[1] - horizontalRadius,
|
right[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
right,
|
||||||
pointFromVector(offsets[0], right),
|
right,
|
||||||
pointFromVector(offsets[0], right),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[0] - verticalRadius,
|
right[0] - verticalRadius,
|
||||||
right[1] + horizontalRadius,
|
right[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // RIGHT
|
), // RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[0] + verticalRadius,
|
bottom[0] + verticalRadius,
|
||||||
bottom[1] - horizontalRadius,
|
bottom[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
bottom,
|
||||||
pointFromVector(offsets[1], bottom),
|
bottom,
|
||||||
pointFromVector(offsets[1], bottom),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[0] - verticalRadius,
|
bottom[0] - verticalRadius,
|
||||||
bottom[1] - horizontalRadius,
|
bottom[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // BOTTOM
|
), // BOTTOM
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[0] + verticalRadius,
|
left[0] + verticalRadius,
|
||||||
left[1] + horizontalRadius,
|
left[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
left,
|
||||||
pointFromVector(offsets[2], left),
|
left,
|
||||||
pointFromVector(offsets[2], left),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[0] + verticalRadius,
|
left[0] + verticalRadius,
|
||||||
left[1] - horizontalRadius,
|
left[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // LEFT
|
), // LEFT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[0] - verticalRadius,
|
top[0] - verticalRadius,
|
||||||
top[1] + horizontalRadius,
|
top[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
top,
|
||||||
pointFromVector(offsets[3], top),
|
top,
|
||||||
pointFromVector(offsets[3], top),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[0] + verticalRadius,
|
top[0] + verticalRadius,
|
||||||
top[1] + horizontalRadius,
|
top[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // TOP
|
), // TOP
|
||||||
];
|
];
|
||||||
|
|
||||||
const sides = [
|
const corners =
|
||||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
offset > 0
|
||||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
? baseCorners.map(
|
||||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
(corner) =>
|
||||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
curveCatmullRomCubicApproxPoints(
|
||||||
|
curveOffsetPoints(corner, offset),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
[baseCorners[0]],
|
||||||
|
[baseCorners[1]],
|
||||||
|
[baseCorners[2]],
|
||||||
|
[baseCorners[3]],
|
||||||
];
|
];
|
||||||
|
|
||||||
return [sides, corners];
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
|
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -202,6 +204,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -215,6 +218,7 @@ describe("aligning", () => {
|
|||||||
// Add the created group to the current selection
|
// Add the created group to the current selection
|
||||||
mouse.restorePosition(0, 0);
|
mouse.restorePosition(0, 0);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -316,6 +320,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already selected because it was the last element created
|
// The second rectangle is already selected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -330,7 +335,7 @@ describe("aligning", () => {
|
|||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
|
|
||||||
mouse.restorePosition(200, 200);
|
mouse.restorePosition(210, 200);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
@ -341,6 +346,7 @@ describe("aligning", () => {
|
|||||||
// The second group is already selected because it was the last group created
|
// The second group is already selected because it was the last group created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -454,6 +460,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -466,7 +473,7 @@ describe("aligning", () => {
|
|||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
|
|
||||||
// Add group to current selection
|
// Add group to current selection
|
||||||
mouse.restorePosition(0, 0);
|
mouse.restorePosition(10, 0);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
@ -482,6 +489,7 @@ describe("aligning", () => {
|
|||||||
// Select the nested group, the rectangle is already selected
|
// Select the nested group, the rectangle is already selected
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -581,4 +589,424 @@ describe("aligning", () => {
|
|||||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createGroupAndSelectInEditGroupMode = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click();
|
||||||
|
mouse.moveTo(100, 100);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the top", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the left", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the right", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||||
|
});
|
||||||
|
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
|
||||||
|
createGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createNestedGroupAndSelectInEditGroupMode = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
mouse.moveTo(200, 200);
|
||||||
|
// create third element
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// third element is already selected, select the initial group and group together
|
||||||
|
mouse.reset();
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
|
||||||
|
// double click to enter edit mode
|
||||||
|
mouse.doubleClick();
|
||||||
|
|
||||||
|
// select nested group and other element within the group
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(200, 200);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the top", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the left", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the right", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
|
||||||
|
createNestedGroupAndSelectInEditGroupMode();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndSelectSingleGroup = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("aligns elements within a single-selected group correctly to the top", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the bottom", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the left", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the right", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the vertical center", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
|
||||||
|
createAndSelectSingleGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAndSelectSingleGroupWithNestedGroup = () => {
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down();
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(0, 0);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Select the first element.
|
||||||
|
// The second rectangle is already reselected because it was the last element created
|
||||||
|
mouse.reset();
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
|
||||||
|
mouse.reset();
|
||||||
|
UI.clickTool("rectangle");
|
||||||
|
mouse.down(200, 200);
|
||||||
|
mouse.up(100, 100);
|
||||||
|
|
||||||
|
// Add group to current selection
|
||||||
|
mouse.restorePosition(10, 0);
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the nested group
|
||||||
|
API.executeAction(actionGroup);
|
||||||
|
};
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignTop);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignBottom);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignLeft);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignRight);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignVerticallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||||
|
});
|
||||||
|
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
|
||||||
|
createAndSelectSingleGroupWithNestedGroup();
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||||
|
|
||||||
|
API.executeAction(actionAlignHorizontallyCentered);
|
||||||
|
|
||||||
|
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||||
|
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||||
|
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
|||||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
import { getTransformHandles } from "../src/transformHandles";
|
import { getTransformHandles } from "../src/transformHandles";
|
||||||
|
import {
|
||||||
|
getTextEditor,
|
||||||
|
TEXT_EDITOR_SELECTOR,
|
||||||
|
} from "../../excalidraw/tests/queries/dom";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@ -151,10 +155,10 @@ describe("element binding", () => {
|
|||||||
// NOTE this mouse down/up + await needs to be done in order to repro
|
// NOTE this mouse down/up + await needs to be done in order to repro
|
||||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
expect(h.state.editingLinearElement).not.toBe(null);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
mouse.down(0, 0);
|
mouse.down(0, 0);
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
expect(h.state.editingLinearElement).toBe(null);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
mouse.up();
|
mouse.up();
|
||||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||||
@ -172,12 +176,12 @@ describe("element binding", () => {
|
|||||||
const arrow = UI.createElement("arrow", {
|
const arrow = UI.createElement("arrow", {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
size: 50,
|
size: 49,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
|
|
||||||
mouse.downAt(50, 50);
|
mouse.downAt(49, 49);
|
||||||
mouse.moveTo(51, 0);
|
mouse.moveTo(51, 0);
|
||||||
mouse.up(0, 0);
|
mouse.up(0, 0);
|
||||||
|
|
||||||
@ -244,18 +248,12 @@ describe("element binding", () => {
|
|||||||
|
|
||||||
mouse.clickAt(text.x + 50, text.y + 50);
|
mouse.clickAt(text.x + 50, text.y + 50);
|
||||||
|
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
|
||||||
|
|
||||||
fireEvent.change(editor, { target: { value: "" } });
|
fireEvent.change(editor, { target: { value: "" } });
|
||||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||||
|
|
||||||
expect(
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
|
||||||
).toBe(null);
|
|
||||||
expect(arrow.endBinding).toBe(null);
|
expect(arrow.endBinding).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -285,18 +283,14 @@ describe("element binding", () => {
|
|||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
|
|
||||||
mouse.clickAt(text.x + 50, text.y + 50);
|
mouse.clickAt(text.x + 50, text.y + 50);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
|
|
||||||
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
|
||||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||||
|
|
||||||
expect(
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
document.querySelector(".excalidraw-textEditorContainer > textarea"),
|
|
||||||
).toBe(null);
|
|
||||||
expect(arrow.endBinding?.elementId).toBe(text.id);
|
expect(arrow.endBinding?.elementId).toBe(text.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
38
packages/element/tests/collision.test.tsx
Normal file
38
packages/element/tests/collision.test.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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,13 +1,345 @@
|
|||||||
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { LinearElementEditor } from "@excalidraw/element";
|
import type { LinearElementEditor } from "@excalidraw/element";
|
||||||
|
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { AppStateDelta } from "../src/delta";
|
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("AppStateDelta", () => {
|
||||||
describe("ensure stable delta properties order", () => {
|
describe("ensure stable delta properties order", () => {
|
||||||
it("should maintain stable order for root properties", () => {
|
it("should maintain stable order for root properties", () => {
|
||||||
const name = "untitled scene";
|
const name = "untitled scene";
|
||||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
const selectedLinearElement = {
|
||||||
|
elementId: "id1" as LinearElementEditor["elementId"],
|
||||||
|
isEditing: false,
|
||||||
|
};
|
||||||
|
|
||||||
const commonAppState = {
|
const commonAppState = {
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
@ -16,6 +348,7 @@ describe("AppStateDelta", () => {
|
|||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
editingLinearElementId: null,
|
editingLinearElementId: null,
|
||||||
|
selectedLinearElementIsEditing: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
};
|
};
|
||||||
@ -23,23 +356,23 @@ describe("AppStateDelta", () => {
|
|||||||
const prevAppState1: ObservedAppState = {
|
const prevAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name: "",
|
name: "",
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
const nextAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name,
|
name,
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
const prevAppState2: ObservedAppState = {
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
name: "",
|
name: "",
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
const nextAppState2: ObservedAppState = {
|
||||||
selectedLinearElementId,
|
selectedLinearElement,
|
||||||
name,
|
name,
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
@ -57,8 +390,7 @@ describe("AppStateDelta", () => {
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
editingLinearElementId: null,
|
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@ -104,8 +436,7 @@ describe("AppStateDelta", () => {
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElementId: null,
|
selectedLinearElement: null,
|
||||||
editingLinearElementId: null,
|
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@ -146,4 +477,97 @@ describe("AppStateDelta", () => {
|
|||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
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 },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
128
packages/element/tests/distribute.test.tsx
Normal file
128
packages/element/tests/distribute.test.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -505,8 +505,6 @@ describe("group-related duplication", () => {
|
|||||||
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log(h.elements);
|
|
||||||
|
|
||||||
assertElements(h.elements, [
|
assertElements(h.elements, [
|
||||||
{ id: frame.id },
|
{ id: frame.id },
|
||||||
{ id: rectangle1.id, frameId: frame.id },
|
{ id: rectangle1.id, frameId: frame.id },
|
||||||
|
|||||||
153
packages/element/tests/embeddable.test.ts
Normal file
153
packages/element/tests/embeddable.test.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { pointCenter, pointFrom } from "@excalidraw/math";
|
import { pointCenter, pointFrom } from "@excalidraw/math";
|
||||||
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
import { act, queryByTestId, queryByText } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -33,6 +32,11 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
|
|||||||
import { LinearElementEditor } from "../src";
|
import { LinearElementEditor } from "../src";
|
||||||
import { newArrowElement } from "../src";
|
import { newArrowElement } from "../src";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTextEditor,
|
||||||
|
TEXT_EDITOR_SELECTOR,
|
||||||
|
} from "../../excalidraw/tests/queries/dom";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@ -132,7 +136,8 @@ describe("Test Linear Elements", () => {
|
|||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
});
|
});
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
||||||
@ -249,17 +254,99 @@ describe("Test Linear Elements", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
||||||
|
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should enter line editor when using double clicked with ctrl key", () => {
|
it("should enter line editor via enter (line)", () => {
|
||||||
createTwoPointerLinearElement("line");
|
createTwoPointerLinearElement("line");
|
||||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ctrl+enter alias (to align with arrows)
|
||||||
|
it("should enter line editor via ctrl+enter (line)", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
});
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
});
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
mouse.doubleClick();
|
mouse.doubleClick();
|
||||||
});
|
});
|
||||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||||
|
mouse.doubleClick();
|
||||||
|
});
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should enter line editor on dblclick (line)", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.doubleClick();
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||||
|
|
||||||
|
mouse.doubleClick();
|
||||||
|
expect(h.state.selectedLinearElement).toBe(null);
|
||||||
|
await getTextEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldn't create text element on double click in line editor (arrow)", async () => {
|
||||||
|
createTwoPointerLinearElement("arrow");
|
||||||
|
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
enterLineEditingMode(arrow);
|
||||||
|
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||||
|
|
||||||
|
mouse.doubleClick();
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||||
|
expect(h.elements.length).toEqual(1);
|
||||||
|
|
||||||
|
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Inside editor", () => {
|
describe("Inside editor", () => {
|
||||||
@ -290,7 +377,7 @@ describe("Test Linear Elements", () => {
|
|||||||
// drag line from midpoint
|
// drag line from midpoint
|
||||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -346,12 +433,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"55.96978",
|
"54.27552",
|
||||||
"47.44233",
|
"46.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"76.08587",
|
"76.95494",
|
||||||
"43.29417",
|
"44.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -392,7 +479,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(startPoint, endPoint);
|
drag(startPoint, endPoint);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@ -411,12 +498,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"105.96978",
|
"104.27552",
|
||||||
"67.44233",
|
"66.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"126.08587",
|
"126.95494",
|
||||||
"63.29417",
|
"64.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -460,7 +547,7 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`16`,
|
`14`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@ -511,7 +598,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -552,7 +639,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -600,7 +687,7 @@ describe("Test Linear Elements", () => {
|
|||||||
deletePoint(points[2]);
|
deletePoint(points[2]);
|
||||||
expect(line.points.length).toEqual(3);
|
expect(line.points.length).toEqual(3);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`18`,
|
`17`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
|
|
||||||
@ -658,7 +745,7 @@ describe("Test Linear Elements", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`16`,
|
`14`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||||
expect(line.points.length).toEqual(5);
|
expect(line.points.length).toEqual(5);
|
||||||
@ -727,12 +814,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"31.88408",
|
"29.28349",
|
||||||
"23.13276",
|
"20.91105",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"77.74793",
|
"78.86048",
|
||||||
"44.57841",
|
"46.12277",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -756,7 +843,7 @@ describe("Test Linear Elements", () => {
|
|||||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||||
|
|
||||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||||
`12`,
|
`11`,
|
||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||||
|
|
||||||
@ -816,12 +903,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"55.96978",
|
"54.27552",
|
||||||
"47.44233",
|
"46.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"76.08587",
|
"76.95494",
|
||||||
"43.29417",
|
"44.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -983,19 +1070,17 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
expect(position).toMatchInlineSnapshot(`
|
expect(position).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"x": "85.82202",
|
"x": "86.17305",
|
||||||
"y": "75.63461",
|
"y": "76.11251",
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should match styles for text editor", () => {
|
it("should match styles for text editor", async () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
expect(editor).toMatchSnapshot();
|
expect(editor).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1012,9 +1097,7 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(arrow.id);
|
expect(text.containerId).toBe(arrow.id);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
fireEvent.change(editor, {
|
fireEvent.change(editor, {
|
||||||
target: { value: DEFAULT_TEXT },
|
target: { value: DEFAULT_TEXT },
|
||||||
@ -1042,9 +1125,7 @@ describe("Test Linear Elements", () => {
|
|||||||
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(textElement.type).toBe("text");
|
expect(textElement.type).toBe("text");
|
||||||
expect(textElement.containerId).toBe(arrow.id);
|
expect(textElement.containerId).toBe(arrow.id);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
fireEvent.change(editor, {
|
fireEvent.change(editor, {
|
||||||
target: { value: DEFAULT_TEXT },
|
target: { value: DEFAULT_TEXT },
|
||||||
@ -1063,13 +1144,7 @@ describe("Test Linear Elements", () => {
|
|||||||
|
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
mouse.doubleClickAt(line.x, line.y);
|
mouse.doubleClickAt(line.x, line.y);
|
||||||
|
expect(h.elements.length).toBe(1);
|
||||||
expect(h.elements.length).toBe(2);
|
|
||||||
|
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
|
||||||
expect(text.type).toBe("text");
|
|
||||||
expect(text.containerId).toBeNull();
|
|
||||||
expect(line.boundElements).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO fix #7029 and rewrite this test
|
// TODO fix #7029 and rewrite this test
|
||||||
@ -1234,9 +1309,7 @@ describe("Test Linear Elements", () => {
|
|||||||
|
|
||||||
mouse.select(arrow);
|
mouse.select(arrow);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
|
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@ -1262,7 +1335,7 @@ describe("Test Linear Elements", () => {
|
|||||||
mouse.downAt(rect.x, rect.y);
|
mouse.downAt(rect.x, rect.y);
|
||||||
mouse.moveTo(200, 0);
|
mouse.moveTo(200, 0);
|
||||||
mouse.upAt(200, 0);
|
mouse.upAt(200, 0);
|
||||||
expect(arrow.width).toBeCloseTo(204, 0);
|
expect(arrow.width).toBeCloseTo(200, 0);
|
||||||
expect(rect.x).toBe(200);
|
expect(rect.x).toBe(200);
|
||||||
expect(rect.y).toBe(0);
|
expect(rect.y).toBe(0);
|
||||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||||
@ -1411,5 +1484,55 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(line.points[line.points.length - 1][0]).toBe(20);
|
expect(line.points[line.points.length - 1][0]).toBe(20);
|
||||||
expect(line.points[line.points.length - 1][1]).toBe(-20);
|
expect(line.points[line.points.length - 1][1]).toBe(-20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
|
||||||
|
createTwoPointerLinearElement("line");
|
||||||
|
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||||
|
enterLineEditingMode(line);
|
||||||
|
|
||||||
|
const elementsMap = arrayToMap(h.elements);
|
||||||
|
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
|
line,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate original angle between first and last point
|
||||||
|
const originalAngle = Math.atan2(
|
||||||
|
points[1][1] - points[0][1],
|
||||||
|
points[1][0] - points[0][0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag the second point (endpoint) with SHIFT key pressed
|
||||||
|
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
|
||||||
|
const endPoint = pointFrom<GlobalPoint>(
|
||||||
|
startPoint[0] + 4,
|
||||||
|
startPoint[1] + 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Perform drag with SHIFT key modifier
|
||||||
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.downAt(startPoint[0], startPoint[1]);
|
||||||
|
mouse.moveTo(endPoint[0], endPoint[1]);
|
||||||
|
mouse.upAt(endPoint[0], endPoint[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get updated points after drag
|
||||||
|
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||||
|
line,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate new angle
|
||||||
|
const newAngle = Math.atan2(
|
||||||
|
updatedPoints[1][1] - updatedPoints[0][1],
|
||||||
|
updatedPoints[1][0] - updatedPoints[0][0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The angle should be preserved (within a small tolerance for floating point precision)
|
||||||
|
const angleDifference = Math.abs(newAngle - originalAngle);
|
||||||
|
const tolerance = 0.01; // Small tolerance for floating point precision
|
||||||
|
|
||||||
|
expect(angleDifference).toBeLessThan(tolerance);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -510,12 +510,12 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
|
|
||||||
UI.resize(rectangle, "se", [-200, -150]);
|
UI.resize(rectangle, "se", [-200, -150]);
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -538,11 +538,11 @@ describe("arrow element", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
)[0] as ExcalidrawElbowArrowElement;
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||||
|
|
||||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
|
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -819,7 +819,7 @@ describe("image element", () => {
|
|||||||
|
|
||||||
UI.resize(image, "ne", [40, 0]);
|
UI.resize(image, "ne", [40, 0]);
|
||||||
|
|
||||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
|
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||||
|
|
||||||
const imageWidth = image.width;
|
const imageWidth = image.width;
|
||||||
const scale = 20 / image.height;
|
const scale = 20 / image.height;
|
||||||
@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
|
|||||||
|
|
||||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||||
expect(leftBoundArrow.width).toBeCloseTo(143, 0);
|
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||||
expect(leftBoundArrow.angle).toEqual(0);
|
expect(leftBoundArrow.angle).toEqual(0);
|
||||||
expect(leftBoundArrow.startBinding).toBeNull();
|
expect(leftBoundArrow.startBinding).toBeNull();
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import * as constants from "@excalidraw/common";
|
|
||||||
|
|
||||||
import { getPerfectElementSize } from "../src/sizeHelpers";
|
import { getPerfectElementSize } from "../src/sizeHelpers";
|
||||||
|
|
||||||
const EPSILON_DIGITS = 3;
|
const EPSILON_DIGITS = 3;
|
||||||
@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
|
|||||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
|
||||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
|
||||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
|
||||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
|
||||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
|
||||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { getLineHeight } from "@excalidraw/common";
|
import { getLineHeight } from "@excalidraw/common";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
|
computeBoundTextPosition,
|
||||||
} from "../src/textElement";
|
} from "../src/textElement";
|
||||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||||
|
|
||||||
@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
|
|||||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Test computeBoundTextPosition", () => {
|
||||||
|
const createMockElementsMap = () => new Map();
|
||||||
|
|
||||||
|
// Helper function to create rectangle test case with 90-degree rotation
|
||||||
|
const createRotatedRectangleTestCase = (
|
||||||
|
textAlign: string,
|
||||||
|
verticalAlign: string,
|
||||||
|
) => {
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
angle: (Math.PI / 2) as any, // 90 degrees
|
||||||
|
});
|
||||||
|
|
||||||
|
const boundTextElement = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
text: "hello darkness my old friend",
|
||||||
|
textAlign: textAlign as any,
|
||||||
|
verticalAlign: verticalAlign as any,
|
||||||
|
containerId: container.id,
|
||||||
|
}) as ExcalidrawTextElementWithContainer;
|
||||||
|
|
||||||
|
const elementsMap = createMockElementsMap();
|
||||||
|
|
||||||
|
return { container, boundTextElement, elementsMap };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("90-degree rotation with all alignment combinations", () => {
|
||||||
|
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
|
||||||
|
|
||||||
|
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(75, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(
|
||||||
|
TEXT_ALIGN.CENTER,
|
||||||
|
VERTICAL_ALIGN.MIDDLE,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(
|
||||||
|
TEXT_ALIGN.CENTER,
|
||||||
|
VERTICAL_ALIGN.BOTTOM,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(130, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(185, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(160, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
|
||||||
|
const { container, boundTextElement, elementsMap } =
|
||||||
|
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
|
||||||
|
|
||||||
|
const result = computeBoundTextPosition(
|
||||||
|
container,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.x).toBeCloseTo(135, 1);
|
||||||
|
expect(result.y).toBeCloseTo(185, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Alignment } from "@excalidraw/element";
|
import type { Alignment } from "@excalidraw/element";
|
||||||
@ -38,7 +40,11 @@ export const alignActionsPredicate = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return (
|
return (
|
||||||
selectedElements.length > 1 &&
|
getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
appState as Readonly<AppState>,
|
||||||
|
).length > 1 &&
|
||||||
// TODO enable aligning frames when implemented properly
|
// TODO enable aligning frames when implemented properly
|
||||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||||
);
|
);
|
||||||
@ -52,7 +58,12 @@ const alignSelectedElements = (
|
|||||||
) => {
|
) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
const updatedElements = alignElements(
|
||||||
|
selectedElements,
|
||||||
|
alignment,
|
||||||
|
app.scene,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -121,7 +122,7 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: "selection" }
|
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@ -494,13 +495,13 @@ export const actionToggleEraserTool = register({
|
|||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
label: "toolBar.eraser",
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: "selection",
|
type: app.defaultSelectionTool,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@ -530,6 +531,9 @@ export const actionToggleLassoTool = register({
|
|||||||
label: "toolBar.lasso",
|
label: "toolBar.lasso",
|
||||||
icon: LassoIcon,
|
icon: LassoIcon,
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
|
predicate: (elements, appState, props, app) => {
|
||||||
|
return app.defaultSelectionTool !== "lasso";
|
||||||
|
},
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
|
|||||||
icon: TrashIcon,
|
icon: TrashIcon,
|
||||||
trackEvent: { category: "element", action: "delete" },
|
trackEvent: { category: "element", action: "delete" },
|
||||||
perform: (elements, appState, formData, app) => {
|
perform: (elements, appState, formData, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
const {
|
const {
|
||||||
elementId,
|
elementId,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
startBindingElement,
|
startBindingElement,
|
||||||
endBindingElement,
|
endBindingElement,
|
||||||
} = appState.editingLinearElement;
|
} = appState.selectedLinearElement;
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const linearElement = LinearElementEditor.getElement(
|
||||||
if (!element) {
|
elementId,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
if (!linearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// case: no point selected → do nothing, as deleting the whole element
|
// case: no point selected → do nothing, as deleting the whole element
|
||||||
@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// case: deleting last remaining point
|
// case: deleting all points
|
||||||
if (element.points.length < 2) {
|
if (selectedPointsIndices.length >= linearElement.points.length) {
|
||||||
const nextElements = elements.map((el) => {
|
const nextElements = elements.map((el) => {
|
||||||
if (el.id === element.id) {
|
if (el.id === linearElement.id) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
return el;
|
return el;
|
||||||
@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
editingLinearElement: null,
|
selectedLinearElement: null,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -252,15 +255,15 @@ export const actionDeleteSelected = register({
|
|||||||
? null
|
? null
|
||||||
: startBindingElement,
|
: startBindingElement,
|
||||||
endBindingElement: selectedPointsIndices?.includes(
|
endBindingElement: selectedPointsIndices?.includes(
|
||||||
element.points.length - 1,
|
linearElement.points.length - 1,
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: endBindingElement,
|
: endBindingElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
LinearElementEditor.deletePoints(
|
LinearElementEditor.deletePoints(
|
||||||
element,
|
linearElement,
|
||||||
app.scene,
|
app,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -268,8 +271,8 @@ export const actionDeleteSelected = register({
|
|||||||
elements,
|
elements,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement: {
|
selectedLinearElement: {
|
||||||
...appState.editingLinearElement,
|
...appState.selectedLinearElement,
|
||||||
...binding,
|
...binding,
|
||||||
selectedPointsIndices:
|
selectedPointsIndices:
|
||||||
selectedPointsIndices?.[0] > 0
|
selectedPointsIndices?.[0] > 0
|
||||||
@ -295,7 +298,9 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
activeTool: updateActiveTool(appState, {
|
||||||
|
type: app.defaultSelectionTool,
|
||||||
|
}),
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Distribution } from "@excalidraw/element";
|
import type { Distribution } from "@excalidraw/element";
|
||||||
@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
|
|||||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return (
|
return (
|
||||||
selectedElements.length > 1 &&
|
getSelectedElementsByGroup(
|
||||||
|
selectedElements,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
appState as Readonly<AppState>,
|
||||||
|
).length > 2 &&
|
||||||
// TODO enable distributing frames when implemented properly
|
// TODO enable distributing frames when implemented properly
|
||||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||||
);
|
);
|
||||||
@ -49,6 +55,7 @@ const distributeSelectedElements = (
|
|||||||
selectedElements,
|
selectedElements,
|
||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
distribution,
|
distribution,
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// duplicate selected point(s) if editing a line
|
// duplicate selected point(s) if editing a line
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||||
try {
|
try {
|
||||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||||
|
|||||||
@ -3,18 +3,40 @@ import { pointFrom } from "@excalidraw/math";
|
|||||||
import {
|
import {
|
||||||
maybeBindLinearElement,
|
maybeBindLinearElement,
|
||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
|
isBindingEnabled,
|
||||||
|
} from "@excalidraw/element/binding";
|
||||||
|
import {
|
||||||
|
isValidPolygon,
|
||||||
|
LinearElementEditor,
|
||||||
|
newElementWith,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { LinearElementEditor } from "@excalidraw/element";
|
|
||||||
|
|
||||||
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
import {
|
||||||
|
isBindingElement,
|
||||||
|
isFreeDrawElement,
|
||||||
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
import {
|
||||||
|
KEYS,
|
||||||
|
arrayToMap,
|
||||||
|
tupleToCoors,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "@excalidraw/common";
|
||||||
import { isPathALoop } from "@excalidraw/element";
|
import { isPathALoop } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
|
NonDeleted,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { resetCursor } from "../cursor";
|
import { resetCursor } from "../cursor";
|
||||||
import { done } from "../components/icons";
|
import { done } from "../components/icons";
|
||||||
@ -28,14 +50,64 @@ export const actionFinalize = register({
|
|||||||
name: "finalize",
|
name: "finalize",
|
||||||
label: "",
|
label: "",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
const { interactiveCanvas, focusContainer, scene } = app;
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
|
const { event, sceneCoords } =
|
||||||
|
(data as {
|
||||||
|
event?: PointerEvent;
|
||||||
|
sceneCoords?: { x: number; y: number };
|
||||||
|
}) ?? {};
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
if (appState.editingLinearElement) {
|
if (event && appState.selectedLinearElement) {
|
||||||
|
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||||
|
event,
|
||||||
|
appState.selectedLinearElement,
|
||||||
|
appState,
|
||||||
|
app.scene,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||||
|
const element = app.scene.getElement(linearElementEditor.elementId);
|
||||||
|
if (isBindingElement(element)) {
|
||||||
|
bindOrUnbindLinearElement(
|
||||||
|
element,
|
||||||
|
startBindingElement,
|
||||||
|
endBindingElement,
|
||||||
|
app.scene,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linearElementEditor !== appState.selectedLinearElement) {
|
||||||
|
let newElements = elements;
|
||||||
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
|
newElements = newElements.map((el) => {
|
||||||
|
if (el.id === element.id) {
|
||||||
|
return newElementWith(el, {
|
||||||
|
isDeleted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
elements: newElements,
|
||||||
|
appState: {
|
||||||
|
selectedLinearElement: {
|
||||||
|
...linearElementEditor,
|
||||||
|
selectedPointsIndices: null,
|
||||||
|
},
|
||||||
|
suggestedBindings: [],
|
||||||
|
},
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
const { elementId, startBindingElement, endBindingElement } =
|
const { elementId, startBindingElement, endBindingElement } =
|
||||||
appState.editingLinearElement;
|
appState.selectedLinearElement;
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
@ -47,15 +119,30 @@ export const actionFinalize = register({
|
|||||||
scene,
|
scene,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
polygon: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
? elements.filter((el) => el.id !== element.id)
|
? elements.map((el) => {
|
||||||
|
if (el.id === element.id) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
cursorButton: "up",
|
cursorButton: "up",
|
||||||
editingLinearElement: null,
|
selectedLinearElement: new LinearElementEditor(
|
||||||
|
element,
|
||||||
|
arrayToMap(elementsMap),
|
||||||
|
false, // exit editing mode
|
||||||
|
),
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -64,91 +151,108 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
|
|
||||||
const pendingImageElement =
|
|
||||||
appState.pendingImageElementId &&
|
|
||||||
scene.getElement(appState.pendingImageElementId);
|
|
||||||
|
|
||||||
if (pendingImageElement) {
|
|
||||||
scene.mutateElement(
|
|
||||||
pendingImageElement,
|
|
||||||
{ isDeleted: true },
|
|
||||||
{ informMutation: false, isDragging: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.document.activeElement instanceof HTMLElement) {
|
if (window.document.activeElement instanceof HTMLElement) {
|
||||||
focusContainer();
|
focusContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiPointElement = appState.multiElement
|
let element: NonDeleted<ExcalidrawElement> | null = null;
|
||||||
? appState.multiElement
|
if (appState.multiElement) {
|
||||||
: appState.newElement?.type === "freedraw"
|
element = appState.multiElement;
|
||||||
? appState.newElement
|
} else if (
|
||||||
: null;
|
appState.newElement?.type === "freedraw" ||
|
||||||
|
isBindingElement(appState.newElement)
|
||||||
if (multiPointElement) {
|
|
||||||
// pen and mouse have hover
|
|
||||||
if (
|
|
||||||
multiPointElement.type !== "freedraw" &&
|
|
||||||
appState.lastPointerDownWith !== "touch"
|
|
||||||
) {
|
) {
|
||||||
const { points, lastCommittedPoint } = multiPointElement;
|
element = appState.newElement;
|
||||||
|
} else if (Object.keys(appState.selectedElementIds).length === 1) {
|
||||||
|
const candidate = elementsMap.get(
|
||||||
|
Object.keys(appState.selectedElementIds)[0],
|
||||||
|
) as NonDeleted<ExcalidrawLinearElement> | undefined;
|
||||||
|
if (candidate) {
|
||||||
|
element = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
// pen and mouse have hover
|
||||||
|
if (appState.multiElement && element.type !== "freedraw") {
|
||||||
|
const { points, lastCommittedPoint } = element;
|
||||||
if (
|
if (
|
||||||
!lastCommittedPoint ||
|
!lastCommittedPoint ||
|
||||||
points[points.length - 1] !== lastCommittedPoint
|
points[points.length - 1] !== lastCommittedPoint
|
||||||
) {
|
) {
|
||||||
scene.mutateElement(multiPointElement, {
|
scene.mutateElement(element, {
|
||||||
points: multiPointElement.points.slice(0, -1),
|
points: element.points.slice(0, -1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInvisiblySmallElement(multiPointElement)) {
|
if (element && isInvisiblySmallElement(element)) {
|
||||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||||
newElements = newElements.filter(
|
newElements = newElements.map((el) => {
|
||||||
(el) => el.id !== multiPointElement.id,
|
if (el.id === element?.id) {
|
||||||
);
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
// If the multi point line closes the loop,
|
// If the multi point line closes the loop,
|
||||||
// set the last point to first point.
|
// set the last point to first point.
|
||||||
// This ensures that loop remains closed at different scales.
|
// This ensures that loop remains closed at different scales.
|
||||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
const isLoop = isPathALoop(element.points, appState.zoom.value);
|
||||||
if (
|
|
||||||
multiPointElement.type === "line" ||
|
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
|
||||||
multiPointElement.type === "freedraw"
|
const linePoints = element.points;
|
||||||
) {
|
|
||||||
if (isLoop) {
|
|
||||||
const linePoints = multiPointElement.points;
|
|
||||||
const firstPoint = linePoints[0];
|
const firstPoint = linePoints[0];
|
||||||
scene.mutateElement(multiPointElement, {
|
const points: LocalPoint[] = linePoints.map((p, index) =>
|
||||||
points: linePoints.map((p, index) =>
|
|
||||||
index === linePoints.length - 1
|
index === linePoints.length - 1
|
||||||
? pointFrom(firstPoint[0], firstPoint[1])
|
? pointFrom(firstPoint[0], firstPoint[1])
|
||||||
: p,
|
: p,
|
||||||
),
|
);
|
||||||
|
if (isLineElement(element)) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
points,
|
||||||
|
polygon: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
points,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||||
|
scene.mutateElement(element, {
|
||||||
|
polygon: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isBindingElement(multiPointElement) &&
|
isBindingElement(element) &&
|
||||||
!isLoop &&
|
!isLoop &&
|
||||||
multiPointElement.points.length > 1
|
element.points.length > 1 &&
|
||||||
|
isBindingEnabled(appState)
|
||||||
) {
|
) {
|
||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
const coords =
|
||||||
multiPointElement,
|
sceneCoords ??
|
||||||
|
tupleToCoors(
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
|
element,
|
||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
|
||||||
|
maybeBindLinearElement(element, appState, coords, scene);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!appState.activeTool.locked &&
|
(!appState.activeTool.locked &&
|
||||||
appState.activeTool.type !== "freedraw") ||
|
appState.activeTool.type !== "freedraw") ||
|
||||||
!multiPointElement
|
!element
|
||||||
) {
|
) {
|
||||||
resetCursor(interactiveCanvas);
|
resetCursor(interactiveCanvas);
|
||||||
}
|
}
|
||||||
@ -157,13 +261,13 @@ export const actionFinalize = register({
|
|||||||
if (appState.activeTool.type === "eraser") {
|
if (appState.activeTool.type === "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: "selection",
|
type: app.defaultSelectionTool,
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: "selection",
|
type: app.defaultSelectionTool,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,7 +279,7 @@ export const actionFinalize = register({
|
|||||||
activeTool:
|
activeTool:
|
||||||
(appState.activeTool.locked ||
|
(appState.activeTool.locked ||
|
||||||
appState.activeTool.type === "freedraw") &&
|
appState.activeTool.type === "freedraw") &&
|
||||||
multiPointElement
|
element
|
||||||
? appState.activeTool
|
? appState.activeTool
|
||||||
: activeTool,
|
: activeTool,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
@ -186,23 +290,19 @@ export const actionFinalize = register({
|
|||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
selectedElementIds:
|
selectedElementIds:
|
||||||
multiPointElement &&
|
element &&
|
||||||
!appState.activeTool.locked &&
|
!appState.activeTool.locked &&
|
||||||
appState.activeTool.type !== "freedraw"
|
appState.activeTool.type !== "freedraw"
|
||||||
? {
|
? {
|
||||||
...appState.selectedElementIds,
|
...appState.selectedElementIds,
|
||||||
[multiPointElement.id]: true,
|
[element.id]: true,
|
||||||
}
|
}
|
||||||
: appState.selectedElementIds,
|
: appState.selectedElementIds,
|
||||||
// To select the linear element when user has finished mutipoint editing
|
// To select the linear element when user has finished mutipoint editing
|
||||||
selectedLinearElement:
|
selectedLinearElement:
|
||||||
multiPointElement && isLinearElement(multiPointElement)
|
element && isLinearElement(element)
|
||||||
? new LinearElementEditor(
|
? new LinearElementEditor(element, arrayToMap(newElements))
|
||||||
multiPointElement,
|
|
||||||
arrayToMap(newElements),
|
|
||||||
)
|
|
||||||
: appState.selectedLinearElement,
|
: appState.selectedLinearElement,
|
||||||
pendingImageElementId: null,
|
|
||||||
},
|
},
|
||||||
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@ -210,7 +310,7 @@ export const actionFinalize = register({
|
|||||||
},
|
},
|
||||||
keyTest: (event, appState) =>
|
keyTest: (event, appState) =>
|
||||||
(event.key === KEYS.ESCAPE &&
|
(event.key === KEYS.ESCAPE &&
|
||||||
(appState.editingLinearElement !== null ||
|
(appState.selectedLinearElement?.isEditing ||
|
||||||
(!appState.newElement && appState.multiElement === null))) ||
|
(!appState.newElement && appState.multiElement === null))) ||
|
||||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||||
appState.multiElement !== null),
|
appState.multiElement !== null),
|
||||||
|
|||||||
@ -1,19 +1,29 @@
|
|||||||
import { LinearElementEditor } from "@excalidraw/element";
|
import {
|
||||||
|
isElbowArrow,
|
||||||
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
import { arrayToMap, invariant } from "@excalidraw/common";
|
||||||
|
|
||||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
import {
|
||||||
|
toggleLinePolygonState,
|
||||||
|
CaptureUpdateAction,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import type {
|
||||||
|
ExcalidrawLinearElement,
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
ExcalidrawLineElement,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { lineEditorIcon } from "../components/icons";
|
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
import { ButtonIcon } from "../components/ButtonIcon";
|
||||||
|
|
||||||
|
import { newElementWith } from "../../element/src/mutateElement";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
export const actionToggleLinearEditor = register({
|
export const actionToggleLinearEditor = register({
|
||||||
@ -35,7 +45,7 @@ export const actionToggleLinearEditor = register({
|
|||||||
predicate: (elements, appState, _, app) => {
|
predicate: (elements, appState, _, app) => {
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
if (
|
if (
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
selectedElements.length === 1 &&
|
selectedElements.length === 1 &&
|
||||||
isLinearElement(selectedElements[0]) &&
|
isLinearElement(selectedElements[0]) &&
|
||||||
!isElbowArrow(selectedElements[0])
|
!isElbowArrow(selectedElements[0])
|
||||||
@ -50,14 +60,25 @@ export const actionToggleLinearEditor = register({
|
|||||||
includeBoundTextElement: true,
|
includeBoundTextElement: true,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
const editingLinearElement =
|
invariant(selectedElement, "No selected element found");
|
||||||
appState.editingLinearElement?.elementId === selectedElement.id
|
invariant(
|
||||||
? null
|
appState.selectedLinearElement,
|
||||||
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
"No selected linear element found",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
selectedElement.id === appState.selectedLinearElement.elementId,
|
||||||
|
"Selected element ID and linear editor elementId does not match",
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedLinearElement = {
|
||||||
|
...appState.selectedLinearElement,
|
||||||
|
isEditing: !appState.selectedLinearElement.isEditing,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
editingLinearElement,
|
selectedLinearElement,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
@ -67,6 +88,10 @@ export const actionToggleLinearEditor = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
if (!selectedElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const label = t(
|
const label = t(
|
||||||
selectedElement.type === "arrow"
|
selectedElement.type === "arrow"
|
||||||
? "labels.lineEditor.editArrow"
|
? "labels.lineEditor.editArrow"
|
||||||
@ -83,3 +108,110 @@ export const actionToggleLinearEditor = register({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionTogglePolygon = register({
|
||||||
|
name: "togglePolygon",
|
||||||
|
category: DEFAULT_CATEGORIES.elements,
|
||||||
|
icon: polygonIcon,
|
||||||
|
keywords: ["loop"],
|
||||||
|
label: (elements, appState, app) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allPolygons = !selectedElements.some(
|
||||||
|
(element) => !isLineElement(element) || !element.polygon,
|
||||||
|
);
|
||||||
|
|
||||||
|
return allPolygons
|
||||||
|
? "labels.polygon.breakPolygon"
|
||||||
|
: "labels.polygon.convertToPolygon";
|
||||||
|
},
|
||||||
|
trackEvent: {
|
||||||
|
category: "element",
|
||||||
|
},
|
||||||
|
predicate: (elements, appState, _, app) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
selectedElements.length > 0 &&
|
||||||
|
selectedElements.every(
|
||||||
|
(element) => isLineElement(element) && element.points.length >= 4,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform(elements, appState, _, app) {
|
||||||
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
|
if (selectedElements.some((element) => !isLineElement(element))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetElements = selectedElements as ExcalidrawLineElement[];
|
||||||
|
|
||||||
|
// if one element not a polygon, convert all to polygon
|
||||||
|
const nextPolygonState = targetElements.some((element) => !element.polygon);
|
||||||
|
|
||||||
|
const targetElementsMap = arrayToMap(targetElements);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: elements.map((element) => {
|
||||||
|
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newElementWith(element, {
|
||||||
|
backgroundColor: nextPolygonState
|
||||||
|
? element.backgroundColor
|
||||||
|
: "transparent",
|
||||||
|
...toggleLinePolygonState(element, nextPolygonState),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
appState,
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
PanelComponent: ({ appState, updateData, app }) => {
|
||||||
|
const selectedElements = app.scene.getSelectedElements({
|
||||||
|
selectedElementIds: appState.selectedElementIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedElements.length === 0 ||
|
||||||
|
selectedElements.some(
|
||||||
|
(element) =>
|
||||||
|
!isLineElement(element) ||
|
||||||
|
// only show polygon button if every selected element is already
|
||||||
|
// a polygon, effectively showing this button only to allow for
|
||||||
|
// disabling the polygon state
|
||||||
|
!element.polygon ||
|
||||||
|
element.points.length < 3,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPolygon = selectedElements.every(
|
||||||
|
(element) => isLineElement(element) && element.polygon,
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = t(
|
||||||
|
allPolygon
|
||||||
|
? "labels.polygon.breakPolygon"
|
||||||
|
: "labels.polygon.convertToPolygon",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonIcon
|
||||||
|
icon={polygonIcon}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
active={allPolygon}
|
||||||
|
onClick={() => updateData(null)}
|
||||||
|
style={{ marginLeft: "auto" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -18,18 +18,16 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
tupleToCoors,
|
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
|
isTransparent,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { getNonDeletedElements } from "@excalidraw/element";
|
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bindLinearElement,
|
bindLinearElement,
|
||||||
bindPointToSnapToElementOutline,
|
|
||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
@ -47,15 +45,18 @@ import {
|
|||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { hasStrokeColor } from "@excalidraw/element";
|
import { hasStrokeColor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
import {
|
||||||
|
updateElbowArrowPoints,
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
CaptureUpdateAction,
|
||||||
|
toggleLinePolygonState,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -136,6 +137,11 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
|
import {
|
||||||
|
withCaretPositionPreservation,
|
||||||
|
restoreCaretPosition,
|
||||||
|
} from "../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
@ -320,9 +326,11 @@ export const actionChangeStrokeColor = register({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
|
{appState.stylesPanelMode === "full" && (
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||||
@ -340,6 +348,7 @@ export const actionChangeStrokeColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -349,27 +358,59 @@ export const actionChangeBackgroundColor = register({
|
|||||||
name: "changeBackgroundColor",
|
name: "changeBackgroundColor",
|
||||||
label: "labels.changeBackground",
|
label: "labels.changeBackground",
|
||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, value) => {
|
perform: (elements, appState, value, app) => {
|
||||||
|
if (!value.currentItemBackgroundColor) {
|
||||||
return {
|
return {
|
||||||
...(value.currentItemBackgroundColor && {
|
|
||||||
elements: changeProperty(elements, appState, (el) =>
|
|
||||||
newElementWith(el, {
|
|
||||||
backgroundColor: value.currentItemBackgroundColor,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
...value,
|
...value,
|
||||||
},
|
},
|
||||||
captureUpdate: !!value.currentItemBackgroundColor
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
? CaptureUpdateAction.IMMEDIATELY
|
};
|
||||||
: CaptureUpdateAction.EVENTUALLY,
|
}
|
||||||
|
|
||||||
|
let nextElements;
|
||||||
|
|
||||||
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
const shouldEnablePolygon =
|
||||||
|
!isTransparent(value.currentItemBackgroundColor) &&
|
||||||
|
selectedElements.every(
|
||||||
|
(el) => isLineElement(el) && canBecomePolygon(el.points),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldEnablePolygon) {
|
||||||
|
const selectedElementsMap = arrayToMap(selectedElements);
|
||||||
|
nextElements = elements.map((el) => {
|
||||||
|
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
|
||||||
|
return newElementWith(el, {
|
||||||
|
backgroundColor: value.currentItemBackgroundColor,
|
||||||
|
...toggleLinePolygonState(el, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
nextElements = changeProperty(elements, appState, (el) =>
|
||||||
|
newElementWith(el, {
|
||||||
|
backgroundColor: value.currentItemBackgroundColor,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: nextElements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
...value,
|
||||||
|
},
|
||||||
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
|
{appState.stylesPanelMode === "full" && (
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||||
@ -387,6 +428,7 @@ export const actionChangeBackgroundColor = register({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -487,9 +529,11 @@ export const actionChangeStrokeWidth = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
{appState.stylesPanelMode === "full" && (
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
|
)}
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
@ -544,9 +588,11 @@ export const actionChangeSloppiness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
{appState.stylesPanelMode === "full" && (
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
|
)}
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
@ -597,9 +643,11 @@ export const actionChangeStrokeStyle = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
{appState.stylesPanelMode === "full" && (
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
|
)}
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="strokeStyle"
|
group="strokeStyle"
|
||||||
@ -666,7 +714,7 @@ export const actionChangeFontSize = register({
|
|||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
return changeFontSize(elements, appState, app, () => value, value);
|
return changeFontSize(elements, appState, app, () => value, value);
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontSize")}</legend>
|
<legend>{t("labels.fontSize")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@ -725,7 +773,14 @@ export const actionChangeFontSize = register({
|
|||||||
? null
|
? null
|
||||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -985,7 +1040,7 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
PanelComponent: ({ elements, appState, app, updateData, data }) => {
|
||||||
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
||||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||||
@ -1063,20 +1118,28 @@ export const actionChangeFontFamily = register({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
{appState.stylesPanelMode === "full" && (
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
|
)}
|
||||||
<FontPicker
|
<FontPicker
|
||||||
isOpened={appState.openPopup === "fontFamily"}
|
isOpened={appState.openPopup === "fontFamily"}
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
onSelect={(fontFamily) => {
|
onSelect={(fontFamily) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => {
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
openPopup: null,
|
openPopup: null,
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
currentItemFontFamily: fontFamily,
|
currentItemFontFamily: fontFamily,
|
||||||
});
|
});
|
||||||
|
|
||||||
// defensive clear so immediate close won't abuse the cached elements
|
// defensive clear so immediate close won't abuse the cached elements
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
},
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onHover={(fontFamily) => {
|
onHover={(fontFamily) => {
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
@ -1133,25 +1196,28 @@ export const actionChangeFontFamily = register({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
|
...batchedData,
|
||||||
openPopup: "fontFamily",
|
openPopup: "fontFamily",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// close, use the cache and clear it afterwards
|
const fontFamilyData = {
|
||||||
const data = {
|
|
||||||
openPopup: null,
|
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cachedElements: new Map(cachedElementsRef.current),
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
resetAll: true,
|
resetAll: true,
|
||||||
} as ChangeFontFamilyData;
|
} as ChangeFontFamilyData;
|
||||||
|
|
||||||
if (isUnmounted.current) {
|
setBatchedData({
|
||||||
// in case the component was unmounted by the parent, trigger the update directly
|
...fontFamilyData,
|
||||||
updateData({ ...batchedData, ...data });
|
});
|
||||||
} else {
|
|
||||||
setBatchedData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (
|
||||||
|
appState.stylesPanelMode === "compact" &&
|
||||||
|
appState.editingTextElement
|
||||||
|
) {
|
||||||
|
restoreCaretPosition(null); // Just refocus without saved position
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -1194,8 +1260,9 @@ export const actionChangeTextAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
@ -1244,7 +1311,14 @@ export const actionChangeTextAlign = register({
|
|||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemTextAlign,
|
hasSelection ? null : appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1286,7 +1360,7 @@ export const actionChangeVerticalAlign = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@ -1336,7 +1410,14 @@ export const actionChangeVerticalAlign = register({
|
|||||||
) !== null,
|
) !== null,
|
||||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1373,7 +1454,7 @@ export const actionChangeRoundness = register({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||||
const targetElements = getTargetElements(
|
const targetElements = getTargetElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
@ -1417,6 +1498,7 @@ export const actionChangeRoundness = register({
|
|||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => updateData(value)}
|
||||||
/>
|
/>
|
||||||
|
{renderAction("togglePolygon")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
@ -1584,6 +1666,25 @@ export const actionChangeArrowhead = register({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeArrowProperties = register({
|
||||||
|
name: "changeArrowProperties",
|
||||||
|
label: "Change arrow properties",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value, app) => {
|
||||||
|
// This action doesn't perform any changes directly
|
||||||
|
// It's just a container for the arrow type and arrowhead actions
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||||
|
return (
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{renderAction("changeArrowType")}
|
||||||
|
{renderAction("changeArrowhead")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeArrowType = register({
|
export const actionChangeArrowType = register({
|
||||||
name: "changeArrowType",
|
name: "changeArrowType",
|
||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
@ -1626,64 +1727,17 @@ export const actionChangeArrowType = register({
|
|||||||
-1,
|
-1,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const startHoveredElement =
|
const startElement =
|
||||||
!newElement.startBinding &&
|
newElement.startBinding &&
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
(elementsMap.get(
|
||||||
newElement.startBinding.elementId,
|
newElement.startBinding.elementId,
|
||||||
) as ExcalidrawBindableElement);
|
) as ExcalidrawBindableElement);
|
||||||
const endElement = endHoveredElement
|
const endElement =
|
||||||
? endHoveredElement
|
newElement.endBinding &&
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
(elementsMap.get(
|
||||||
newElement.endBinding.elementId,
|
newElement.endBinding.elementId,
|
||||||
) as ExcalidrawBindableElement);
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
)
|
|
||||||
: startGlobalPoint;
|
|
||||||
const finalEndPoint = endHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
newElement,
|
|
||||||
endHoveredElement,
|
|
||||||
"end",
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
app.scene,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
|
|
||||||
|
|
||||||
const startBinding =
|
const startBinding =
|
||||||
startElement && newElement.startBinding
|
startElement && newElement.startBinding
|
||||||
? {
|
? {
|
||||||
@ -1693,6 +1747,7 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
startElement,
|
startElement,
|
||||||
"start",
|
"start",
|
||||||
|
elementsMap,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@ -1705,6 +1760,7 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
endElement,
|
endElement,
|
||||||
"end",
|
"end",
|
||||||
|
elementsMap,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@ -1714,7 +1770,7 @@ export const actionChangeArrowType = register({
|
|||||||
startBinding,
|
startBinding,
|
||||||
endBinding,
|
endBinding,
|
||||||
...updateElbowArrowPoints(newElement, elementsMap, {
|
...updateElbowArrowPoints(newElement, elementsMap, {
|
||||||
points: [finalStartPoint, finalEndPoint].map(
|
points: [startGlobalPoint, endGlobalPoint].map(
|
||||||
(p): LocalPoint =>
|
(p): LocalPoint =>
|
||||||
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
pointFrom(p[0] - newElement.x, p[1] - newElement.y),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const actionSelectAll = register({
|
|||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
viewMode: false,
|
viewMode: false,
|
||||||
perform: (elements, appState, value, app) => {
|
perform: (elements, appState, value, app) => {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,10 @@ export const actionToggleSearchMenu = register({
|
|||||||
predicate: (appState) => appState.gridModeEnabled,
|
predicate: (appState) => appState.gridModeEnabled,
|
||||||
},
|
},
|
||||||
perform(elements, appState, _, app) {
|
perform(elements, appState, _, app) {
|
||||||
|
if (appState.openDialog) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
|
||||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
appState.openSidebar.tab === CANVAS_SEARCH_TAB
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export {
|
|||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
|
actionChangeArrowProperties,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -179,6 +179,7 @@ export class ActionManager {
|
|||||||
appProps={this.app.props}
|
appProps={this.app.props}
|
||||||
app={this.app}
|
app={this.app}
|
||||||
data={data}
|
data={data}
|
||||||
|
renderAction={this.renderAction}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export type ActionName =
|
|||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
|
| "changeArrowProperties"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
@ -142,7 +143,8 @@ export type ActionName =
|
|||||||
| "cropEditor"
|
| "cropEditor"
|
||||||
| "wrapSelectionInFrame"
|
| "wrapSelectionInFrame"
|
||||||
| "toggleLassoTool"
|
| "toggleLassoTool"
|
||||||
| "toggleShapeSwitch";
|
| "toggleShapeSwitch"
|
||||||
|
| "togglePolygon";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -151,6 +153,10 @@ export type PanelComponentProps = {
|
|||||||
appProps: ExcalidrawProps;
|
appProps: ExcalidrawProps;
|
||||||
data?: Record<string, any>;
|
data?: Record<string, any>;
|
||||||
app: AppClassProperties;
|
app: AppClassProperties;
|
||||||
|
renderAction: (
|
||||||
|
name: ActionName,
|
||||||
|
data?: PanelComponentProps["data"],
|
||||||
|
) => React.JSX.Element | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
STATS_PANELS,
|
STATS_PANELS,
|
||||||
THEME,
|
THEME,
|
||||||
DEFAULT_GRID_STEP,
|
DEFAULT_GRID_STEP,
|
||||||
|
isTestEnv,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState, NormalizedZoomValue } from "./types";
|
import type { AppState, NormalizedZoomValue } from "./types";
|
||||||
@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
|
||||||
currentItemStartArrowhead: null,
|
currentItemStartArrowhead: null,
|
||||||
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||||
currentItemRoundness: "round",
|
currentItemRoundness: isTestEnv() ? "sharp" : "round",
|
||||||
currentItemArrowType: ARROW_TYPE.round,
|
currentItemArrowType: ARROW_TYPE.round,
|
||||||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||||
@ -47,7 +48,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
newElement: null,
|
newElement: null,
|
||||||
editingTextElement: null,
|
editingTextElement: null,
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
editingLinearElement: null,
|
|
||||||
activeTool: {
|
activeTool: {
|
||||||
type: "selection",
|
type: "selection",
|
||||||
customType: null,
|
customType: null,
|
||||||
@ -108,7 +108,6 @@ export const getDefaultAppState = (): Omit<
|
|||||||
value: 1 as NormalizedZoomValue,
|
value: 1 as NormalizedZoomValue,
|
||||||
},
|
},
|
||||||
viewModeEnabled: false,
|
viewModeEnabled: false,
|
||||||
pendingImageElementId: null,
|
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
snapLines: [],
|
snapLines: [],
|
||||||
@ -124,6 +123,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
searchMatches: null,
|
searchMatches: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
|
stylesPanelMode: "full",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -175,7 +175,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
newElement: { browser: false, export: false, server: false },
|
newElement: { browser: false, export: false, server: false },
|
||||||
editingTextElement: { browser: false, export: false, server: false },
|
editingTextElement: { browser: false, export: false, server: false },
|
||||||
editingGroupId: { browser: true, export: false, server: false },
|
editingGroupId: { browser: true, export: false, server: false },
|
||||||
editingLinearElement: { browser: false, export: false, server: false },
|
|
||||||
activeTool: { browser: true, export: false, server: false },
|
activeTool: { browser: true, export: false, server: false },
|
||||||
penMode: { browser: true, export: false, server: false },
|
penMode: { browser: true, export: false, server: false },
|
||||||
penDetected: { browser: true, export: false, server: false },
|
penDetected: { browser: true, export: false, server: false },
|
||||||
@ -237,7 +236,6 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
zenModeEnabled: { browser: true, export: false, server: false },
|
zenModeEnabled: { browser: true, export: false, server: false },
|
||||||
zoom: { browser: true, export: false, server: false },
|
zoom: { browser: true, export: false, server: false },
|
||||||
viewModeEnabled: { browser: false, export: false, server: false },
|
viewModeEnabled: { browser: false, export: false, server: false },
|
||||||
pendingImageElementId: { browser: false, export: false, server: false },
|
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
selectedLinearElement: { browser: true, export: false, server: false },
|
selectedLinearElement: { browser: true, export: false, server: false },
|
||||||
snapLines: { browser: false, export: false, server: false },
|
snapLines: { browser: false, export: false, server: false },
|
||||||
@ -250,6 +248,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
searchMatches: { browser: false, export: false, server: false },
|
searchMatches: { browser: false, export: false, server: false },
|
||||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||||
activeLockedId: { browser: false, export: false, server: false },
|
activeLockedId: { browser: false, export: false, server: false },
|
||||||
|
stylesPanelMode: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createPasteEvent,
|
createPasteEvent,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
|
parseDataTransferEvent,
|
||||||
serializeAsClipboardJSON,
|
serializeAsClipboardJSON,
|
||||||
} from "./clipboard";
|
} from "./clipboard";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "123";
|
text = "123";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = JSON.stringify({ val: 42 });
|
text = JSON.stringify({ val: 42 });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/plain": json,
|
"text/plain": json,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
});
|
});
|
||||||
@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": json,
|
"text/html": json,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": `<div> ${json}</div>`,
|
"text/html": `<div> ${json}</div>`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
"text/html": `<img src="https://example.com/image.png" />`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
|
|||||||
]);
|
]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -141,6 +160,7 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/plain": `a b
|
"text/plain": `a b
|
||||||
@ -149,6 +169,7 @@ describe("parseClipboard()", () => {
|
|||||||
7 10`,
|
7 10`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -157,6 +178,7 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": `a b
|
"text/html": `a b
|
||||||
@ -165,6 +187,7 @@ describe("parseClipboard()", () => {
|
|||||||
7 10`,
|
7 10`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -173,6 +196,7 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
|
await parseDataTransferEvent(
|
||||||
createPasteEvent({
|
createPasteEvent({
|
||||||
types: {
|
types: {
|
||||||
"text/html": `<html>
|
"text/html": `<html>
|
||||||
@ -186,6 +210,7 @@ describe("parseClipboard()", () => {
|
|||||||
7 10`,
|
7 10`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
isMemberOf,
|
isMemberOf,
|
||||||
isPromiseLike,
|
isPromiseLike,
|
||||||
|
EVENT,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "@excalidraw/element";
|
import { mutateElement } from "@excalidraw/element";
|
||||||
@ -16,15 +17,26 @@ import {
|
|||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ExcalidrawError } from "./errors";
|
import { ExcalidrawError } from "./errors";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
import {
|
||||||
|
createFile,
|
||||||
|
getFileHandle,
|
||||||
|
isSupportedImageFileType,
|
||||||
|
normalizeFile,
|
||||||
|
} from "./data/blob";
|
||||||
|
|
||||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
|
|
||||||
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
@ -92,7 +104,7 @@ export const createPasteEvent = ({
|
|||||||
console.warn("createPasteEvent: no types or files provided");
|
console.warn("createPasteEvent: no types or files provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new ClipboardEvent("paste", {
|
const event = new ClipboardEvent(EVENT.PASTE, {
|
||||||
clipboardData: new DataTransfer(),
|
clipboardData: new DataTransfer(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -101,10 +113,11 @@ export const createPasteEvent = ({
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
files = files || [];
|
files = files || [];
|
||||||
files.push(value);
|
files.push(value);
|
||||||
|
event.clipboardData?.items.add(value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.items.add(value, type);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||||
}
|
}
|
||||||
@ -229,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLDataItem = (
|
||||||
event: ClipboardEvent,
|
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
const html = dataItem.value;
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
@ -332,18 +341,21 @@ export const readSystemClipboard = async () => {
|
|||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
||||||
|
|
||||||
|
const mixedContent =
|
||||||
|
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
||||||
|
|
||||||
if (mixedContent) {
|
if (mixedContent) {
|
||||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
dataList.getData(MIME_TYPES.text) ??
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@ -354,23 +366,155 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
return {
|
||||||
|
type: "text",
|
||||||
return { type: "text", value: (text || "").trim() };
|
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: "" };
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AllowedParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: string; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItemType<
|
||||||
|
T extends AllowedParsedDataTransferItem["type"],
|
||||||
|
> = AllowedParsedDataTransferItem & { type: T };
|
||||||
|
|
||||||
|
export type ParsedDataTransferFile = Extract<
|
||||||
|
AllowedParsedDataTransferItem,
|
||||||
|
{ kind: "file" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
findByType: typeof findDataTransferItemType;
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
getData: typeof getDataTransferItemData;
|
||||||
|
getFiles: typeof getDataTransferFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDataTransferItemType = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
||||||
|
return (
|
||||||
|
this.find(
|
||||||
|
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const getDataTransferItemData = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
type: T,
|
||||||
|
):
|
||||||
|
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
||||||
|
| null {
|
||||||
|
const item = this.find(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
||||||
|
item.type === type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataTransferFiles = function (
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
): ParsedDataTransferFile[] {
|
||||||
|
return this.filter(
|
||||||
|
(item): item is ParsedDataTransferFile => item.kind === "file",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseDataTransferEvent = async (
|
||||||
|
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||||
|
): Promise<ParsedDataTranferList> => {
|
||||||
|
let items: DataTransferItemList | undefined = undefined;
|
||||||
|
|
||||||
|
if (isClipboardEvent(event)) {
|
||||||
|
items = event.clipboardData?.items;
|
||||||
|
} else {
|
||||||
|
const dragEvent = event;
|
||||||
|
items = dragEvent.dataTransfer?.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItems = (
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(items || []).map(
|
||||||
|
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||||
|
if (item.kind === "file") {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const fileHandle = await getFileHandle(item);
|
||||||
|
return {
|
||||||
|
type: file.type,
|
||||||
|
kind: "file",
|
||||||
|
file: await normalizeFile(file),
|
||||||
|
fileHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (item.kind === "string") {
|
||||||
|
const { type } = item;
|
||||||
|
let value: string;
|
||||||
|
if ("clipboardData" in event && event.clipboardData) {
|
||||||
|
value = event.clipboardData?.getData(type);
|
||||||
|
} else {
|
||||||
|
value = await new Promise<string>((resolve) => {
|
||||||
|
item.getAsString((str) => resolve(str));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type, kind: "string", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((data): data is ParsedDataTransferItem => data != null);
|
||||||
|
|
||||||
|
return Object.assign(dataItems, {
|
||||||
|
findByType: findDataTransferItemType,
|
||||||
|
getData: getDataTransferItemData,
|
||||||
|
getFiles: getDataTransferFiles,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
event,
|
dataList,
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -519,3 +663,14 @@ const copyTextViaExecCommand = (text: string | null) => {
|
|||||||
|
|
||||||
return success;
|
return success;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isClipboardEvent = (
|
||||||
|
event: React.SyntheticEvent | Event,
|
||||||
|
): event is ClipboardEvent => {
|
||||||
|
/** not using instanceof ClipboardEvent due to tests (jsdom) */
|
||||||
|
return (
|
||||||
|
event.type === EVENT.PASTE ||
|
||||||
|
event.type === EVENT.COPY ||
|
||||||
|
event.type === EVENT.CUT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -91,3 +91,120 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.compact-action-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
|
||||||
|
--default-button-size: 2rem;
|
||||||
|
|
||||||
|
.compact-action-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-hover-bg, var(--island-bg-color));
|
||||||
|
border-color: var(
|
||||||
|
--button-hover-border,
|
||||||
|
var(--button-border, var(--default-border-color))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--button-active-bg, var(--island-bg-color));
|
||||||
|
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions-island {
|
||||||
|
width: fit-content;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-actions-theme-scope {
|
||||||
|
--button-border: transparent;
|
||||||
|
--button-bg: var(--color-surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.theme--dark .shape-actions-theme-scope {
|
||||||
|
--button-hover-bg: #363541;
|
||||||
|
--button-bg: var(--color-surface-high);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
@ -19,6 +20,7 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
|
isArrowElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
||||||
@ -46,15 +48,20 @@ import {
|
|||||||
hasStrokeWidth,
|
hasStrokeWidth,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { SHAPES } from "./shapes";
|
import { getFormValue } from "../actions/actionProperties";
|
||||||
|
|
||||||
|
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
|
import { getToolbarTools } from "./shapes";
|
||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
|
|
||||||
import { useDevice } from "./App";
|
import { useDevice, useExcalidrawContainer } from "./App";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
|
import { PropertiesPopover } from "./PropertiesPopover";
|
||||||
import {
|
import {
|
||||||
EmbedIcon,
|
EmbedIcon,
|
||||||
extraToolsIcon,
|
extraToolsIcon,
|
||||||
@ -63,11 +70,29 @@ import {
|
|||||||
laserPointerToolIcon,
|
laserPointerToolIcon,
|
||||||
MagicIcon,
|
MagicIcon,
|
||||||
LassoIcon,
|
LassoIcon,
|
||||||
|
sharpArrowIcon,
|
||||||
|
roundArrowIcon,
|
||||||
|
elbowArrowIcon,
|
||||||
|
TextSizeIcon,
|
||||||
|
adjustmentsIcon,
|
||||||
|
DotsHorizontalIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
|
||||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
import type {
|
||||||
|
AppClassProperties,
|
||||||
|
AppProps,
|
||||||
|
UIAppState,
|
||||||
|
Zoom,
|
||||||
|
AppState,
|
||||||
|
} from "../types";
|
||||||
import type { ActionManager } from "../actions/manager";
|
import type { ActionManager } from "../actions/manager";
|
||||||
|
|
||||||
|
// Common CSS class combinations
|
||||||
|
const PROPERTIES_CLASSES = clsx([
|
||||||
|
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
|
||||||
|
"properties-content",
|
||||||
|
]);
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
targetElements: ExcalidrawElement[],
|
targetElements: ExcalidrawElement[],
|
||||||
@ -140,7 +165,7 @@ export const SelectedShapeActions = ({
|
|||||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||||
|
|
||||||
const showLineEditorAction =
|
const showLineEditorAction =
|
||||||
!appState.editingLinearElement &&
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
targetElements.length === 1 &&
|
targetElements.length === 1 &&
|
||||||
isLinearElement(targetElements[0]) &&
|
isLinearElement(targetElements[0]) &&
|
||||||
!isElbowArrow(targetElements[0]);
|
!isElbowArrow(targetElements[0]);
|
||||||
@ -280,6 +305,437 @@ export const SelectedShapeActions = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CompactShapeActions = ({
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
renderAction,
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
|
}: {
|
||||||
|
appState: UIAppState;
|
||||||
|
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||||
|
renderAction: ActionManager["renderAction"];
|
||||||
|
app: AppClassProperties;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
}) => {
|
||||||
|
const targetElements = getTargetElements(elementsMap, appState);
|
||||||
|
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
|
const isEditingTextOrNewElement = Boolean(
|
||||||
|
appState.editingTextElement || appState.newElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showFillIcons =
|
||||||
|
(hasBackground(appState.activeTool.type) &&
|
||||||
|
!isTransparent(appState.currentItemBackgroundColor)) ||
|
||||||
|
targetElements.some(
|
||||||
|
(element) =>
|
||||||
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLinkIcon = targetElements.length === 1;
|
||||||
|
|
||||||
|
const showLineEditorAction =
|
||||||
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
|
targetElements.length === 1 &&
|
||||||
|
isLinearElement(targetElements[0]) &&
|
||||||
|
!isElbowArrow(targetElements[0]);
|
||||||
|
|
||||||
|
const showCropEditorAction =
|
||||||
|
!appState.croppingElementId &&
|
||||||
|
targetElements.length === 1 &&
|
||||||
|
isImageElement(targetElements[0]);
|
||||||
|
|
||||||
|
const showAlignActions = alignActionsPredicate(appState, app);
|
||||||
|
|
||||||
|
let isSingleElementBoundContainer = false;
|
||||||
|
if (
|
||||||
|
targetElements.length === 2 &&
|
||||||
|
(hasBoundTextElement(targetElements[0]) ||
|
||||||
|
hasBoundTextElement(targetElements[1]))
|
||||||
|
) {
|
||||||
|
isSingleElementBoundContainer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compact-shape-actions">
|
||||||
|
{/* Stroke Color */}
|
||||||
|
{canChangeStrokeColor(appState, targetElements) && (
|
||||||
|
<div className={clsx("compact-action-item")}>
|
||||||
|
{renderAction("changeStrokeColor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background Color */}
|
||||||
|
{canChangeBackgroundColor(appState, targetElements) && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("changeBackgroundColor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined Properties (Fill, Stroke, Opacity) */}
|
||||||
|
{(showFillIcons ||
|
||||||
|
hasStrokeWidth(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => hasStrokeWidth(element.type)) ||
|
||||||
|
hasStrokeStyle(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => hasStrokeStyle(element.type)) ||
|
||||||
|
canChangeRoundness(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => canChangeRoundness(element.type))) && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactStrokeStyles"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setAppState({ openPopup: "compactStrokeStyles" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.stroke")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "compactStrokeStyles"
|
||||||
|
? null
|
||||||
|
: "compactStrokeStyles",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{adjustmentsIcon}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactStrokeStyles" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
className={PROPERTIES_CLASSES}
|
||||||
|
container={container}
|
||||||
|
style={{ maxWidth: "13rem" }}
|
||||||
|
onClose={() => {}}
|
||||||
|
>
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
hasStrokeWidth(element.type),
|
||||||
|
)) &&
|
||||||
|
renderAction("changeStrokeWidth")}
|
||||||
|
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
hasStrokeStyle(element.type),
|
||||||
|
)) && (
|
||||||
|
<>
|
||||||
|
{renderAction("changeStrokeStyle")}
|
||||||
|
{renderAction("changeSloppiness")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(canChangeRoundness(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
canChangeRoundness(element.type),
|
||||||
|
)) &&
|
||||||
|
renderAction("changeRoundness")}
|
||||||
|
{renderAction("changeOpacity")}
|
||||||
|
</div>
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined Arrow Properties */}
|
||||||
|
{(toolIsArrow(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactArrowProperties"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setAppState({ openPopup: "compactArrowProperties" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.arrowtypes")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "compactArrowProperties"
|
||||||
|
? null
|
||||||
|
: "compactArrowProperties",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
// Show an icon based on the current arrow type
|
||||||
|
const arrowType = getFormValue(
|
||||||
|
targetElements,
|
||||||
|
app,
|
||||||
|
(element) => {
|
||||||
|
if (isArrowElement(element)) {
|
||||||
|
return element.elbowed
|
||||||
|
? "elbow"
|
||||||
|
: element.roundness
|
||||||
|
? "round"
|
||||||
|
: "sharp";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(element) => isArrowElement(element),
|
||||||
|
(hasSelection) =>
|
||||||
|
hasSelection ? null : appState.currentItemArrowType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (arrowType === "elbow") {
|
||||||
|
return elbowArrowIcon;
|
||||||
|
}
|
||||||
|
if (arrowType === "round") {
|
||||||
|
return roundArrowIcon;
|
||||||
|
}
|
||||||
|
return sharpArrowIcon;
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactArrowProperties" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
container={container}
|
||||||
|
className="properties-content"
|
||||||
|
style={{ maxWidth: "13rem" }}
|
||||||
|
onClose={() => {}}
|
||||||
|
>
|
||||||
|
{renderAction("changeArrowProperties")}
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Linear Editor */}
|
||||||
|
{showLineEditorAction && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("toggleLinearEditor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text Properties */}
|
||||||
|
{(appState.activeTool.type === "text" ||
|
||||||
|
targetElements.some(isTextElement)) && (
|
||||||
|
<>
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("changeFontFamily")}
|
||||||
|
</div>
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactTextProperties"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
saveCaretPosition();
|
||||||
|
}
|
||||||
|
setAppState({ openPopup: "compactTextProperties" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
restoreCaretPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.textAlign")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (appState.openPopup === "compactTextProperties") {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
} else {
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
saveCaretPosition();
|
||||||
|
}
|
||||||
|
setAppState({ openPopup: "compactTextProperties" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TextSizeIcon}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactTextProperties" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
className={PROPERTIES_CLASSES}
|
||||||
|
container={container}
|
||||||
|
style={{ maxWidth: "13rem" }}
|
||||||
|
// Improve focus handling for text editing scenarios
|
||||||
|
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||||
|
onClose={() => {
|
||||||
|
// Refocus text editor when popover closes with caret restoration
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
restoreCaretPosition();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{(appState.activeTool.type === "text" ||
|
||||||
|
targetElements.some(isTextElement)) &&
|
||||||
|
renderAction("changeFontSize")}
|
||||||
|
{(appState.activeTool.type === "text" ||
|
||||||
|
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||||
|
renderAction("changeTextAlign")}
|
||||||
|
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||||
|
renderAction("changeVerticalAlign")}
|
||||||
|
</div>
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dedicated Copy Button */}
|
||||||
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("duplicateSelection")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dedicated Delete Button */}
|
||||||
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("deleteSelectedElements")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined Other Actions */}
|
||||||
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactOtherProperties"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setAppState({ openPopup: "compactOtherProperties" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.actions")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setAppState({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "compactOtherProperties"
|
||||||
|
? null
|
||||||
|
: "compactOtherProperties",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DotsHorizontalIcon}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactOtherProperties" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
className={PROPERTIES_CLASSES}
|
||||||
|
container={container}
|
||||||
|
style={{
|
||||||
|
maxWidth: "12rem",
|
||||||
|
// center the popover content
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
onClose={() => {}}
|
||||||
|
>
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.layers")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{renderAction("sendToBack")}
|
||||||
|
{renderAction("sendBackward")}
|
||||||
|
{renderAction("bringForward")}
|
||||||
|
{renderAction("bringToFront")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{showAlignActions && !isSingleElementBoundContainer && (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.align")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{isRTL ? (
|
||||||
|
<>
|
||||||
|
{renderAction("alignRight")}
|
||||||
|
{renderAction("alignHorizontallyCentered")}
|
||||||
|
{renderAction("alignLeft")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderAction("alignLeft")}
|
||||||
|
{renderAction("alignHorizontallyCentered")}
|
||||||
|
{renderAction("alignRight")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeHorizontally")}
|
||||||
|
{/* breaks the row ˇˇ */}
|
||||||
|
<div style={{ flexBasis: "100%", height: 0 }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: ".5rem",
|
||||||
|
marginTop: "-0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderAction("alignTop")}
|
||||||
|
{renderAction("alignVerticallyCentered")}
|
||||||
|
{renderAction("alignBottom")}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeVertically")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.actions")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{renderAction("group")}
|
||||||
|
{renderAction("ungroup")}
|
||||||
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
|
{showCropEditorAction && renderAction("cropEditor")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ShapesSwitcher = ({
|
export const ShapesSwitcher = ({
|
||||||
activeTool,
|
activeTool,
|
||||||
appState,
|
appState,
|
||||||
@ -295,7 +751,8 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
const frameToolSelected = activeTool.type === "frame";
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
const laserToolSelected = activeTool.type === "laser";
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
const lassoToolSelected = activeTool.type === "lasso";
|
const lassoToolSelected =
|
||||||
|
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||||
|
|
||||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
@ -303,10 +760,14 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
{getToolbarTools(app).map(
|
||||||
|
({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
if (
|
if (
|
||||||
UIOptions.tools?.[
|
UIOptions.tools?.[
|
||||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
value as Extract<
|
||||||
|
typeof value,
|
||||||
|
keyof AppProps["UIOptions"]["tools"]
|
||||||
|
>
|
||||||
] === false
|
] === false
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
@ -352,7 +813,6 @@ export const ShapesSwitcher = ({
|
|||||||
if (value === "image") {
|
if (value === "image") {
|
||||||
app.setActiveTool({
|
app.setActiveTool({
|
||||||
type: value,
|
type: value,
|
||||||
insertOnCanvasDirectly: pointerType !== "mouse",
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
app.setActiveTool({ type: value });
|
app.setActiveTool({ type: value });
|
||||||
@ -360,7 +820,8 @@ export const ShapesSwitcher = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
<div className="App-toolbar__divider" />
|
<div className="App-toolbar__divider" />
|
||||||
|
|
||||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||||
@ -419,6 +880,7 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.laser")}
|
{t("toolBar.laser")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
{app.defaultSelectionTool !== "lasso" && (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||||
icon={LassoIcon}
|
icon={LassoIcon}
|
||||||
@ -427,6 +889,7 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.lasso")}
|
{t("toolBar.lasso")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||||
Generate
|
Generate
|
||||||
</div>
|
</div>
|
||||||
@ -506,15 +969,3 @@ export const ExitZenModeAction = ({
|
|||||||
{t("buttons.exitZenMode")}
|
{t("buttons.exitZenMode")}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FinalizeAction = ({
|
|
||||||
renderAction,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
renderAction: ActionManager["renderAction"];
|
|
||||||
className?: string;
|
|
||||||
}) => (
|
|
||||||
<div className={`finalize-button ${className}`}>
|
|
||||||
{renderAction("finalize", { size: "small" })}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,7 @@ interface ButtonIconProps {
|
|||||||
/** include standalone style (could interfere with parent styles) */
|
/** include standalone style (could interfere with parent styles) */
|
||||||
standalone?: boolean;
|
standalone?: boolean;
|
||||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||||
@ -30,6 +31,7 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
|||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
className={clsx(className, { standalone, active })}
|
className={clsx(className, { standalone, active })}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
style={props.style}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -22,6 +22,12 @@
|
|||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 11rem;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.color-picker-container--no-top-picks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker__top-picks {
|
.color-picker__top-picks {
|
||||||
@ -80,6 +86,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__button-background {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
.color-picker__button-outline {
|
.color-picker__button-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||||
@ -18,7 +18,12 @@ import { useExcalidrawContainer } from "../App";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
import { slashIcon } from "../icons";
|
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
|
||||||
|
import {
|
||||||
|
saveCaretPosition,
|
||||||
|
restoreCaretPosition,
|
||||||
|
temporarilyDisableTextEditorBlur,
|
||||||
|
} from "../../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
@ -67,6 +72,7 @@ interface ColorPickerProps {
|
|||||||
palette?: ColorPaletteCustom | null;
|
palette?: ColorPaletteCustom | null;
|
||||||
topPicks?: ColorTuple;
|
topPicks?: ColorTuple;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPickerPopupContent = ({
|
const ColorPickerPopupContent = ({
|
||||||
@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
|
|||||||
elements,
|
elements,
|
||||||
palette = COLOR_PALETTE,
|
palette = COLOR_PALETTE,
|
||||||
updateData,
|
updateData,
|
||||||
|
getOpenPopup,
|
||||||
|
appState,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
ColorPickerProps,
|
ColorPickerProps,
|
||||||
| "type"
|
| "type"
|
||||||
@ -86,7 +94,10 @@ const ColorPickerPopupContent = ({
|
|||||||
| "elements"
|
| "elements"
|
||||||
| "palette"
|
| "palette"
|
||||||
| "updateData"
|
| "updateData"
|
||||||
>) => {
|
| "appState"
|
||||||
|
> & {
|
||||||
|
getOpenPopup: () => AppState["openPopup"];
|
||||||
|
}) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
@ -117,6 +128,8 @@ const ColorPickerPopupContent = ({
|
|||||||
<PropertiesPopover
|
<PropertiesPopover
|
||||||
container={container}
|
container={container}
|
||||||
style={{ maxWidth: "13rem" }}
|
style={{ maxWidth: "13rem" }}
|
||||||
|
// Improve focus handling for text editing scenarios
|
||||||
|
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
// refocus due to eye dropper
|
// refocus due to eye dropper
|
||||||
focusPickerContent();
|
focusPickerContent();
|
||||||
@ -131,8 +144,23 @@ const ColorPickerPopupContent = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
// only clear if we're still the active popup (avoid racing with switch)
|
||||||
|
if (getOpenPopup() === type) {
|
||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
|
}
|
||||||
setActiveColorPickerSection(null);
|
setActiveColorPickerSection(null);
|
||||||
|
|
||||||
|
// Refocus text editor when popover closes if we were editing text
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{palette ? (
|
{palette ? (
|
||||||
@ -141,7 +169,17 @@ const ColorPickerPopupContent = ({
|
|||||||
palette={palette}
|
palette={palette}
|
||||||
color={color}
|
color={color}
|
||||||
onChange={(changedColor) => {
|
onChange={(changedColor) => {
|
||||||
|
// Save caret position before color change if editing text
|
||||||
|
const savedSelection = appState.editingTextElement
|
||||||
|
? saveCaretPosition()
|
||||||
|
: null;
|
||||||
|
|
||||||
onChange(changedColor);
|
onChange(changedColor);
|
||||||
|
|
||||||
|
// Restore caret position after color change if editing text
|
||||||
|
if (appState.editingTextElement && savedSelection) {
|
||||||
|
restoreCaretPosition(savedSelection);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onEyeDropperToggle={(force) => {
|
onEyeDropperToggle={(force) => {
|
||||||
setEyeDropperState((state) => {
|
setEyeDropperState((state) => {
|
||||||
@ -168,6 +206,7 @@ const ColorPickerPopupContent = ({
|
|||||||
if (eyeDropperState) {
|
if (eyeDropperState) {
|
||||||
setEyeDropperState(null);
|
setEyeDropperState(null);
|
||||||
} else {
|
} else {
|
||||||
|
// close explicitly on Escape
|
||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -188,11 +227,32 @@ const ColorPickerTrigger = ({
|
|||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
type,
|
type,
|
||||||
|
compactMode = false,
|
||||||
|
mode = "background",
|
||||||
|
onToggle,
|
||||||
|
editingTextElement,
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
|
compactMode?: boolean;
|
||||||
|
mode?: "background" | "stroke";
|
||||||
|
onToggle: () => void;
|
||||||
|
editingTextElement?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// use pointerdown so we run before outside-close logic
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// If editing text, temporarily disable the wysiwyg blur event
|
||||||
|
if (editingTextElement) {
|
||||||
|
temporarilyDisableTextEditorBlur();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
@ -208,8 +268,37 @@ const ColorPickerTrigger = ({
|
|||||||
? t("labels.showStroke")
|
? t("labels.showStroke")
|
||||||
: t("labels.showBackground")
|
: t("labels.showBackground")
|
||||||
}
|
}
|
||||||
|
data-openpopup={type}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||||
|
{compactMode && color && (
|
||||||
|
<div className="color-picker__button-background">
|
||||||
|
{mode === "background" ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||||
|
? "#fff"
|
||||||
|
: "#111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backgroundIcon}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||||
|
? "#fff"
|
||||||
|
: "#111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strokeIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -224,25 +313,59 @@ export const ColorPicker = ({
|
|||||||
topPicks,
|
topPicks,
|
||||||
updateData,
|
updateData,
|
||||||
appState,
|
appState,
|
||||||
|
compactMode = false,
|
||||||
}: ColorPickerProps) => {
|
}: ColorPickerProps) => {
|
||||||
|
const openRef = useRef(appState.openPopup);
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = appState.openPopup;
|
||||||
|
}, [appState.openPopup]);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className={clsx("color-picker-container", {
|
||||||
|
"color-picker-container--no-top-picks": compactMode,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!compactMode && (
|
||||||
<TopPicks
|
<TopPicks
|
||||||
activeColor={color}
|
activeColor={color}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
type={type}
|
type={type}
|
||||||
topPicks={topPicks}
|
topPicks={topPicks}
|
||||||
/>
|
/>
|
||||||
<ButtonSeparator />
|
)}
|
||||||
|
{!compactMode && <ButtonSeparator />}
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={appState.openPopup === type}
|
open={appState.openPopup === type}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
updateData({ openPopup: open ? type : null });
|
if (open) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* serves as an active color indicator as well */}
|
{/* serves as an active color indicator as well */}
|
||||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
<ColorPickerTrigger
|
||||||
|
color={color}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
compactMode={compactMode}
|
||||||
|
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||||
|
editingTextElement={!!appState.editingTextElement}
|
||||||
|
onToggle={() => {
|
||||||
|
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||||
|
if (appState.openPopup === type) {
|
||||||
|
// toggle off on same trigger
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
} else if (appState.openPopup) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
} else {
|
||||||
|
// open this one
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* popup content */}
|
{/* popup content */}
|
||||||
{appState.openPopup === type && (
|
{appState.openPopup === type && (
|
||||||
<ColorPickerPopupContent
|
<ColorPickerPopupContent
|
||||||
@ -253,6 +376,8 @@ export const ColorPicker = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
getOpenPopup={() => openRef.current}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
|
|||||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||||
import { useStable } from "../../hooks/useStable";
|
import { useStable } from "../../hooks/useStable";
|
||||||
|
|
||||||
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||||
|
|
||||||
import "./CommandPalette.scss";
|
import "./CommandPalette.scss";
|
||||||
@ -293,6 +295,7 @@ function CommandPaletteInner({
|
|||||||
actionManager.actions.decreaseFontSize,
|
actionManager.actions.decreaseFontSize,
|
||||||
actionManager.actions.toggleLinearEditor,
|
actionManager.actions.toggleLinearEditor,
|
||||||
actionManager.actions.cropEditor,
|
actionManager.actions.cropEditor,
|
||||||
|
actionManager.actions.togglePolygon,
|
||||||
actionLink,
|
actionLink,
|
||||||
actionCopyElementLink,
|
actionCopyElementLink,
|
||||||
actionLinkToElement,
|
actionLinkToElement,
|
||||||
@ -502,7 +505,6 @@ function CommandPaletteInner({
|
|||||||
if (value === "image") {
|
if (value === "image") {
|
||||||
app.setActiveTool({
|
app.setActiveTool({
|
||||||
type: value,
|
type: value,
|
||||||
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
app.setActiveTool({ type: value });
|
app.setActiveTool({ type: value });
|
||||||
@ -964,7 +966,7 @@ const CommandItem = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{command.label}
|
<Ellipsify>{command.label}</Ellipsify>
|
||||||
</div>
|
</div>
|
||||||
{showShortcut && command.shortcut && (
|
{showShortcut && command.shortcut && (
|
||||||
<CommandShortcutHint shortcut={command.shortcut} />
|
<CommandShortcutHint shortcut={command.shortcut} />
|
||||||
|
|||||||
18
packages/excalidraw/components/Ellipsify.tsx
Normal file
18
packages/excalidraw/components/Ellipsify.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export const Ellipsify = ({
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...rest}
|
||||||
|
style={{
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
...rest.style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,5 +11,10 @@
|
|||||||
2rem + 4 * var(--default-button-size)
|
2rem + 4 * var(--default-button-size)
|
||||||
); // 4 gaps + 4 buttons
|
); // 4 gaps + 4 buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
display: block;
|
||||||
|
grid-template-columns: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import clsx from "clsx";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY } from "@excalidraw/common";
|
||||||
@ -58,6 +59,7 @@ interface FontPickerProps {
|
|||||||
onHover: (fontFamily: FontFamilyValues) => void;
|
onHover: (fontFamily: FontFamilyValues) => void;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
onPopupChange: (open: boolean) => void;
|
onPopupChange: (open: boolean) => void;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPicker = React.memo(
|
export const FontPicker = React.memo(
|
||||||
@ -69,6 +71,7 @@ export const FontPicker = React.memo(
|
|||||||
onHover,
|
onHover,
|
||||||
onLeave,
|
onLeave,
|
||||||
onPopupChange,
|
onPopupChange,
|
||||||
|
compactMode = false,
|
||||||
}: FontPickerProps) => {
|
}: FontPickerProps) => {
|
||||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||||
const onSelectCallback = useCallback(
|
const onSelectCallback = useCallback(
|
||||||
@ -81,7 +84,14 @@ export const FontPicker = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className={clsx("FontPicker__container", {
|
||||||
|
"FontPicker__container--compact": compactMode,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{!compactMode && (
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection<FontFamilyValues | false>
|
<RadioSelection<FontFamilyValues | false>
|
||||||
type="button"
|
type="button"
|
||||||
@ -90,9 +100,13 @@ export const FontPicker = React.memo(
|
|||||||
onClick={onSelectCallback}
|
onClick={onSelectCallback}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ButtonSeparator />
|
)}
|
||||||
|
{!compactMode && <ButtonSeparator />}
|
||||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
<FontPickerTrigger
|
||||||
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
isOpened={isOpened}
|
||||||
|
/>
|
||||||
{isOpened && (
|
{isOpened && (
|
||||||
<FontPickerList
|
<FontPickerList
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
|||||||
@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
|
|||||||
onClose,
|
onClose,
|
||||||
}: FontPickerListProps) => {
|
}: FontPickerListProps) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const { fonts } = useApp();
|
const app = useApp();
|
||||||
|
const { fonts } = app;
|
||||||
const { showDeprecatedFonts } = useAppProps();
|
const { showDeprecatedFonts } = useAppProps();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
|
|||||||
onLeave,
|
onLeave,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create a wrapped onSelect function that preserves caret position
|
||||||
|
const wrappedOnSelect = useCallback(
|
||||||
|
(fontFamily: FontFamilyValues) => {
|
||||||
|
// Save caret position before font selection if editing text
|
||||||
|
let savedSelection: { start: number; end: number } | null = null;
|
||||||
|
if (app.state.editingTextElement) {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
savedSelection = {
|
||||||
|
start: textEditor.selectionStart,
|
||||||
|
end: textEditor.selectionEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(fontFamily);
|
||||||
|
|
||||||
|
// Restore caret position after font selection if editing text
|
||||||
|
if (app.state.editingTextElement && savedSelection) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor && savedSelection) {
|
||||||
|
textEditor.focus();
|
||||||
|
textEditor.selectionStart = savedSelection.start;
|
||||||
|
textEditor.selectionEnd = savedSelection.end;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelect, app.state.editingTextElement],
|
||||||
|
);
|
||||||
|
|
||||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||||
(event) => {
|
(event) => {
|
||||||
const handled = fontPickerKeyHandler({
|
const handled = fontPickerKeyHandler({
|
||||||
@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
|
|||||||
inputRef,
|
inputRef,
|
||||||
hoveredFont,
|
hoveredFont,
|
||||||
filteredFonts,
|
filteredFonts,
|
||||||
onSelect,
|
onSelect: wrappedOnSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
|
|||||||
// allow to tab between search and selected font
|
// allow to tab between search and selected font
|
||||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onSelect(Number(e.currentTarget.value));
|
wrappedOnSelect(Number(e.currentTarget.value));
|
||||||
}}
|
}}
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
if (hoveredFont?.value !== font.value) {
|
if (hoveredFont?.value !== font.value) {
|
||||||
@ -282,9 +319,24 @@ export const FontPickerList = React.memo(
|
|||||||
className="properties-content"
|
className="properties-content"
|
||||||
container={container}
|
container={container}
|
||||||
style={{ width: "15rem" }}
|
style={{ width: "15rem" }}
|
||||||
onClose={onClose}
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (app.state.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPointerLeave={onLeave}
|
onPointerLeave={onLeave}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||||
>
|
>
|
||||||
<QuickSearch
|
<QuickSearch
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -7,33 +6,38 @@ import { t } from "../../i18n";
|
|||||||
import { ButtonIcon } from "../ButtonIcon";
|
import { ButtonIcon } from "../ButtonIcon";
|
||||||
import { TextIcon } from "../icons";
|
import { TextIcon } from "../icons";
|
||||||
|
|
||||||
import { isDefaultFont } from "./FontPicker";
|
import { useExcalidrawSetAppState } from "../App";
|
||||||
|
|
||||||
interface FontPickerTriggerProps {
|
interface FontPickerTriggerProps {
|
||||||
selectedFontFamily: FontFamilyValues | null;
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
isOpened?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPickerTrigger = ({
|
export const FontPickerTrigger = ({
|
||||||
selectedFontFamily,
|
selectedFontFamily,
|
||||||
|
isOpened = false,
|
||||||
}: FontPickerTriggerProps) => {
|
}: FontPickerTriggerProps) => {
|
||||||
const isTriggerActive = useMemo(
|
const setAppState = useExcalidrawSetAppState();
|
||||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
|
||||||
[selectedFontFamily],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||||
<div>
|
|
||||||
<ButtonIcon
|
<ButtonIcon
|
||||||
standalone
|
standalone
|
||||||
icon={TextIcon}
|
icon={TextIcon}
|
||||||
title={t("labels.showFonts")}
|
title={t("labels.showFonts")}
|
||||||
className="properties-trigger"
|
className="properties-trigger"
|
||||||
testId={"font-family-show-fonts"}
|
testId={"font-family-show-fonts"}
|
||||||
active={isTriggerActive}
|
active={isOpened}
|
||||||
// no-op
|
onClick={() => {
|
||||||
onClick={() => {}}
|
setAppState((appState) => ({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "fontFamily" ? null : appState.openPopup,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
isFlowchartNodeElement,
|
isFlowchartNodeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
|
isLineElement,
|
||||||
isTextBindableContainer,
|
isTextBindableContainer,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
@ -73,10 +74,6 @@ const getHints = ({
|
|||||||
return t("hints.embeddable");
|
return t("hints.embeddable");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
|
||||||
return t("hints.placeImage");
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -118,7 +115,7 @@ const getHints = ({
|
|||||||
appState.selectionElement &&
|
appState.selectionElement &&
|
||||||
!selectedElements.length &&
|
!selectedElements.length &&
|
||||||
!appState.editingTextElement &&
|
!appState.editingTextElement &&
|
||||||
!appState.editingLinearElement
|
!appState.selectedLinearElement?.isEditing
|
||||||
) {
|
) {
|
||||||
return [t("hints.deepBoxSelect")];
|
return [t("hints.deepBoxSelect")];
|
||||||
}
|
}
|
||||||
@ -133,12 +130,14 @@ const getHints = ({
|
|||||||
|
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
if (isLinearElement(selectedElements[0])) {
|
if (isLinearElement(selectedElements[0])) {
|
||||||
if (appState.editingLinearElement) {
|
if (appState.selectedLinearElement?.isEditing) {
|
||||||
return appState.editingLinearElement.selectedPointsIndices
|
return appState.selectedLinearElement.selectedPointsIndices
|
||||||
? t("hints.lineEditor_pointSelected")
|
? t("hints.lineEditor_pointSelected")
|
||||||
: t("hints.lineEditor_nothingSelected");
|
: t("hints.lineEditor_nothingSelected");
|
||||||
}
|
}
|
||||||
return t("hints.lineEditor_info");
|
return isLineElement(selectedElements[0])
|
||||||
|
? t("hints.lineEditor_line_info")
|
||||||
|
: t("hints.lineEditor_info");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!appState.newElement &&
|
!appState.newElement &&
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user