Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bcd5b622c |
@@ -1,5 +1,3 @@
|
||||
MODE="development"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
MODE="production"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Project coding standards
|
||||
|
||||
## Generic Communication Guidelines
|
||||
|
||||
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||
- Stop apologising if corrected, just provide the correct information or code
|
||||
- Prefer code unless asked for explanation
|
||||
- Stop summarizing what you've changed after modifications unless asked for
|
||||
|
||||
## TypeScript Guidelines
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Where possible, prefer implementations without allocation
|
||||
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||
- Prefer immutable data (const, readonly)
|
||||
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||
|
||||
## React Guidelines
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the React hooks rules (no conditional hooks)
|
||||
- Keep components small and focused
|
||||
- Use CSS modules for component styling
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Use PascalCase for component names, interfaces, and type aliases
|
||||
- Use camelCase for variables, functions, and methods
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use try/catch blocks for async operations
|
||||
- Implement proper error boundaries in React components
|
||||
- Always log errors with contextual information
|
||||
|
||||
## Testing
|
||||
|
||||
- Always attempt to fix #problems
|
||||
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||
|
||||
## Types
|
||||
|
||||
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: Auto release
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn release --tag=next --non-interactive
|
||||
yarn autorelease
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Auto release excalidraw preview
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-preview:
|
||||
name: Auto release preview
|
||||
if: github.event.comment.body == '@excalibot trigger release' && github.event.issue.pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to release comment
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: "+1"
|
||||
- name: Get PR SHA
|
||||
id: sha
|
||||
uses: actions/github-script@v4
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const pr = await github.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
});
|
||||
return pr.data.head.sha
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ steps.sha.outputs.result }}
|
||||
fetch-depth: 2
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Set up publish access
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Auto release preview
|
||||
id: "autorelease"
|
||||
run: |
|
||||
yarn add @actions/core -W
|
||||
yarn autorelease preview ${{ github.event.issue.number }}
|
||||
- name: Post comment post release
|
||||
if: always()
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: "@${{ github.event.comment.user.login }} ${{ steps.autorelease.outputs.result }}"
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install and lint
|
||||
run: |
|
||||
|
||||
@@ -14,10 +14,10 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }}
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Create report file
|
||||
run: |
|
||||
|
||||
@@ -17,14 +17,9 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn --frozen-lockfile
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
- name: Install in packages/excalidraw
|
||||
run: yarn
|
||||
working-directory: packages/excalidraw
|
||||
|
||||
+1
-2
@@ -25,5 +25,4 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
meta*.json
|
||||
@@ -1,34 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Structure
|
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features
|
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||
3. **Testing**: Always run `yarn test:update` before committing
|
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn test:typecheck # TypeScript type checking
|
||||
yarn test:update # Run all tests (with snapshot updates)
|
||||
yarn fix # Auto-fix formatting and linting issues
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Package System
|
||||
|
||||
- Uses Yarn workspaces for monorepo management
|
||||
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||
- Build system uses esbuild for packages, Vite for the app
|
||||
- TypeScript throughout with strict configuration
|
||||
+4
-5
@@ -1,4 +1,4 @@
|
||||
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||
FROM node:18 AS build
|
||||
|
||||
WORKDIR /opt/node_app
|
||||
|
||||
@@ -6,14 +6,13 @@ COPY . .
|
||||
|
||||
# do not ignore optional dependencies:
|
||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||
RUN yarn --network-timeout 600000
|
||||
|
||||
ARG NODE_ENV=production
|
||||
|
||||
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||
RUN yarn build:app:docker
|
||||
|
||||
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -23,17 +23,24 @@
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/LICENSE">
|
||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" /></a>
|
||||
<img alt="Excalidraw is released under the MIT license." src="https://img.shields.io/badge/license-MIT-blue.svg" />
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@excalidraw/excalidraw">
|
||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" /></a>
|
||||
<img alt="npm downloads/month" src="https://img.shields.io/npm/dm/@excalidraw/excalidraw" />
|
||||
</a>
|
||||
<a href="https://docs.excalidraw.com/docs/introduction/contributing">
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<img alt="PRs welcome!" src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" />
|
||||
</a>
|
||||
<a href="https://discord.gg/UexuTaE">
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/></a>
|
||||
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
|
||||
</a>
|
||||
<a href="https://deepwiki.com/excalidraw/excalidraw">
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" /></a>
|
||||
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
|
||||
</a>
|
||||
<a href="https://twitter.com/excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/></a>
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||
</a>
|
||||
<img alt"CodeRabbit Reviews" src="https://img.shields.io/coderabbit/prs/github/excalidraw/excalidraw?utm_source=oss&utm_medium=github&utm_campaign=excalidraw%2Fexcalidraw&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews"/>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co
|
||||
```jsx live
|
||||
function App() {
|
||||
return (
|
||||
<div style={{ height: "500px" }}>
|
||||
<div style={{ height: "500px"}}>
|
||||
<Excalidraw>
|
||||
<Footer>
|
||||
<button
|
||||
@@ -27,19 +27,19 @@ function App() {
|
||||
|
||||
This will only work for `Desktop` devices.
|
||||
|
||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useEditorInterface`](#useEditorInterface) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
|
||||
|
||||
Open the `Menu` in the below playground and you will see the `custom footer` rendered.
|
||||
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
className="custom-footer"
|
||||
style={{ marginLeft: "20px", height: "2rem" }}
|
||||
style= {{ marginLeft: '20px', height: '2rem'}}
|
||||
onClick={() => alert("This is custom footer in mobile menu")}
|
||||
>
|
||||
custom footer
|
||||
|
||||
@@ -363,7 +363,13 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
```ts
|
||||
(
|
||||
tool: (
|
||||
| { type: ToolType }
|
||||
| (
|
||||
| { type: Exclude<ToolType, "image"> }
|
||||
| {
|
||||
type: Extract<ToolType, "image">;
|
||||
insertOnCanvasDirectly?: boolean;
|
||||
}
|
||||
)
|
||||
| { type: "custom"; customType: string }
|
||||
) & { locked?: boolean },
|
||||
) => {};
|
||||
@@ -371,7 +377,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
|
||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
|
||||
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
||||
|
||||
## setCursor
|
||||
|
||||
@@ -292,7 +292,7 @@ viewportCoordsToSceneCoords({ clientX: number, clientY: number },<br/> 
|
||||
appState: <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L95">AppState</a><br/>): {x: number, y: number}
|
||||
</pre>
|
||||
|
||||
### useEditorInterface
|
||||
### useDevice
|
||||
|
||||
This hook can be used to check the type of device which is being used. It can only be used inside the `children` of `Excalidraw` component.
|
||||
|
||||
@@ -300,8 +300,8 @@ Open the `main menu` in the below example to view the footer.
|
||||
|
||||
```jsx live noInline
|
||||
const MobileFooter = ({}) => {
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<button
|
||||
@@ -336,20 +336,12 @@ render(<App />);
|
||||
The `device` has the following `attributes`, some grouped into `viewport` and `editor` objects, per context.
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
|
||||
The `EditorInterface` object has the following properties:
|
||||
|
||||
| Name | Type | Description |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `formFactor` | `'phone' | 'tablet' | 'desktop'` | Indicates the device type based on screen size |
|
||||
| `desktopUIMode` | `'compact' | 'full'` | UI mode for desktop form factor |
|
||||
| `userAgent.raw` | `string` | Raw user agent string |
|
||||
| `userAgent.isMobileDevice` | `boolean` | True if device is mobile |
|
||||
| `userAgent.platform` | `'ios' | 'android' | 'other' | 'unknown'` | Device platform |
|
||||
| `isTouchScreen` | `boolean` | True if touch events are detected |
|
||||
| `canFitSidebar` | `boolean` | True if sidebar can fit in the viewport |
|
||||
| `isLandscape` | `boolean` | True if viewport is in landscape mode |
|
||||
| --- | --- | --- |
|
||||
| `viewport.isMobile` | `boolean` | Set to `true` when viewport is in `mobile` breakpoint |
|
||||
| `viewport.isLandscape` | `boolean` | Set to `true` when the viewport is in `landscape` mode |
|
||||
| `editor.canFitSidebar` | `boolean` | Set to `true` if there's enough space to fit the `sidebar` |
|
||||
| `editor.isMobile` | `boolean` | Set to `true` when editor container is in `mobile` breakpoint |
|
||||
| `isTouchScreen` | `boolean` | Set to `true` for `touch` when touch event detected |
|
||||
|
||||
### i18n
|
||||
|
||||
|
||||
@@ -28,12 +28,32 @@ To start the example app using the `@excalidraw/excalidraw` package, follow the
|
||||
|
||||
## Releasing
|
||||
|
||||
### Create a test release
|
||||
|
||||
You can create a test release by posting the below comment in your pull request:
|
||||
|
||||
```bash
|
||||
@excalibot trigger release
|
||||
```
|
||||
|
||||
Once the version is released `@excalibot` will post a comment with the release version.
|
||||
|
||||
### Creating a production release
|
||||
|
||||
To release the next stable version follow the below steps:
|
||||
|
||||
```bash
|
||||
yarn release --tag=latest --version=0.19.0
|
||||
yarn prerelease:excalidraw
|
||||
```
|
||||
|
||||
You will need to pass the `latest` tag with `version` for which you want to create the release. This will make the changes needed before publishing the packages into NPM, like updating dependencies of all `@excalidraw/*` packages, generating new entries in `CHANGELOG.md` and more.
|
||||
You need to pass the `version` for which you want to create the release. This will make the changes needed before making the release like updating `package.json`, `changelog` and more.
|
||||
|
||||
The next step is to run the `release` script:
|
||||
|
||||
```bash
|
||||
yarn release:excalidraw
|
||||
```
|
||||
|
||||
This will publish the package.
|
||||
|
||||
Right now there are two steps to create a production release but once this works fine these scripts will be combined and more automation will be done.
|
||||
|
||||
@@ -38,8 +38,6 @@ If you want to only import `Excalidraw` component you can do :point_down:
|
||||
|
||||
```jsx showLineNumbers
|
||||
import dynamic from "next/dynamic";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import("@excalidraw/excalidraw")).Excalidraw,
|
||||
{
|
||||
|
||||
@@ -33,7 +33,6 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
excalidraw:
|
||||
build:
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:packages": "yarn --cwd ../../ build:packages",
|
||||
"build:workspace": "yarn build:packages && yarn copy:assets",
|
||||
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
|
||||
"copy:assets": "cp -r ../../packages/excalidraw/dist/prod/fonts ./public",
|
||||
"dev": "yarn build:workspace && next dev -p 3005",
|
||||
"build": "yarn build:workspace && next build",
|
||||
|
||||
@@ -12,10 +12,10 @@ const MobileFooter = ({
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
excalidrawLib: typeof TExcalidraw;
|
||||
}) => {
|
||||
const { useEditorInterface, Footer } = excalidrawLib;
|
||||
const { useDevice, Footer } = excalidrawLib;
|
||||
|
||||
const editorInterface = useEditorInterface();
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
const device = useDevice();
|
||||
if (device.editor.isMobile) {
|
||||
return (
|
||||
<Footer>
|
||||
<CustomFooter
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5002",
|
||||
"build:preview": "yarn build && yarn preview",
|
||||
"build:packages": "yarn --cwd ../../ build:packages"
|
||||
"build:package": "yarn workspace @excalidraw/excalidraw run build:esm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "yarn install",
|
||||
"buildCommand": "yarn build:packages && yarn build"
|
||||
"buildCommand": "yarn build:package && yarn build"
|
||||
}
|
||||
|
||||
+18
-67
@@ -4,7 +4,6 @@ import {
|
||||
TTDDialogTrigger,
|
||||
CaptureUpdateAction,
|
||||
reconcileElements,
|
||||
useEditorInterface,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -48,11 +48,7 @@ import {
|
||||
youtubeIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { isElementLink } from "@excalidraw/element";
|
||||
import {
|
||||
bumpElementVersions,
|
||||
restoreAppState,
|
||||
restoreElements,
|
||||
} from "@excalidraw/excalidraw/data/restore";
|
||||
import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import clsx from "clsx";
|
||||
@@ -109,8 +105,8 @@ import { TopErrorBoundary } from "./components/TopErrorBoundary";
|
||||
import {
|
||||
exportToBackend,
|
||||
getCollaborationLinkData,
|
||||
importFromBackend,
|
||||
isCollaborationLink,
|
||||
loadScene,
|
||||
} from "./data";
|
||||
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
@@ -124,7 +120,6 @@ import {
|
||||
LibraryIndexedDBAdapter,
|
||||
LibraryLocalStorageMigrationAdapter,
|
||||
LocalData,
|
||||
localStorageQuotaExceededAtom,
|
||||
} from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
@@ -142,9 +137,6 @@ import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
|
||||
|
||||
import "./index.scss";
|
||||
|
||||
import { ExcalidrawPlusPromoBanner } from "./components/ExcalidrawPlusPromoBanner";
|
||||
import { AppSidebar } from "./components/AppSidebar";
|
||||
|
||||
import type { CollabAPI } from "./collab/Collab";
|
||||
|
||||
polyfill();
|
||||
@@ -228,20 +220,9 @@ const initializeScene = async (opts: {
|
||||
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
||||
let scene: Omit<
|
||||
RestoredDataState,
|
||||
// we're not storing files in the scene database/localStorage, and instead
|
||||
// fetch them async from a different store
|
||||
"files"
|
||||
> & {
|
||||
let scene: RestoredDataState & {
|
||||
scrollToContent?: boolean;
|
||||
} = {
|
||||
elements: restoreElements(localDataState?.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
appState: restoreAppState(localDataState?.appState, null),
|
||||
};
|
||||
} = await loadScene(null, null, localDataState);
|
||||
|
||||
let roomLinkData = getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
||||
@@ -255,26 +236,11 @@ const initializeScene = async (opts: {
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
const imported = await importFromBackend(
|
||||
scene = await loadScene(
|
||||
jsonBackendMatch[1],
|
||||
jsonBackendMatch[2],
|
||||
localDataState,
|
||||
);
|
||||
|
||||
scene = {
|
||||
elements: bumpElementVersions(
|
||||
restoreElements(imported.elements, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
localDataState?.elements,
|
||||
),
|
||||
appState: restoreAppState(
|
||||
imported.appState,
|
||||
// local appState when importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
localDataState?.appState,
|
||||
),
|
||||
};
|
||||
}
|
||||
scene.scrollToContent = true;
|
||||
if (!roomLinkData) {
|
||||
@@ -376,8 +342,6 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const [langCode, setLangCode] = useAppLangCode();
|
||||
|
||||
const editorInterface = useEditorInterface();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -526,10 +490,8 @@ const ExcalidrawWrapper = () => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
elements: restoreElements(data.scene.elements, null, {
|
||||
repairBindings: true,
|
||||
}),
|
||||
appState: restoreAppState(data.scene.appState, null),
|
||||
...data.scene,
|
||||
...restore(data.scene, null, null, { repairBindings: true }),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
}
|
||||
@@ -537,6 +499,11 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
@@ -627,6 +594,7 @@ const ExcalidrawWrapper = () => {
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
@@ -701,8 +669,8 @@ const ExcalidrawWrapper = () => {
|
||||
debugRenderer(
|
||||
debugCanvasRef.current,
|
||||
appState,
|
||||
elements,
|
||||
window.devicePixelRatio,
|
||||
() => forceRefresh((prev) => !prev),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -766,8 +734,6 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||
|
||||
const onCollabDialogOpen = useCallback(
|
||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||
[setShareDialogState],
|
||||
@@ -886,22 +852,14 @@ const ExcalidrawWrapper = () => {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="excalidraw-ui-top-right">
|
||||
{excalidrawAPI?.getEditorInterface().formFactor === "desktop" && (
|
||||
<ExcalidrawPlusPromoBanner
|
||||
isSignedIn={isExcalidrawPlusSignedUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="top-right-ui">
|
||||
{collabError.message && <CollabError collabError={collabError} />}
|
||||
<LiveCollaborationTrigger
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() =>
|
||||
setShareDialogState({ isOpen: true, type: "share" })
|
||||
}
|
||||
editorInterface={editorInterface}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -950,15 +908,10 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="alertalert--warning">
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{localStorageQuotaExceeded && (
|
||||
<div className="alert alert--danger">
|
||||
{t("alerts.localStorageQuotaExceeded")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
@@ -987,8 +940,6 @@ const ExcalidrawWrapper = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppSidebar />
|
||||
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
|
||||
@@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
|
||||
// should be aligned with MAX_ALLOWED_FILE_BYTES
|
||||
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
export const FILE_CACHE_MAX_AGE_SEC = 31536000;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
reconcileElements,
|
||||
} from "@excalidraw/excalidraw";
|
||||
import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
|
||||
import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common";
|
||||
import { APP_NAME, EVENT } from "@excalidraw/common";
|
||||
import {
|
||||
IDLE_THRESHOLD,
|
||||
ACTIVE_THRESHOLD,
|
||||
@@ -29,8 +29,6 @@ import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
|
||||
import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore";
|
||||
|
||||
import type {
|
||||
ReconciledExcalidrawElement,
|
||||
RemoteExcalidrawElement,
|
||||
@@ -313,7 +311,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: readonly SyncableExcalidrawElement[],
|
||||
) => {
|
||||
syncableElements = cloneJSON(syncableElements);
|
||||
try {
|
||||
const storedElements = await saveToFirebase(
|
||||
this.portal,
|
||||
@@ -444,7 +441,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private decryptPayload = async (
|
||||
iv: Uint8Array<ArrayBuffer>,
|
||||
iv: Uint8Array,
|
||||
encryptedData: ArrayBuffer,
|
||||
decryptionKey: string,
|
||||
): Promise<ValueOf<SocketUpdateDataSource>> => {
|
||||
@@ -533,10 +530,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingRoomLinkData) {
|
||||
// when joining existing room, don't merge it with current scene data
|
||||
this.excalidrawAPI.resetScene();
|
||||
} else {
|
||||
if (!existingRoomLinkData) {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
@@ -565,7 +559,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array<ArrayBuffer>) => {
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
@@ -582,9 +576,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
case WS_SUBTYPES.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
this.initializeRoom({ fetchScene: false });
|
||||
const remoteElements = toBrandedType<
|
||||
readonly RemoteExcalidrawElement[]
|
||||
>(decryptedData.payload.elements);
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
const reconciledElements =
|
||||
this._reconcileElements(remoteElements);
|
||||
this.handleRemoteSceneUpdate(reconciledElements);
|
||||
@@ -598,11 +590,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
}
|
||||
case WS_SUBTYPES.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this._reconcileElements(
|
||||
toBrandedType<readonly RemoteExcalidrawElement[]>(
|
||||
decryptedData.payload.elements,
|
||||
),
|
||||
),
|
||||
this._reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case WS_SUBTYPES.MOUSE_LOCATION: {
|
||||
@@ -751,28 +739,17 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
};
|
||||
|
||||
private _reconcileElements = (
|
||||
remoteElements: readonly RemoteExcalidrawElement[],
|
||||
remoteElements: readonly ExcalidrawElement[],
|
||||
): ReconciledExcalidrawElement[] => {
|
||||
const localElements = this.getSceneElementsIncludingDeleted();
|
||||
const appState = this.excalidrawAPI.getAppState();
|
||||
|
||||
const existingElements = this.getSceneElementsIncludingDeleted();
|
||||
|
||||
// NOTE ideally we restore _after_ reconciliation but we can't do that
|
||||
// as we'd regenerate even elements such as appState.newElement which would
|
||||
// break the state
|
||||
remoteElements = restoreElements(remoteElements, existingElements);
|
||||
|
||||
let reconciledElements = reconcileElements(
|
||||
existingElements,
|
||||
remoteElements,
|
||||
const restoredRemoteElements = restoreElements(remoteElements, null);
|
||||
const reconciledElements = reconcileElements(
|
||||
localElements,
|
||||
restoredRemoteElements as RemoteExcalidrawElement[],
|
||||
appState,
|
||||
);
|
||||
|
||||
reconciledElements = bumpElementVersions(
|
||||
reconciledElements,
|
||||
existingElements,
|
||||
);
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||
import { EncryptedIcon } from "./EncryptedIcon";
|
||||
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||
|
||||
export const AppFooter = React.memo(
|
||||
({ onChange }: { onChange: () => void }) => {
|
||||
@@ -18,7 +19,11 @@ export const AppFooter = React.memo(
|
||||
}}
|
||||
>
|
||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||
{!isExcalidrawPlusSignedUser && <EncryptedIcon />}
|
||||
{isExcalidrawPlusSignedUser ? (
|
||||
<ExcalidrawPlusAppLink />
|
||||
) : (
|
||||
<EncryptedIcon />
|
||||
)}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.excalidraw {
|
||||
.app-sidebar-promo-container {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-image {
|
||||
margin: 1rem 0;
|
||||
|
||||
height: 16.25rem;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
transparent 60%,
|
||||
var(--sidebar-bg-color) 100%
|
||||
),
|
||||
var(--image-source);
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-sidebar-promo-text {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { DefaultSidebar, Sidebar, THEME } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
messageCircleIcon,
|
||||
presentationIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { LinkButton } from "@excalidraw/excalidraw/components/LinkButton";
|
||||
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||
|
||||
import "./AppSidebar.scss";
|
||||
|
||||
export const AppSidebar = () => {
|
||||
const { theme, openSidebar } = useUIAppState();
|
||||
|
||||
return (
|
||||
<DefaultSidebar>
|
||||
<DefaultSidebar.TabTriggers>
|
||||
<Sidebar.TabTrigger
|
||||
tab="comments"
|
||||
style={{ opacity: openSidebar?.tab === "comments" ? 1 : 0.4 }}
|
||||
>
|
||||
{messageCircleIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
<Sidebar.TabTrigger
|
||||
tab="presentation"
|
||||
style={{ opacity: openSidebar?.tab === "presentation" ? 1 : 0.4 }}
|
||||
>
|
||||
{presentationIcon}
|
||||
</Sidebar.TabTrigger>
|
||||
</DefaultSidebar.TabTriggers>
|
||||
<Sidebar.Tab tab="comments">
|
||||
<div className="app-sidebar-promo-container">
|
||||
<div
|
||||
className="app-sidebar-promo-image"
|
||||
style={{
|
||||
["--image-source" as any]: `url(/oss_promo_comments_${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.jpg)`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
<div className="app-sidebar-promo-text">
|
||||
Make comments with Excalidraw+
|
||||
</div>
|
||||
<LinkButton
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=comments_promo#excalidraw-redirect`}
|
||||
>
|
||||
Sign up now
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Sidebar.Tab>
|
||||
<Sidebar.Tab tab="presentation" className="px-3">
|
||||
<div className="app-sidebar-promo-container">
|
||||
<div
|
||||
className="app-sidebar-promo-image"
|
||||
style={{
|
||||
["--image-source" as any]: `url(/oss_promo_presentations_${
|
||||
theme === THEME.DARK ? "dark" : "light"
|
||||
}.svg)`,
|
||||
backgroundSize: "60%",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
/>
|
||||
<div className="app-sidebar-promo-text">
|
||||
Create presentations with Excalidraw+
|
||||
</div>
|
||||
<LinkButton
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=presentations_promo#excalidraw-redirect`}
|
||||
>
|
||||
Sign up now
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Sidebar.Tab>
|
||||
</DefaultSidebar>
|
||||
);
|
||||
};
|
||||
@@ -8,14 +8,8 @@ import {
|
||||
getNormalizedCanvasDimensions,
|
||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||
import { arrayToMap, throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isArrowElement,
|
||||
isBindableElement,
|
||||
} from "@excalidraw/element";
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
|
||||
import {
|
||||
isLineSegment,
|
||||
@@ -24,17 +18,9 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import React from "react";
|
||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type { DebugElement } from "@excalidraw/common";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
FixedPointBinding,
|
||||
OrderedExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
|
||||
@@ -75,39 +61,6 @@ const renderCubicBezier = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const isPolygon = (data: any): data is GlobalPoint[] => {
|
||||
return (
|
||||
Array.isArray(data) &&
|
||||
data.every((point) => Array.isArray(point) && point.length === 2)
|
||||
);
|
||||
};
|
||||
|
||||
const renderPolygon = (
|
||||
context: CanvasRenderingContext2D,
|
||||
zoom: number,
|
||||
points: GlobalPoint[],
|
||||
color: string,
|
||||
) => {
|
||||
if (points.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.fillStyle = color;
|
||||
context.globalAlpha = 0.3;
|
||||
context.beginPath();
|
||||
context.moveTo(points[0][0] * zoom, points[0][1] * zoom);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
context.lineTo(points[i][0] * zoom, points[i][1] * zoom);
|
||||
}
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.globalAlpha = 1.0;
|
||||
context.strokeStyle = color;
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||
context.strokeStyle = "#888";
|
||||
context.save();
|
||||
@@ -120,176 +73,6 @@ const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
|
||||
context.save();
|
||||
};
|
||||
|
||||
const _renderBinding = (
|
||||
context: CanvasRenderingContext2D,
|
||||
binding: FixedPointBinding,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) => {
|
||||
if (!binding.fixedPoint) {
|
||||
console.warn("Binding must have a fixedPoint");
|
||||
return;
|
||||
}
|
||||
|
||||
const bindable = elementsMap.get(
|
||||
binding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||
binding.fixedPoint,
|
||||
bindable,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(x * zoom, y * zoom);
|
||||
context.bezierCurveTo(
|
||||
x * zoom - width,
|
||||
y * zoom - height,
|
||||
x * zoom - width,
|
||||
y * zoom + height,
|
||||
x * zoom,
|
||||
y * zoom,
|
||||
);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const _renderBindableBinding = (
|
||||
binding: FixedPointBinding,
|
||||
context: CanvasRenderingContext2D,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: string,
|
||||
) => {
|
||||
const bindable = elementsMap.get(
|
||||
binding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
if (!binding.fixedPoint) {
|
||||
console.warn("Binding must have a fixedPoint");
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = getGlobalFixedPointForBindableElement(
|
||||
binding.fixedPoint,
|
||||
bindable,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(x * zoom, y * zoom);
|
||||
context.bezierCurveTo(
|
||||
x * zoom + width,
|
||||
y * zoom + height,
|
||||
x * zoom + width,
|
||||
y * zoom - height,
|
||||
x * zoom,
|
||||
y * zoom,
|
||||
);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const renderBindings = (
|
||||
context: CanvasRenderingContext2D,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
zoom: number,
|
||||
) => {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
const dim = 16;
|
||||
elements.forEach((element) => {
|
||||
if (element.isDeleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArrowElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (
|
||||
!elementsMap
|
||||
.get(element.startBinding.elementId)
|
||||
?.boundElements?.find((e) => e.id === element.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
_renderBinding(
|
||||
context,
|
||||
element.startBinding,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
element.startBinding?.mode === "orbit" ? "red" : "black",
|
||||
);
|
||||
}
|
||||
|
||||
if (element.endBinding) {
|
||||
if (
|
||||
!elementsMap
|
||||
.get(element.endBinding.elementId)
|
||||
?.boundElements?.find((e) => e.id === element.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
_renderBinding(
|
||||
context,
|
||||
element.endBinding,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
element.endBinding?.mode === "orbit" ? "red" : "black",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isBindableElement(element) && element.boundElements?.length) {
|
||||
element.boundElements.forEach((boundElement) => {
|
||||
if (boundElement.type !== "arrow") {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrow = elementsMap.get(
|
||||
boundElement.id,
|
||||
) as ExcalidrawArrowElement;
|
||||
|
||||
if (arrow && arrow.startBinding?.elementId === element.id) {
|
||||
_renderBindableBinding(
|
||||
arrow.startBinding,
|
||||
context,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
"green",
|
||||
);
|
||||
}
|
||||
if (arrow && arrow.endBinding?.elementId === element.id) {
|
||||
_renderBindableBinding(
|
||||
arrow.endBinding,
|
||||
context,
|
||||
elementsMap,
|
||||
zoom,
|
||||
dim,
|
||||
dim,
|
||||
"green",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const render = (
|
||||
frame: DebugElement[],
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -313,14 +96,6 @@ const render = (
|
||||
el.color,
|
||||
);
|
||||
break;
|
||||
case isPolygon(el.data):
|
||||
renderPolygon(
|
||||
context,
|
||||
appState.zoom.value,
|
||||
el.data as GlobalPoint[],
|
||||
el.color,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown element type ${JSON.stringify(el)}`);
|
||||
}
|
||||
@@ -330,14 +105,18 @@ const render = (
|
||||
const _debugRenderer = (
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||
canvas,
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
@@ -354,7 +133,6 @@ const _debugRenderer = (
|
||||
);
|
||||
|
||||
renderOrigin(context, appState.zoom.value);
|
||||
renderBindings(context, elements, appState.zoom.value);
|
||||
|
||||
if (
|
||||
window.visualDebug?.currentFrame &&
|
||||
@@ -406,10 +184,10 @@ export const debugRenderer = throttleRAF(
|
||||
(
|
||||
canvas: HTMLCanvasElement,
|
||||
appState: AppState,
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
scale: number,
|
||||
refresh: () => void,
|
||||
) => {
|
||||
_debugRenderer(canvas, appState, elements, scale);
|
||||
_debugRenderer(canvas, appState, scale, refresh);
|
||||
},
|
||||
{ trailing: true },
|
||||
);
|
||||
@@ -536,29 +314,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
ref?: React.Ref<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={ref}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||
ref,
|
||||
() => canvasRef.current,
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={canvasRef}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugCanvas;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||
|
||||
export const ExcalidrawPlusAppLink = () => {
|
||||
if (!isExcalidrawPlusSignedUser) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="plus-button"
|
||||
>
|
||||
Go to Excalidraw+
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
export const ExcalidrawPlusPromoBanner = ({
|
||||
isSignedIn,
|
||||
}: {
|
||||
isSignedIn: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={
|
||||
isSignedIn
|
||||
? import.meta.env.VITE_APP_PLUS_APP
|
||||
: `${
|
||||
import.meta.env.VITE_APP_PLUS_LP
|
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=guestBanner#excalidraw-redirect`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="plus-banner"
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_SIDEBAR,
|
||||
debounce,
|
||||
} from "@excalidraw/common";
|
||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
||||
import {
|
||||
createStore,
|
||||
entries,
|
||||
@@ -26,9 +27,6 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
|
||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||
@@ -47,8 +45,6 @@ import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
export const localStorageQuotaExceededAtom = atom(false);
|
||||
|
||||
class LocalFileManager extends FileManager {
|
||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||
await entries(filesStore).then((entries) => {
|
||||
@@ -73,9 +69,6 @@ const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||
localStorageQuotaExceededAtom,
|
||||
);
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
@@ -88,29 +81,19 @@ const saveDataStateToLocalStorage = (
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(getNonDeletedElements(elements)),
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
if (localStorageQuotaExceeded) {
|
||||
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
|
||||
appJotaiStore.set(localStorageQuotaExceededAtom, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isQuotaExceededError = (error: any) => {
|
||||
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||
};
|
||||
|
||||
type SavingLockTypes = "collaboration";
|
||||
|
||||
export class LocalData {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { reconcileElements } from "@excalidraw/excalidraw";
|
||||
import { MIME_TYPES, toBrandedType } from "@excalidraw/common";
|
||||
import { MIME_TYPES } from "@excalidraw/common";
|
||||
import { decompressData } from "@excalidraw/excalidraw/data/encode";
|
||||
import {
|
||||
encryptData,
|
||||
@@ -105,8 +105,8 @@ const decryptElements = async (
|
||||
data: FirebaseStoredScene,
|
||||
roomKey: string,
|
||||
): Promise<readonly ExcalidrawElement[]> => {
|
||||
const ciphertext = data.ciphertext.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
const iv = data.iv.toUint8Array() as Uint8Array<ArrayBuffer>;
|
||||
const ciphertext = data.ciphertext.toUint8Array();
|
||||
const iv = data.iv.toUint8Array();
|
||||
|
||||
const decrypted = await decryptData(iv, ciphertext, roomKey);
|
||||
const decodedData = new TextDecoder("utf-8").decode(
|
||||
@@ -243,7 +243,7 @@ export const saveToFirebase = async (
|
||||
|
||||
FirebaseSceneVersionCache.set(socket, storedElements);
|
||||
|
||||
return toBrandedType<RemoteExcalidrawElement[]>(storedElements);
|
||||
return storedElements;
|
||||
};
|
||||
|
||||
export const loadFromFirebase = async (
|
||||
@@ -259,9 +259,7 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IV_LENGTH_BYTES,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
|
||||
import { restore } from "@excalidraw/excalidraw/data/restore";
|
||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
import { isInitializedImageElement } from "@excalidraw/element";
|
||||
import { t } from "@excalidraw/excalidraw/i18n";
|
||||
@@ -83,13 +84,13 @@ export type SocketUpdateDataSource = {
|
||||
SCENE_INIT: {
|
||||
type: WS_SUBTYPES.INIT;
|
||||
payload: {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
SCENE_UPDATE: {
|
||||
type: WS_SUBTYPES.UPDATE;
|
||||
payload: {
|
||||
elements: readonly OrderedExcalidrawElement[];
|
||||
elements: readonly ExcalidrawElement[];
|
||||
};
|
||||
};
|
||||
MOUSE_LOCATION: {
|
||||
@@ -199,7 +200,7 @@ const legacy_decodeFromBackend = async ({
|
||||
};
|
||||
};
|
||||
|
||||
export const importFromBackend = async (
|
||||
const importFromBackend = async (
|
||||
id: string,
|
||||
decryptionKey: string,
|
||||
): Promise<ImportedDataState> => {
|
||||
@@ -241,6 +242,40 @@ export const importFromBackend = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const loadScene = async (
|
||||
id: string | null,
|
||||
privateKey: string | null,
|
||||
// Supply local state even if importing from backend to ensure we restore
|
||||
// localStorage user settings which we do not persist on server.
|
||||
// Non-optional so we don't forget to pass it even if `undefined`.
|
||||
localDataState: ImportedDataState | undefined | null,
|
||||
) => {
|
||||
let data;
|
||||
if (id != null && privateKey != null) {
|
||||
// the private key is used to decrypt the content from the server, take
|
||||
// extra care not to leak it
|
||||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements: data.elements,
|
||||
appState: data.appState,
|
||||
// note: this will always be empty because we're not storing files
|
||||
// in the scene database/localStorage, and instead fetch them async
|
||||
// from a different database
|
||||
files: data.files,
|
||||
};
|
||||
};
|
||||
|
||||
type ExportToBackendResult =
|
||||
| { url: null; errorMessage: string }
|
||||
| { url: string; errorMessage: null };
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
clearAppStateForLocalStorage,
|
||||
getDefaultAppState,
|
||||
} from "@excalidraw/excalidraw/appState";
|
||||
import { clearElementsForLocalStorage } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
@@ -49,7 +50,7 @@ export const importFromLocalStorage = () => {
|
||||
let elements: ExcalidrawElement[] = [];
|
||||
if (savedElements) {
|
||||
try {
|
||||
elements = JSON.parse(savedElements);
|
||||
elements = clearElementsForLocalStorage(JSON.parse(savedElements));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
// Do nothing because elements array is already empty
|
||||
|
||||
+20
-47
@@ -24,35 +24,34 @@ export class Debug {
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static LAST_FRAME_TIMESTAMP = 0;
|
||||
private static FRAME_COUNT = 0;
|
||||
private static ANIMATION_FRAME_ID: null | number = null;
|
||||
|
||||
private static scheduleAnimationFrame = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||
Debug.ANIMATION_FRAME_ID = requestAnimationFrame((timestamp) => {
|
||||
if (Debug.LAST_FRAME_TIMESTAMP !== timestamp) {
|
||||
Debug.LAST_FRAME_TIMESTAMP = timestamp;
|
||||
Debug.FRAME_COUNT++;
|
||||
}
|
||||
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID !== null) {
|
||||
Debug.scheduleAnimationFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
Debug.scheduleAnimationFrame();
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
@@ -67,15 +66,7 @@ export class Debug {
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(
|
||||
name,
|
||||
`${times.length} runs: ${avgFrameTime}ms across ${
|
||||
Debug.FRAME_COUNT
|
||||
} frames (${getFps(avgFrameTime)} fps ~ ${lessPrecise(
|
||||
(avgFrameTime / 16.67) * 100,
|
||||
1,
|
||||
)}% of frame budget)`,
|
||||
);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
@@ -85,24 +76,6 @@ export class Debug {
|
||||
}
|
||||
}
|
||||
}
|
||||
Debug.FRAME_COUNT = 0;
|
||||
|
||||
// Check for stop condition after logging
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
window.cancelAnimationFrame(Debug.ANIMATION_FRAME_ID!);
|
||||
Debug.ANIMATION_FRAME_ID = null;
|
||||
Debug.FRAME_COUNT = 0;
|
||||
Debug.LAST_FRAME_TIMESTAMP = 0;
|
||||
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw Whiteboard</title>
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
+14
-27
@@ -1,5 +1,3 @@
|
||||
@import "../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
--color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
|
||||
|
||||
@@ -7,6 +5,12 @@
|
||||
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
|
||||
}
|
||||
|
||||
.top-right-ui {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
@@ -54,7 +58,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
.collab-offline-warning {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
@@ -65,18 +69,10 @@
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
z-index: 6;
|
||||
white-space: pre;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: var(--color-danger-dark);
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,31 +82,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.plus-banner {
|
||||
.plus-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 0.5rem 0.875rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background-color: var(--island-bg-color);
|
||||
color: var(--color-primary) !important;
|
||||
text-decoration: none !important;
|
||||
|
||||
font-family: var(--ui-font);
|
||||
font-size: 0.8333rem;
|
||||
font-size: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
height: var(--lg-button-size);
|
||||
|
||||
border: none;
|
||||
box-shadow: 0 0 0 1px var(--color-surface-lowest);
|
||||
background-color: var(--color-surface-low);
|
||||
color: var(--button-color, var(--color-on-surface)) !important;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: white !important;
|
||||
@@ -122,7 +109,7 @@
|
||||
}
|
||||
|
||||
.theme--dark {
|
||||
.plus-banner {
|
||||
.plus-button {
|
||||
&:hover {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/random-username": "1.0.0",
|
||||
@@ -36,7 +36,6 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"socket.io-client": "4.7.2",
|
||||
"uqr": "0.1.2",
|
||||
"vite-plugin-html": "3.2.2"
|
||||
},
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getFeatureFlag } from "@excalidraw/common";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import callsites from "callsites";
|
||||
|
||||
@@ -34,7 +33,6 @@ Sentry.init({
|
||||
Sentry.captureConsoleIntegration({
|
||||
levels: ["error"],
|
||||
}),
|
||||
Sentry.featureFlagsIntegration(),
|
||||
],
|
||||
beforeSend(event) {
|
||||
if (event.request?.url) {
|
||||
@@ -81,14 +79,3 @@ Sentry.init({
|
||||
return event;
|
||||
},
|
||||
});
|
||||
|
||||
const flagsIntegration =
|
||||
Sentry.getClient()?.getIntegrationByName<Sentry.FeatureFlagsIntegration>(
|
||||
"FeatureFlags",
|
||||
);
|
||||
if (flagsIntegration) {
|
||||
flagsIntegration.addFeatureFlag(
|
||||
"COMPLEX_BINDINGS",
|
||||
getFeatureFlag("COMPLEX_BINDINGS"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Spinner from "@excalidraw/excalidraw/components/Spinner";
|
||||
|
||||
interface QRCodeProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const QRCode = ({ value }: QRCodeProps) => {
|
||||
const [svgData, setSvgData] = useState<string | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
import("./qrcode.chunk")
|
||||
.then(({ generateQRCodeSVG }) => {
|
||||
if (mounted) {
|
||||
try {
|
||||
setSvgData(generateQRCodeSVG(value));
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!svgData) {
|
||||
return (
|
||||
<div className="ShareDialog__active__qrcode ShareDialog__active__qrcode--loading">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ShareDialog__active__qrcode"
|
||||
role="img"
|
||||
aria-label="QR code for collaboration link"
|
||||
dangerouslySetInnerHTML={{ __html: svgData }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -140,31 +140,6 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
$size: 150px;
|
||||
width: $size;
|
||||
height: $size;
|
||||
|
||||
& svg {
|
||||
width: $size;
|
||||
height: $size;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
background: var(--island-bg-color);
|
||||
border: 1px solid var(--dialog-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
import { QRCode } from "./QRCode";
|
||||
|
||||
import type { CollabAPI } from "../collab/Collab";
|
||||
|
||||
@@ -143,7 +142,6 @@ const ActiveRoomDialog = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<QRCode value={activeRoomLink} />
|
||||
<div className="ShareDialog__active__description">
|
||||
<p>
|
||||
<span
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { renderSVG } from "uqr";
|
||||
|
||||
export const generateQRCodeSVG = (text: string): string => {
|
||||
return renderSVG(text);
|
||||
};
|
||||
@@ -17,15 +17,30 @@ describe("Test MobileMenu", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<ExcalidrawApp />);
|
||||
h.app.refreshEditorInterface();
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should set editor interface correctly", () => {
|
||||
expect(h.app.editorInterface.formFactor).toBe("phone");
|
||||
it("should set device correctly", () => {
|
||||
expect(h.app.device).toMatchInlineSnapshot(`
|
||||
{
|
||||
"editor": {
|
||||
"canFitSidebar": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
"isTouchScreen": false,
|
||||
"viewport": {
|
||||
"isLandscape": false,
|
||||
"isMobile": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should initialize with welcome screen and hide once user interacts", async () => {
|
||||
|
||||
@@ -205,7 +205,6 @@ describe("collaboration", () => {
|
||||
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false }),
|
||||
@@ -248,5 +247,79 @@ describe("collaboration", () => {
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// simulate local update
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([
|
||||
h.elements[0],
|
||||
newElementWith(h.elements[1], { x: 100 }),
|
||||
]),
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
// we expect to iterate the stack to the first visible change
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
// simulate force deleting the element remotely
|
||||
API.updateScene({
|
||||
elements: syncInvalidIndices([rect1]),
|
||||
captureUpdate: CaptureUpdateAction.NEVER,
|
||||
});
|
||||
|
||||
// snapshot was correctly updated and marked the element as deleted
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining(rect1Props),
|
||||
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
|
||||
]);
|
||||
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
|
||||
});
|
||||
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
// with explicit redo (as update) we again restored the element from the snapshot!
|
||||
await waitFor(() => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(API.getSnapshot()).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A", isDeleted: false }),
|
||||
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-12
@@ -34,7 +34,7 @@
|
||||
"prettier": "2.6.2",
|
||||
"rewire": "6.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.0.12",
|
||||
"vite-plugin-checker": "0.7.2",
|
||||
"vite-plugin-ejs": "1.7.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
"vitest-canvas-mock": "0.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": "18.0.0 - 22.x.x"
|
||||
},
|
||||
"homepage": ".",
|
||||
"prettier": "@excalidraw/prettier-config",
|
||||
@@ -52,17 +52,13 @@
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
|
||||
"build:app": "yarn --cwd ./excalidraw-app build:app",
|
||||
"build:common": "yarn --cwd ./packages/common build:esm",
|
||||
"build:element": "yarn --cwd ./packages/element build:esm",
|
||||
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:math": "yarn --cwd ./packages/math build:esm",
|
||||
"build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
|
||||
"build:package": "yarn --cwd ./packages/excalidraw build:esm",
|
||||
"build:version": "yarn --cwd ./excalidraw-app build:version",
|
||||
"build": "yarn --cwd ./excalidraw-app build",
|
||||
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
|
||||
"start": "yarn --cwd ./excalidraw-app start",
|
||||
"start:production": "yarn --cwd ./excalidraw-app start:production",
|
||||
"start:example": "yarn build:packages && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"start:example": "yarn build:package && yarn --cwd ./examples/with-script-in-browser start",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watch=false",
|
||||
"test:app": "vitest",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
@@ -80,10 +76,9 @@
|
||||
"locales-coverage:description": "node scripts/locales-coverage-description.js",
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"release": "node scripts/release.js",
|
||||
"release:test": "node scripts/release.js --tag=test",
|
||||
"release:next": "node scripts/release.js --tag=next",
|
||||
"release:latest": "node scripts/release.js --tag=latest",
|
||||
"autorelease": "node scripts/autorelease.js",
|
||||
"prerelease:excalidraw": "node scripts/prerelease.js",
|
||||
"release:excalidraw": "node scripts/release.js",
|
||||
"rm:build": "rimraf --glob excalidraw-app/build excalidraw-app/dist excalidraw-app/dev-dist packages/*/dist packages/*/build examples/*/build examples/*/dist",
|
||||
"rm:node_modules": "rimraf --glob node_modules excalidraw-app/node_modules packages/*/node_modules",
|
||||
"clean-install": "yarn rm:node_modules && yarn install"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/common",
|
||||
"version": "0.18.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/common/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,10 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/common/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
"types": "./dist/types/common/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -55,11 +52,5 @@
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"tinycolor2": "1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tinycolor2": "1.4.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`applyDarkModeFilter > COLOR_PALETTE regression tests > matches snapshot for all palette colors 1`] = `
|
||||
{
|
||||
"black": "#d3d3d3",
|
||||
"blue": [
|
||||
"#121e26",
|
||||
"#154162",
|
||||
"#2273b4",
|
||||
"#3791e0",
|
||||
"#56a2e8",
|
||||
],
|
||||
"bronze": [
|
||||
"#221c1a",
|
||||
"#362b26",
|
||||
"#5a463d",
|
||||
"#917569",
|
||||
"#a98d84",
|
||||
],
|
||||
"cyan": [
|
||||
"#0a1e20",
|
||||
"#004149",
|
||||
"#007281",
|
||||
"#0f8fa1",
|
||||
"#3da5b6",
|
||||
],
|
||||
"grape": [
|
||||
"#211a25",
|
||||
"#5b3165",
|
||||
"#a954be",
|
||||
"#d471ed",
|
||||
"#e28af8",
|
||||
],
|
||||
"gray": [
|
||||
"#161718",
|
||||
"#202325",
|
||||
"#33383d",
|
||||
"#6e757c",
|
||||
"#b7bcc1",
|
||||
],
|
||||
"green": [
|
||||
"#0f1d12",
|
||||
"#043b0c",
|
||||
"#056715",
|
||||
"#16842a",
|
||||
"#39994b",
|
||||
],
|
||||
"orange": [
|
||||
"#22190d",
|
||||
"#4c2b01",
|
||||
"#924800",
|
||||
"#cd6005",
|
||||
"#f17634",
|
||||
],
|
||||
"pink": [
|
||||
"#26191e",
|
||||
"#602e40",
|
||||
"#b04d70",
|
||||
"#f56e9d",
|
||||
"#ff8dbc",
|
||||
],
|
||||
"red": [
|
||||
"#1f1717",
|
||||
"#5a2c2c",
|
||||
"#b44d4d",
|
||||
"#fa6969",
|
||||
"#ff8383",
|
||||
],
|
||||
"teal": [
|
||||
"#0a1d17",
|
||||
"#00422b",
|
||||
"#00744b",
|
||||
"#039267",
|
||||
"#32a783",
|
||||
],
|
||||
"transparent": "#ededed00",
|
||||
"violet": [
|
||||
"#1f1c29",
|
||||
"#4a3b72",
|
||||
"#8a6cdf",
|
||||
"#a885ff",
|
||||
"#b595ff",
|
||||
],
|
||||
"white": "#121212",
|
||||
"yellow": [
|
||||
"#1e1900",
|
||||
"#362600",
|
||||
"#5f3a00",
|
||||
"#905000",
|
||||
"#b86200",
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -5,18 +5,17 @@ export class BinaryHeap<T> {
|
||||
|
||||
sinkDown(idx: number) {
|
||||
const node = this.content[idx];
|
||||
const nodeScore = this.scoreFunction(node);
|
||||
while (idx > 0) {
|
||||
const parentN = ((idx + 1) >> 1) - 1;
|
||||
const parent = this.content[parentN];
|
||||
if (nodeScore < this.scoreFunction(parent)) {
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
@@ -25,39 +24,35 @@ export class BinaryHeap<T> {
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child1N = ((idx + 1) << 1) - 1;
|
||||
const child2N = child1N + 1;
|
||||
let smallestIdx = idx;
|
||||
let smallestScore = score;
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
|
||||
// Check left child
|
||||
if (child1N < length) {
|
||||
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||
if (child1Score < smallestScore) {
|
||||
smallestIdx = child1N;
|
||||
smallestScore = child1Score;
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
}
|
||||
}
|
||||
|
||||
// Check right child
|
||||
if (child2N < length) {
|
||||
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||
if (child2Score < smallestScore) {
|
||||
smallestIdx = child2N;
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (smallestIdx === idx) {
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
// Move the smaller child up, continue finding position for node
|
||||
this.content[idx] = this.content[smallestIdx];
|
||||
idx = smallestIdx;
|
||||
}
|
||||
|
||||
// Place node in its final position
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
push(node: T) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* x and y position of top left corner, x and y position of bottom right corner
|
||||
*/
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
box.length === 4 &&
|
||||
typeof box[0] === "number" &&
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
@@ -1,280 +0,0 @@
|
||||
import {
|
||||
applyDarkModeFilter,
|
||||
COLOR_PALETTE,
|
||||
rgbToHex,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
describe("applyDarkModeFilter", () => {
|
||||
describe("basic transformations", () => {
|
||||
it("transforms black to near-white", () => {
|
||||
const result = applyDarkModeFilter("#000000");
|
||||
// Black inverted 93% + hue rotate should be near white/light gray
|
||||
expect(result).toBe("#ededed");
|
||||
});
|
||||
|
||||
it("transforms white to near-black", () => {
|
||||
const result = applyDarkModeFilter("#ffffff");
|
||||
// White inverted 93% should be near black/dark gray
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms pure red", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Invert 93% + hue rotate 180deg produces a cyan-ish tint
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("transforms pure green", () => {
|
||||
const result = applyDarkModeFilter("#00ff00");
|
||||
// Invert 93% + hue rotate 180deg
|
||||
expect(result).toBe("#008f00");
|
||||
});
|
||||
|
||||
it("transforms pure blue", () => {
|
||||
const result = applyDarkModeFilter("#0000ff");
|
||||
// Invert 93% + hue rotate 180deg produces a light purple
|
||||
expect(result).toBe("#cdcdff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("color formats", () => {
|
||||
it("handles hex with hash", () => {
|
||||
const result = applyDarkModeFilter("#ff0000");
|
||||
// Fully opaque colors return 6-char hex
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
});
|
||||
|
||||
it("handles named colors", () => {
|
||||
const result = applyDarkModeFilter("red");
|
||||
// "red" = #ff0000, fully opaque
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgb format", () => {
|
||||
const result = applyDarkModeFilter("rgb(255, 0, 0)");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("handles rgba format and preserves alpha", () => {
|
||||
const result = applyDarkModeFilter("rgba(255, 0, 0, 0.5)");
|
||||
expect(result).toMatch(/^#[0-9a-f]{8}$/);
|
||||
// Alpha 0.5 = 128 in hex = 80
|
||||
expect(result).toBe("#ff909080");
|
||||
});
|
||||
|
||||
it("handles transparent", () => {
|
||||
const result = applyDarkModeFilter("transparent");
|
||||
// transparent = rgba(0,0,0,0), inverted should still have 0 alpha
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
it("handles shorthand hex", () => {
|
||||
const result = applyDarkModeFilter("#f00");
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha preservation", () => {
|
||||
it("omits alpha for full opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff0000ff");
|
||||
// Full opacity returns 6-char hex (no alpha suffix)
|
||||
expect(result).toBe("#ff9090");
|
||||
});
|
||||
|
||||
it("preserves 50% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000080");
|
||||
expect(result.slice(-2)).toBe("80");
|
||||
});
|
||||
|
||||
it("preserves 0% opacity", () => {
|
||||
const result = applyDarkModeFilter("#ff000000");
|
||||
expect(result.slice(-2)).toBe("00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("COLOR_PALETTE regression tests", () => {
|
||||
it("transforms black from palette", () => {
|
||||
// COLOR_PALETTE.black is #1e1e1e (not pure black)
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
expect(result).toBe("#d3d3d3");
|
||||
});
|
||||
|
||||
it("transforms white from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
expect(result).toBe("#121212");
|
||||
});
|
||||
|
||||
it("transforms transparent from palette", () => {
|
||||
const result = applyDarkModeFilter(COLOR_PALETTE.transparent);
|
||||
expect(result).toBe("#ededed00");
|
||||
});
|
||||
|
||||
// Test each color family from the palette (all opaque, so 6-char hex)
|
||||
describe("red shades", () => {
|
||||
const redShades = COLOR_PALETTE.red;
|
||||
it.each(redShades.map((color, i) => [color, i]))(
|
||||
"transforms red shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("blue shades", () => {
|
||||
const blueShades = COLOR_PALETTE.blue;
|
||||
it.each(blueShades.map((color, i) => [color, i]))(
|
||||
"transforms blue shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("green shades", () => {
|
||||
const greenShades = COLOR_PALETTE.green;
|
||||
it.each(greenShades.map((color, i) => [color, i]))(
|
||||
"transforms green shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("gray shades", () => {
|
||||
const grayShades = COLOR_PALETTE.gray;
|
||||
it.each(grayShades.map((color, i) => [color, i]))(
|
||||
"transforms gray shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("bronze shades", () => {
|
||||
const bronzeShades = COLOR_PALETTE.bronze;
|
||||
it.each(bronzeShades.map((color, i) => [color, i]))(
|
||||
"transforms bronze shade %s (index %d)",
|
||||
(color) => {
|
||||
const result = applyDarkModeFilter(color as string);
|
||||
expect(result).toMatch(/^#[0-9a-f]{6}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Snapshot test for full palette to catch any regressions
|
||||
it("matches snapshot for all palette colors", () => {
|
||||
const transformedPalette: Record<string, string | string[]> = {};
|
||||
|
||||
transformedPalette.black = applyDarkModeFilter(COLOR_PALETTE.black);
|
||||
transformedPalette.white = applyDarkModeFilter(COLOR_PALETTE.white);
|
||||
transformedPalette.transparent = applyDarkModeFilter(
|
||||
COLOR_PALETTE.transparent,
|
||||
);
|
||||
|
||||
// Transform color arrays
|
||||
for (const colorName of [
|
||||
"gray",
|
||||
"red",
|
||||
"pink",
|
||||
"grape",
|
||||
"violet",
|
||||
"blue",
|
||||
"cyan",
|
||||
"teal",
|
||||
"green",
|
||||
"yellow",
|
||||
"orange",
|
||||
"bronze",
|
||||
] as const) {
|
||||
const shades = COLOR_PALETTE[colorName];
|
||||
transformedPalette[colorName] = shades.map((shade) =>
|
||||
applyDarkModeFilter(shade),
|
||||
);
|
||||
}
|
||||
|
||||
expect(transformedPalette).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
it("returns same result for same input (cached)", () => {
|
||||
const result1 = applyDarkModeFilter("#ff0000");
|
||||
const result2 = applyDarkModeFilter("#ff0000");
|
||||
expect(result1).toBe(result2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("rgbToHex", () => {
|
||||
describe("basic RGB conversion", () => {
|
||||
it("converts black (0,0,0)", () => {
|
||||
expect(rgbToHex(0, 0, 0)).toBe("#000000");
|
||||
});
|
||||
|
||||
it("converts white (255,255,255)", () => {
|
||||
expect(rgbToHex(255, 255, 255)).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("converts red (255,0,0)", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("converts green (0,255,0)", () => {
|
||||
expect(rgbToHex(0, 255, 0)).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("converts blue (0,0,255)", () => {
|
||||
expect(rgbToHex(0, 0, 255)).toBe("#0000ff");
|
||||
});
|
||||
|
||||
it("converts arbitrary color", () => {
|
||||
expect(rgbToHex(30, 30, 30)).toBe("#1e1e1e");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leading zeros preservation", () => {
|
||||
it("preserves leading zeros for low values", () => {
|
||||
expect(rgbToHex(0, 0, 1)).toBe("#000001");
|
||||
expect(rgbToHex(0, 1, 0)).toBe("#000100");
|
||||
expect(rgbToHex(1, 0, 0)).toBe("#010000");
|
||||
});
|
||||
|
||||
it("preserves zeros for single-digit hex values", () => {
|
||||
expect(rgbToHex(15, 15, 15)).toBe("#0f0f0f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("alpha handling", () => {
|
||||
it("omits alpha when undefined", () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe("#ff0000");
|
||||
expect(rgbToHex(255, 0, 0, undefined)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("omits alpha when fully opaque (1)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 1)).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("includes alpha for semi-transparent (0.5)", () => {
|
||||
// 0.5 * 255 = 127.5 -> rounds to 128 = 0x80
|
||||
expect(rgbToHex(255, 0, 0, 0.5)).toBe("#ff000080");
|
||||
});
|
||||
|
||||
it("includes alpha for fully transparent (0)", () => {
|
||||
expect(rgbToHex(255, 0, 0, 0)).toBe("#ff000000");
|
||||
});
|
||||
|
||||
it("includes alpha for near-opaque (0.99)", () => {
|
||||
// 0.99 * 255 = 252.45 -> rounds to 252 = 0xfc
|
||||
expect(rgbToHex(255, 0, 0, 0.99)).toBe("#ff0000fc");
|
||||
});
|
||||
|
||||
it("pads alpha with leading zero when needed", () => {
|
||||
// 0.05 * 255 = 12.75 -> rounds to 13 = 0x0d
|
||||
expect(rgbToHex(255, 0, 0, 0.05)).toBe("#ff00000d");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +1,7 @@
|
||||
import oc from "open-color";
|
||||
import tinycolor from "tinycolor2";
|
||||
|
||||
import { clamp } from "@excalidraw/math";
|
||||
import { degreesToRadians } from "@excalidraw/math";
|
||||
|
||||
import type { Degrees } from "@excalidraw/math";
|
||||
|
||||
import type { Merge } from "./utility-types";
|
||||
|
||||
export { tinycolor };
|
||||
|
||||
// Browser-only cache to avoid memory leaks on server
|
||||
const DARK_MODE_COLORS_CACHE: Map<string, string> | null =
|
||||
typeof window !== "undefined" ? new Map() : null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark mode color transformation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cssHueRotate(
|
||||
red: number,
|
||||
green: number,
|
||||
blue: number,
|
||||
degrees: Degrees,
|
||||
): { r: number; g: number; b: number } {
|
||||
// normalize
|
||||
const r = red / 255;
|
||||
const g = green / 255;
|
||||
const b = blue / 255;
|
||||
|
||||
// Convert degrees to radians
|
||||
const a = degreesToRadians(degrees);
|
||||
|
||||
const c = Math.cos(a);
|
||||
const s = Math.sin(a);
|
||||
|
||||
// rotation matrix
|
||||
const matrix = [
|
||||
0.213 + c * 0.787 - s * 0.213,
|
||||
0.715 - c * 0.715 - s * 0.715,
|
||||
0.072 - c * 0.072 + s * 0.928,
|
||||
0.213 - c * 0.213 + s * 0.143,
|
||||
0.715 + c * 0.285 + s * 0.14,
|
||||
0.072 - c * 0.072 - s * 0.283,
|
||||
0.213 - c * 0.213 - s * 0.787,
|
||||
0.715 - c * 0.715 + s * 0.715,
|
||||
0.072 + c * 0.928 + s * 0.072,
|
||||
];
|
||||
|
||||
// transform
|
||||
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
|
||||
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
|
||||
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
|
||||
|
||||
// clamp the values to [0, 1] range and convert back to [0, 255]
|
||||
return {
|
||||
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
|
||||
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
|
||||
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
const cssInvert = (
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
percent: number,
|
||||
): { r: number; g: number; b: number } => {
|
||||
const p = clamp(percent, 0, 100) / 100;
|
||||
|
||||
// Function to invert a single color component
|
||||
const invertComponent = (color: number): number => {
|
||||
// Apply the invert formula
|
||||
const inverted = color * (1 - p) + (255 - color) * p;
|
||||
// Round to the nearest integer and clamp to [0, 255]
|
||||
return Math.round(clamp(inverted, 0, 255));
|
||||
};
|
||||
|
||||
// Calculate the inverted RGB components
|
||||
const invertedR = invertComponent(r);
|
||||
const invertedG = invertComponent(g);
|
||||
const invertedB = invertComponent(b);
|
||||
|
||||
return { r: invertedR, g: invertedG, b: invertedB };
|
||||
};
|
||||
|
||||
export const applyDarkModeFilter = (color: string): string => {
|
||||
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const tc = tinycolor(color);
|
||||
const alpha = tc.getAlpha();
|
||||
|
||||
// order of operations matters
|
||||
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
|
||||
const rgb = tc.toRgb();
|
||||
const inverted = cssInvert(rgb.r, rgb.g, rgb.b, 93);
|
||||
const rotated = cssHueRotate(
|
||||
inverted.r,
|
||||
inverted.g,
|
||||
inverted.b,
|
||||
180 as Degrees,
|
||||
);
|
||||
|
||||
const result = rgbToHex(rotated.r, rotated.g, rotated.b, alpha);
|
||||
|
||||
if (DARK_MODE_COLORS_CACHE) {
|
||||
DARK_MODE_COLORS_CACHE.set(color, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const COLOR_OUTLINE_CONTRAST_THRESHOLD = 240;
|
||||
|
||||
// FIXME can't put to utils.ts rn because of circular dependency
|
||||
@@ -281,22 +167,7 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) =>
|
||||
COLOR_PALETTE.red[index],
|
||||
] as const;
|
||||
|
||||
export const rgbToHex = (r: number, g: number, b: number, a?: number) => {
|
||||
// (1 << 24) adds 0x1000000 to ensure the hex string is always 7 chars,
|
||||
// then slice(1) removes the leading "1" to get exactly 6 hex digits
|
||||
// e.g. rgb(0,0,0) -> 0x1000000 -> "1000000" -> "000000"
|
||||
const hex6 = `#${((1 << 24) + (r << 16) + (g << 8) + b)
|
||||
.toString(16)
|
||||
.slice(1)}`;
|
||||
if (a !== undefined && a < 1) {
|
||||
// convert alpha from 0-1 float to 0-255 int, then to 2-digit hex
|
||||
// e.g. 0.5 -> 128 -> "80"
|
||||
const alphaHex = Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${hex6}${alphaHex}`;
|
||||
}
|
||||
return hex6;
|
||||
};
|
||||
export const rgbToHex = (r: number, g: number, b: number) =>
|
||||
`#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,25 @@ import type { AppProps, AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -17,7 +36,6 @@ export const APP_NAME = "Excalidraw";
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
@@ -99,22 +117,12 @@ export const ENV = {
|
||||
};
|
||||
|
||||
export const CLASSES = {
|
||||
SIDEBAR: "sidebar",
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||
FRAME_NAME: "frame-name",
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 28,
|
||||
xl: 36,
|
||||
} as const;
|
||||
|
||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
@@ -139,49 +147,19 @@ export const FONT_FAMILY = {
|
||||
Assistant: 10,
|
||||
};
|
||||
|
||||
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
|
||||
// so we need to have generic font fallback before it
|
||||
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
|
||||
export const MONOSPACE_GENERIC_FONT = "monospace";
|
||||
|
||||
export const FONT_FAMILY_GENERIC_FALLBACKS = {
|
||||
[SANS_SERIF_GENERIC_FONT]: 998,
|
||||
[MONOSPACE_GENERIC_FONT]: 999,
|
||||
};
|
||||
|
||||
export const FONT_FAMILY_FALLBACKS = {
|
||||
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
|
||||
...FONT_FAMILY_GENERIC_FALLBACKS,
|
||||
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
|
||||
};
|
||||
|
||||
export function getGenericFontFamilyFallback(
|
||||
fontFamily: number,
|
||||
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Cascadia:
|
||||
case FONT_FAMILY["Comic Shanns"]:
|
||||
return MONOSPACE_GENERIC_FONT;
|
||||
|
||||
default:
|
||||
return SANS_SERIF_GENERIC_FONT;
|
||||
}
|
||||
}
|
||||
|
||||
export const getFontFamilyFallbacks = (
|
||||
fontFamily: number,
|
||||
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
|
||||
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
|
||||
|
||||
switch (fontFamily) {
|
||||
case FONT_FAMILY.Excalifont:
|
||||
return [
|
||||
CJK_HAND_DRAWN_FALLBACK_FONT,
|
||||
genericFallbackFont,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
];
|
||||
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
default:
|
||||
return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
return [WINDOWS_EMOJI_FALLBACK_FONT];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -243,20 +221,13 @@ export const IMAGE_MIME_TYPES = {
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const STRING_MIME_TYPES = {
|
||||
export const MIME_TYPES = {
|
||||
text: "text/plain",
|
||||
html: "text/html",
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
// LEGACY: fully-qualified library JSON data
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
// list of excalidraw library item ids
|
||||
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||
} as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
...STRING_MIME_TYPES,
|
||||
// image-encoded excalidraw data
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
"excalidraw.png": "image/png",
|
||||
@@ -305,6 +276,9 @@ export const IDLE_THRESHOLD = 60_000;
|
||||
// Report a user active each ACTIVE_THRESHOLD milliseconds
|
||||
export const ACTIVE_THRESHOLD = 3_000;
|
||||
|
||||
// duplicates --theme-filter, should be removed soon
|
||||
export const THEME_FILTER = "invert(93%) hue-rotate(180deg)";
|
||||
|
||||
export const URL_QUERY_KEYS = {
|
||||
addLibrary: "addLibrary",
|
||||
} as const;
|
||||
@@ -328,6 +302,16 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
},
|
||||
};
|
||||
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
|
||||
|
||||
export const EXPORT_SCALES = [1, 2, 3];
|
||||
@@ -493,19 +477,3 @@ export enum UserIdleState {
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
/**
|
||||
* distance at which we merge points instead of adding a new merge-point
|
||||
* when converting a line to a polygon (merge currently means overlaping
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||
|
||||
export const BIND_MODE_TIMEOUT = 700; // ms
|
||||
|
||||
// glass background for mobile action buttons
|
||||
export const MOBILE_ACTION_BUTTON_BG = {
|
||||
background: "var(--mobile-action-button-bg)",
|
||||
} as const;
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
export type StylesPanelMode = "compact" | "full" | "mobile";
|
||||
|
||||
export type EditorInterface = Readonly<{
|
||||
formFactor: "phone" | "tablet" | "desktop";
|
||||
desktopUIMode: "compact" | "full";
|
||||
userAgent: Readonly<{
|
||||
isMobileDevice: boolean;
|
||||
platform: "ios" | "android" | "other" | "unknown";
|
||||
}>;
|
||||
isTouchScreen: boolean;
|
||||
canFitSidebar: boolean;
|
||||
isLandscape: boolean;
|
||||
}>;
|
||||
|
||||
// storage key
|
||||
const DESKTOP_UI_MODE_STORAGE_KEY = "excalidraw.desktopUIMode";
|
||||
|
||||
// breakpoints
|
||||
// mobile: up to 699px
|
||||
export const MQ_MAX_MOBILE = 599;
|
||||
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||
|
||||
// desktop/laptop
|
||||
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// user agent detections
|
||||
export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
export const isWindows = /^Win/.test(navigator.platform);
|
||||
export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
|
||||
export const isFirefox =
|
||||
typeof window !== "undefined" &&
|
||||
"netscape" in window &&
|
||||
navigator.userAgent.indexOf("rv:") > 1 &&
|
||||
navigator.userAgent.indexOf("Gecko") > 1;
|
||||
export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
// export const isMobile =
|
||||
// isIOS ||
|
||||
// /android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
// navigator.userAgent,
|
||||
// ) ||
|
||||
// /android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||
|
||||
// utilities
|
||||
export const isMobileBreakpoint = (width: number, height: number) => {
|
||||
return (
|
||||
width <= MQ_MAX_MOBILE ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||
);
|
||||
};
|
||||
|
||||
export const isTabletBreakpoint = (
|
||||
editorWidth: number,
|
||||
editorHeight: number,
|
||||
) => {
|
||||
const minSide = Math.min(editorWidth, editorHeight);
|
||||
const maxSide = Math.max(editorWidth, editorHeight);
|
||||
|
||||
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||
};
|
||||
|
||||
const isMobileOrTablet = (): boolean => {
|
||||
const ua = navigator.userAgent || "";
|
||||
const platform = navigator.platform || "";
|
||||
const uaData = (navigator as any).userAgentData as
|
||||
| { mobile?: boolean; platform?: string }
|
||||
| undefined;
|
||||
|
||||
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||
if (uaData) {
|
||||
const plat = (uaData.platform || "").toLowerCase();
|
||||
const isDesktopOS =
|
||||
plat === "windows" ||
|
||||
plat === "macos" ||
|
||||
plat === "linux" ||
|
||||
plat === "chrome os";
|
||||
if (uaData.mobile === true) {
|
||||
return true;
|
||||
}
|
||||
if (uaData.mobile === false && plat === "android") {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
if (isDesktopOS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) ios (includes ipad) --------------------------------------------
|
||||
if (isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 3) android legacy ua fallback -------------------------------------
|
||||
if (isAndroid) {
|
||||
const isAndroidPhone = /Mobile/i.test(ua);
|
||||
const isAndroidTablet = !isAndroidPhone;
|
||||
if (isAndroidPhone || isAndroidTablet) {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4) last resort desktop exclusion ----------------------------------
|
||||
const looksDesktopPlatform =
|
||||
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||
if (looksDesktopPlatform) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFormFactor = (
|
||||
editorWidth: number,
|
||||
editorHeight: number,
|
||||
): EditorInterface["formFactor"] => {
|
||||
if (isMobileBreakpoint(editorWidth, editorHeight)) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
if (isTabletBreakpoint(editorWidth, editorHeight)) {
|
||||
return "tablet";
|
||||
}
|
||||
|
||||
return "desktop";
|
||||
};
|
||||
|
||||
export const deriveStylesPanelMode = (
|
||||
editorInterface: EditorInterface,
|
||||
): StylesPanelMode => {
|
||||
if (editorInterface.formFactor === "phone") {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
if (editorInterface.formFactor === "tablet") {
|
||||
return "compact";
|
||||
}
|
||||
|
||||
return editorInterface.desktopUIMode;
|
||||
};
|
||||
|
||||
export const createUserAgentDescriptor = (
|
||||
userAgentString: string,
|
||||
): EditorInterface["userAgent"] => {
|
||||
const normalizedUA = userAgentString ?? "";
|
||||
let platform: EditorInterface["userAgent"]["platform"] = "unknown";
|
||||
|
||||
if (isIOS) {
|
||||
platform = "ios";
|
||||
} else if (isAndroid) {
|
||||
platform = "android";
|
||||
} else if (normalizedUA) {
|
||||
platform = "other";
|
||||
}
|
||||
|
||||
return {
|
||||
isMobileDevice: isMobileOrTablet(),
|
||||
platform,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const loadDesktopUIModePreference = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DESKTOP_UI_MODE_STORAGE_KEY);
|
||||
if (stored === "compact" || stored === "full") {
|
||||
return stored as EditorInterface["desktopUIMode"];
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore storage access issues (e.g., Safari private mode)
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const persistDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(DESKTOP_UI_MODE_STORAGE_KEY, mode);
|
||||
} catch (error) {
|
||||
// ignore storage access issues (e.g., Safari private mode)
|
||||
}
|
||||
};
|
||||
|
||||
export const setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => {
|
||||
if (mode !== "compact" && mode !== "full") {
|
||||
return;
|
||||
}
|
||||
|
||||
persistDesktopUIMode(mode);
|
||||
|
||||
return mode;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./binary-heap";
|
||||
export * from "./bounds";
|
||||
export * from "./colors";
|
||||
export * from "./constants";
|
||||
export * from "./font-metadata";
|
||||
@@ -11,5 +10,3 @@ export * from "./random";
|
||||
export * from "./url";
|
||||
export * from "./utils";
|
||||
export * from "./emitter";
|
||||
export * from "./visualdebug";
|
||||
export * from "./editorInterface";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isDarwin } from "./editorInterface";
|
||||
import { isDarwin } from "./constants";
|
||||
|
||||
import type { ValueOf } from "./utility-types";
|
||||
|
||||
|
||||
+67
-108
@@ -1,6 +1,11 @@
|
||||
import { average } from "@excalidraw/math";
|
||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
ExcalidrawElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import type {
|
||||
ActiveTool,
|
||||
@@ -10,12 +15,13 @@ import type {
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { tinycolor } from "./colors";
|
||||
import { COLOR_PALETTE } from "./colors";
|
||||
import {
|
||||
DEFAULT_VERSION,
|
||||
ENV,
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
|
||||
@@ -86,8 +92,7 @@ export const isWritableElement = (
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password" ||
|
||||
target.type === "search"));
|
||||
target.type === "password"));
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -96,6 +101,7 @@ export const getFontFamilyString = ({
|
||||
}) => {
|
||||
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
|
||||
if (id === fontFamily) {
|
||||
// TODO: we should fallback first to generic family names first
|
||||
return `${fontFamilyString}${getFontFamilyFallbacks(id)
|
||||
.map((x) => `, ${x}`)
|
||||
.join("")}`;
|
||||
@@ -115,11 +121,6 @@ export const getFontString = ({
|
||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||
};
|
||||
|
||||
/** executes callback in the frame that's after the current one */
|
||||
export const nextAnimationFrame = async (cb: () => any) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||
};
|
||||
|
||||
export const debounce = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
timeout: number,
|
||||
@@ -378,10 +379,6 @@ export const removeSelection = () => {
|
||||
|
||||
export const distance = (x: number, y: number) => Math.abs(x - y);
|
||||
|
||||
export const isSelectionLikeTool = (type: ToolType | "custom") => {
|
||||
return type === "selection" || type === "lasso";
|
||||
};
|
||||
|
||||
export const updateActiveTool = (
|
||||
appState: Pick<AppState, "activeTool">,
|
||||
data: ((
|
||||
@@ -423,6 +420,19 @@ export const allowFullScreen = () =>
|
||||
|
||||
export const exitFullScreen = () => document.exitFullscreen();
|
||||
|
||||
export const getShortcutKey = (shortcut: string): string => {
|
||||
shortcut = shortcut
|
||||
.replace(/\bAlt\b/i, "Alt")
|
||||
.replace(/\bShift\b/i, "Shift")
|
||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
||||
if (isDarwin) {
|
||||
return shortcut
|
||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
||||
.replace(/\bAlt\b/i, "Option");
|
||||
}
|
||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
||||
};
|
||||
|
||||
export const viewportCoordsToSceneCoords = (
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
{
|
||||
@@ -549,9 +559,18 @@ export const mapFind = <T, K>(
|
||||
};
|
||||
|
||||
export const isTransparent = (color: string) => {
|
||||
return tinycolor(color).getAlpha() === 0;
|
||||
const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
|
||||
const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
|
||||
return (
|
||||
isRGBTransparent ||
|
||||
isRRGGBBTransparent ||
|
||||
color === COLOR_PALETTE.transparent
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
|
||||
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
|
||||
|
||||
export type ResolvablePromise<T> = Promise<T> & {
|
||||
resolve: [T] extends [undefined]
|
||||
? (value?: MaybePromise<Awaited<T>>) => void
|
||||
@@ -693,8 +712,8 @@ export const arrayToObject = <T>(
|
||||
array: readonly T[],
|
||||
groupBy?: (value: T) => string | number,
|
||||
) =>
|
||||
array.reduce((acc, value, idx) => {
|
||||
acc[groupBy ? groupBy(value) : idx] = value;
|
||||
array.reduce((acc, value) => {
|
||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
||||
return acc;
|
||||
}, {} as { [key: string]: T });
|
||||
|
||||
@@ -1151,69 +1170,39 @@ export const normalizeEOL = (str: string) => {
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
export type HasBrand<T> = {
|
||||
type HasBrand<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T]: K extends `~brand${infer _}` | "_brand" ? true : never;
|
||||
[K in keyof T]: K extends `~brand${infer _}` ? true : never;
|
||||
}[keyof T];
|
||||
|
||||
type RemoveAllBrands<T> = HasBrand<T> extends true
|
||||
? {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[K in keyof T as K extends `~brand~${infer _}` | "_brand"
|
||||
? never
|
||||
: K]: T[K];
|
||||
[K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K];
|
||||
}
|
||||
: T;
|
||||
: never;
|
||||
|
||||
// For accepting values - uses loose matching for branded types
|
||||
// Preserves readonly modifier: mutable array requires mutable input
|
||||
type UnbrandForValue<T> = T extends Map<infer E, infer F>
|
||||
? Map<UnbrandForValue<E>, UnbrandForValue<F>>
|
||||
// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940
|
||||
// currently does not cover all types (e.g. tuples, promises...)
|
||||
type Unbrand<T> = T extends Map<infer E, infer F>
|
||||
? Map<E, F>
|
||||
: T extends Set<infer E>
|
||||
? Set<UnbrandForValue<E>>
|
||||
: T extends readonly any[]
|
||||
? T extends any[]
|
||||
? unknown[] // mutable array - require mutable input
|
||||
: readonly unknown[] // readonly array - accept readonly input
|
||||
? Set<E>
|
||||
: T extends Array<infer E>
|
||||
? Array<E>
|
||||
: RemoveAllBrands<T>;
|
||||
|
||||
// For return types - preserves array element unbranding
|
||||
export type Unbrand<T> = T extends Map<infer E, infer F>
|
||||
? Map<Unbrand<E>, Unbrand<F>>
|
||||
: T extends Set<infer E>
|
||||
? Set<Unbrand<E>>
|
||||
: T extends readonly (infer E)[]
|
||||
? Array<Unbrand<E>>
|
||||
: RemoveAllBrands<T>;
|
||||
|
||||
export type CombineBrands<BrandedType, CurrentType> =
|
||||
BrandedType extends readonly (infer BE)[]
|
||||
? CurrentType extends readonly (infer CE)[]
|
||||
? Array<CE & BE>
|
||||
: CurrentType & BrandedType
|
||||
: CurrentType & BrandedType;
|
||||
|
||||
export type CombineBrandsIfNeeded<T, Required> = [T] extends [Required]
|
||||
? T[]
|
||||
: HasBrand<T> extends true
|
||||
? CombineBrands<T, Required>[]
|
||||
: Required[];
|
||||
|
||||
/**
|
||||
* Makes type into a branded type, ensuring that value is assignable to
|
||||
* the base unbranded type. Optionally you can explicitly supply current value
|
||||
* the base ubranded type. Optionally you can explicitly supply current value
|
||||
* type to combine both (useful for composite branded types. Make sure you
|
||||
* compose branded types which are not composite themselves.)
|
||||
*/
|
||||
export function toBrandedType<BrandedType>(
|
||||
value: UnbrandForValue<BrandedType>,
|
||||
): BrandedType;
|
||||
export function toBrandedType<BrandedType, CurrentType>(
|
||||
value: CurrentType,
|
||||
): CombineBrands<BrandedType, CurrentType>;
|
||||
export function toBrandedType(value: unknown) {
|
||||
return value;
|
||||
}
|
||||
export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
|
||||
value: Unbrand<BrandedType>,
|
||||
) => {
|
||||
return value as CurrentType & BrandedType;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -1249,6 +1238,20 @@ export const escapeDoubleQuotes = (str: string) => {
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
const { x, y, width, height } = element;
|
||||
|
||||
const centerXPoint = x + width / 2 + xOffset;
|
||||
|
||||
const centerYPoint = y + height / 2 + yOffset;
|
||||
|
||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
||||
};
|
||||
|
||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||
return Array.isArray(value);
|
||||
@@ -1291,47 +1294,3 @@ export const reduceToCommonValue = <T, R = T>(
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
|
||||
type FEATURE_FLAGS = {
|
||||
COMPLEX_BINDINGS: boolean;
|
||||
};
|
||||
|
||||
const FEATURE_FLAGS_STORAGE_KEY = "excalidraw-feature-flags";
|
||||
const DEFAULT_FEATURE_FLAGS: FEATURE_FLAGS = {
|
||||
COMPLEX_BINDINGS: false,
|
||||
};
|
||||
let featureFlags: FEATURE_FLAGS | null = null;
|
||||
|
||||
export const getFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
flag: F,
|
||||
): FEATURE_FLAGS[F] => {
|
||||
if (!featureFlags) {
|
||||
try {
|
||||
const serializedFlags = localStorage.getItem(FEATURE_FLAGS_STORAGE_KEY);
|
||||
if (serializedFlags) {
|
||||
const flags = JSON.parse(serializedFlags);
|
||||
featureFlags = flags ?? DEFAULT_FEATURE_FLAGS;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (featureFlags || DEFAULT_FEATURE_FLAGS)[flag];
|
||||
};
|
||||
|
||||
export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
|
||||
flag: F,
|
||||
value: FEATURE_FLAGS[F],
|
||||
) => {
|
||||
try {
|
||||
featureFlags = {
|
||||
...(featureFlags || DEFAULT_FEATURE_FLAGS),
|
||||
[flag]: value,
|
||||
};
|
||||
localStorage.setItem(
|
||||
FEATURE_FLAGS_STORAGE_KEY,
|
||||
JSON.stringify(featureFlags),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("unable to set feature flag", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/element",
|
||||
"version": "0.18.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/element/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
@@ -13,10 +13,7 @@
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/element/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
"types": "./dist/types/element/src/*.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
@@ -55,9 +52,5 @@
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,14 +164,9 @@ export class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements, options);
|
||||
this.replaceAllElements(elements);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,19 +263,12 @@ export class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
// 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 nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
validateIndicesThrottled(_nextElements);
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
|
||||
@@ -1,69 +1,26 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
getClosedCurveShape,
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPolygonShape,
|
||||
} from "@excalidraw/utils/shape";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
THEME,
|
||||
isTransparent,
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
applyDarkModeFilter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
SVGPathString,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import {
|
||||
canBecomePolygon,
|
||||
isElbowArrow,
|
||||
isEmbeddableElement,
|
||||
isIframeElement,
|
||||
isIframeLikeElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import {
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
import { shouldTestInside } from "./collision";
|
||||
import { generateFreeDrawOvoidSvgPath } from "./freedraw";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -71,103 +28,12 @@ import type {
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
// Toggle between old (perfect-freehand) and new (ovoid-union) freedraw rendering
|
||||
// Set to true to use the new ovoid-based implementation
|
||||
export const USE_NEW_FREEDRAW_RENDERER = true;
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ shape: ElementShape; theme: AppState["theme"] }
|
||||
>();
|
||||
|
||||
/**
|
||||
* 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,
|
||||
theme: AppState["theme"] | null,
|
||||
) => {
|
||||
const cached = ShapeCache.cache.get(element);
|
||||
if (cached && (theme === null || cached.theme === theme)) {
|
||||
return cached.shape as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public static delete = (element: ExcalidrawElement) => {
|
||||
ShapeCache.cache.delete(element);
|
||||
elementWithCanvasCache.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;
|
||||
theme: AppState["theme"];
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element, renderConfig ? renderConfig.theme : null);
|
||||
|
||||
// `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,
|
||||
theme: THEME.LIGHT,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
if (!renderConfig?.isExporting) {
|
||||
ShapeCache.cache.set(element, {
|
||||
shape,
|
||||
theme: renderConfig?.theme || THEME.LIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
||||
|
||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||
|
||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||
@@ -198,7 +64,6 @@ function adjustRoughness(element: ExcalidrawElement): number {
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
isDarkMode: boolean = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
@@ -223,9 +88,7 @@ export const generateRoughOptions = (
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
stroke: element.strokeColor,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
@@ -239,8 +102,6 @@ export const generateRoughOptions = (
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
@@ -254,8 +115,6 @@ export const generateRoughOptions = (
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
@@ -309,7 +168,6 @@ const getArrowheadShapes = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
canvasBackgroundColor: string,
|
||||
isDarkMode: boolean,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
@@ -335,10 +193,6 @@ const getArrowheadShapes = (
|
||||
return [generator.line(x3, y3, x4, y4, options)];
|
||||
};
|
||||
|
||||
const strokeColor = isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
|
||||
switch (arrowhead) {
|
||||
case "dot":
|
||||
case "circle":
|
||||
@@ -354,10 +208,10 @@ const getArrowheadShapes = (
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
: element.strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: strokeColor,
|
||||
stroke: element.strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
@@ -382,7 +236,7 @@ const getArrowheadShapes = (
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
@@ -410,7 +264,7 @@ const getArrowheadShapes = (
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: strokeColor,
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
@@ -449,182 +303,6 @@ 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.
|
||||
*
|
||||
@@ -632,22 +310,19 @@ export const generateLinearCollisionShape = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
const _generateElementShape = (
|
||||
export const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
isExporting,
|
||||
canvasBackgroundColor,
|
||||
embedsValidationStatus,
|
||||
theme,
|
||||
}: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: string;
|
||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||
theme?: AppState["theme"];
|
||||
},
|
||||
): ElementShape => {
|
||||
const isDarkMode = theme === THEME.DARK;
|
||||
): Drawable | Drawable[] | null => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
@@ -673,7 +348,6 @@ const _generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
true,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -689,7 +363,6 @@ const _generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
false,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -727,7 +400,7 @@ const _generateElementShape = (
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
generateRoughOptions(element, true),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
@@ -737,7 +410,7 @@ const _generateElementShape = (
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element, false, isDarkMode),
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
@@ -748,14 +421,14 @@ const _generateElementShape = (
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element, false, isDarkMode),
|
||||
generateRoughOptions(element),
|
||||
);
|
||||
return shape;
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
const options = generateRoughOptions(element, false, isDarkMode);
|
||||
const options = generateRoughOptions(element);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
@@ -780,7 +453,7 @@ const _generateElementShape = (
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
generateRoughOptions(element, true),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -813,7 +486,6 @@ const _generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -831,7 +503,6 @@ const _generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -839,32 +510,23 @@ const _generateElementShape = (
|
||||
return shape;
|
||||
}
|
||||
case "freedraw": {
|
||||
// oredered in terms of z-index [background, stroke]
|
||||
const shapes: ElementShapes[typeof element.type] = [];
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
generateFreeDrawShape(element);
|
||||
|
||||
// (1) background fill (rc shape), optional
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shapes.push(
|
||||
generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element, false, isDarkMode),
|
||||
stroke: "none",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// (2) stroke - use new ovoid renderer or legacy perfect-freehand
|
||||
if (USE_NEW_FREEDRAW_RENDERER) {
|
||||
shapes.push(generateFreeDrawOvoidSvgPath(element) as SVGPathString);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shapes.push(getFreeDrawSvgPath(element));
|
||||
shape = null;
|
||||
}
|
||||
|
||||
return shapes;
|
||||
return shape;
|
||||
}
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
@@ -949,167 +611,3 @@ const generateElbowArrowShape = (
|
||||
|
||||
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.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;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// freedraw shape helper
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// NOTE not cached (-> for SVG export)
|
||||
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
|
||||
return getSvgPathFromStroke(
|
||||
getFreedrawOutlinePoints(element),
|
||||
) as SVGPathString;
|
||||
};
|
||||
|
||||
export const getFreedrawOutlinePoints = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
) => {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
return getStroke(inputPoints as number[][], {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: true,
|
||||
}) as [number, number][];
|
||||
};
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
const getSvgPathFromStroke = (points: number[][]): string => {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -0,0 +1,95 @@
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
|
||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
EmbedsValidationStatus,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { _generateElementShape } from "./Shape";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
||||
|
||||
import type { Drawable } from "roughjs/bin/core";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
ShapeCache.cache.delete(element);
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates & caches shape for element if not already cached, otherwise
|
||||
* returns cached shape.
|
||||
*/
|
||||
public static generateElementShape = <
|
||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
>(
|
||||
element: T,
|
||||
renderConfig: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
if (cachedShape !== undefined) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
|
||||
return shape;
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -18,12 +16,11 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
selectedElements,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
elementsMap,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
|
||||
+1225
-1773
File diff suppressed because it is too large
Load Diff
+31
-164
@@ -2,7 +2,6 @@ import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
type Bounds,
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
@@ -34,8 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { generateRoughOptions } from "./shape";
|
||||
import { ShapeCache } from "./shape";
|
||||
import { generateRoughOptions } from "./Shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import {
|
||||
@@ -43,11 +42,10 @@ import {
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import { getElementShape } from "./shape";
|
||||
import { getElementShape } from "./shapes";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
@@ -79,6 +77,16 @@ export type RectangleBox = {
|
||||
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
/**
|
||||
* x and y position of top left corner, x and y position of bottom right corner
|
||||
*/
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export type SceneBounds = readonly [
|
||||
sceneX: number,
|
||||
sceneY: number,
|
||||
@@ -94,23 +102,9 @@ export class ElementBounds {
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
private static nonRotatedBoundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
) {
|
||||
const cachedBounds =
|
||||
nonRotated && element.angle !== 0
|
||||
? ElementBounds.nonRotatedBoundsCache.get(element)
|
||||
: ElementBounds.boundsCache.get(element);
|
||||
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (
|
||||
cachedBounds?.version &&
|
||||
@@ -121,23 +115,6 @@ export class ElementBounds {
|
||||
) {
|
||||
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);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
@@ -313,42 +290,19 @@ export const getElementLineSegments = (
|
||||
|
||||
if (shape.type === "polycurve") {
|
||||
const curves = shape.data;
|
||||
const pointsOnCurves = curves.map((curve) =>
|
||||
pointsOnBezierCurves(curve, 10),
|
||||
);
|
||||
|
||||
const points = curves
|
||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
||||
.flat();
|
||||
let i = 0;
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
|
||||
if (
|
||||
(isLineElement(element) && !element.polygon) ||
|
||||
isArrowElement(element)
|
||||
) {
|
||||
for (const points of pointsOnCurves) {
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const points = pointsOnCurves.flat();
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
@@ -599,7 +553,7 @@ const solveQuadratic = (
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
export const getCubicBezierCurveBound = (
|
||||
const getCubicBezierCurveBound = (
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
@@ -897,7 +851,6 @@ export const getArrowheadPoints = (
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
// TODO reuse shape.ts
|
||||
const generateLinearElementShape = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Drawable => {
|
||||
@@ -955,7 +908,7 @@ const getLinearElementRotatedBounds = (
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = ShapeCache.get(element, null)?.[0];
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
@@ -986,9 +939,8 @@ const getLinearElementRotatedBounds = (
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
nonRotated: boolean = false,
|
||||
): Bounds => {
|
||||
return ElementBounds.getBounds(element, elementsMap, nonRotated);
|
||||
return ElementBounds.getBounds(element, elementsMap);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
@@ -1142,9 +1094,7 @@ export interface BoundingBox {
|
||||
}
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements:
|
||||
| readonly ExcalidrawElement[]
|
||||
| readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
@@ -1183,71 +1133,6 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
||||
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 = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1261,21 +1146,3 @@ export const doBoundsIntersect = (
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
if (isLinearElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
}
|
||||
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
};
|
||||
|
||||
+129
-557
@@ -1,77 +1,53 @@
|
||||
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
|
||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
ellipse,
|
||||
ellipseSegmentInterceptPoints,
|
||||
ellipseLineIntersectionPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
||||
|
||||
import type {
|
||||
Curve,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Polygon,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
||||
import { getElementBounds } from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
@@ -96,68 +72,45 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs = {
|
||||
point: GlobalPoint;
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
element: ExcalidrawElement;
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
overrideShouldTestInside?: boolean;
|
||||
};
|
||||
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
threshold,
|
||||
elementsMap,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
overrideShouldTestInside = false,
|
||||
}: HitTestArgs) => {
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||
point,
|
||||
pointFrom(
|
||||
frameNameBound.x + frameNameBound.width + threshold,
|
||||
frameNameBound.y + frameNameBound.height + threshold,
|
||||
),
|
||||
)
|
||||
: false;
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||
if (!hitBounds && !hitFrameName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = (
|
||||
overrideShouldTestInside ? true : shouldTestInside(element)
|
||||
)
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = 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);
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
|
||||
return hitElement || hitFrameName;
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
}
|
||||
|
||||
return hit;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
point: GlobalPoint,
|
||||
x: number,
|
||||
y: number,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
@@ -167,152 +120,37 @@ export const hitElementBoundingBox = (
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
hitArgs: HitTestArgs,
|
||||
elementsMap: ElementsMap,
|
||||
) =>
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||
|
||||
export const hitElementBoundText = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (!boundTextElementCandidate) {
|
||||
return false;
|
||||
}
|
||||
const boundTextElement = isLinearElement(element)
|
||||
? {
|
||||
...boundTextElementCandidate,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElementCandidate,
|
||||
elementsMap,
|
||||
),
|
||||
}
|
||||
: boundTextElementCandidate;
|
||||
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
if (element.frameId) {
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
const enclosingFrameBounds = getElementBounds(
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
return isPointWithinBounds(
|
||||
pointFrom(x1, y1),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, toleranceFn?.(element))
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateElements;
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
toleranceFn?: (element: ExcalidrawBindableElement) => number,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
toleranceFn,
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(
|
||||
hitArgs.x,
|
||||
hitArgs.y,
|
||||
getBoundTextShape(hitArgs.element, elementsMap),
|
||||
) &&
|
||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
||||
);
|
||||
};
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -325,26 +163,9 @@ export const getHoveredElementForBinding = (
|
||||
*/
|
||||
export const intersectElementWithLineSegment = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
line: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// First check if the line intersects the element's axis-aligned bounding box
|
||||
// as it is much faster than checking intersection against the element's shape
|
||||
const intersectorBounds = [
|
||||
Math.min(line[0][0] - offset, line[1][0] - offset),
|
||||
Math.min(line[0][1] - offset, line[1][1] - offset),
|
||||
Math.max(line[0][0] + offset, line[1][0] + offset),
|
||||
Math.max(line[0][1] + offset, line[1][1] + offset),
|
||||
] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Do the actual intersection test against the element's shape
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
@@ -352,196 +173,67 @@ export const intersectElementWithLineSegment = (
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "selection":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
onlyFirst,
|
||||
);
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
line,
|
||||
offset,
|
||||
);
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
}
|
||||
};
|
||||
|
||||
const curveIntersections = (
|
||||
curves: Curve<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
for (const j of hits) {
|
||||
intersections.push(pointRotateRads(j, center, angle));
|
||||
}
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const lineIntersections = (
|
||||
lines: LineSegment<GlobalPoint>[],
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
intersections: GlobalPoint[],
|
||||
center: GlobalPoint,
|
||||
angle: Radians,
|
||||
onlyFirst = false,
|
||||
) => {
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(pointRotateRads(intersection, center, angle));
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||
if (intersection) {
|
||||
intersections.push(intersection);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of curves) {
|
||||
// Optimize by doing a cheap bounding box check first
|
||||
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||
const b2 = [
|
||||
Math.min(segment[0][0], segment[1][0]),
|
||||
Math.min(segment[0][1], segment[1][1]),
|
||||
Math.max(segment[0][0], segment[1][0]),
|
||||
Math.max(segment[0][1], segment[1][1]),
|
||||
] as Bounds;
|
||||
|
||||
if (!doBoundsIntersect(b1, b2)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
|
||||
if (onlyFirst) {
|
||||
return intersections;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
segment[0],
|
||||
l[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
segment[1],
|
||||
l[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
return (
|
||||
// Test intersection against the sides, keep only the valid
|
||||
// intersection points and rotate them back to scene space
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -553,45 +245,43 @@ const intersectRectanguloidWithLineSegment = (
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||
|
||||
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
||||
|
||||
lineIntersections(
|
||||
sides,
|
||||
rotatedIntersector,
|
||||
intersections,
|
||||
center,
|
||||
element.angle,
|
||||
onlyFirst,
|
||||
return (
|
||||
sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -603,134 +293,16 @@ const intersectDiamondWithLineSegment = (
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseSegmentInterceptPoints(
|
||||
return ellipseLineIntersectionPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
lineSegment(rotatedA, rotatedB),
|
||||
line(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given point is considered on the given shape's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @param tolerance
|
||||
* @returns
|
||||
*/
|
||||
const isPointOnElementOutline = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 1,
|
||||
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
||||
|
||||
/**
|
||||
* Check if the given point is considered inside the element's border
|
||||
*
|
||||
* @param point
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
export const isPointInElement = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (
|
||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||
!isPathALoop(element.points)
|
||||
) {
|
||||
// There isn't any "inside" for a non-looping path
|
||||
return false;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
|
||||
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const otherPoint = pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
||||
Math.max(element.width, element.height) * 2,
|
||||
),
|
||||
center,
|
||||
);
|
||||
const intersector = lineSegment(point, otherPoint);
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
intersector,
|
||||
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
||||
export const isBindableElementInsideOtherBindable = (
|
||||
innerElement: ExcalidrawBindableElement,
|
||||
outerElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
// Get corner points of the inner element based on its type
|
||||
const getCornerPoints = (
|
||||
element: ExcalidrawElement,
|
||||
offset: number,
|
||||
): GlobalPoint[] => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
// Diamond has 4 corner points at the middle of each side
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x + topX, y + topY - offset), // top
|
||||
pointFrom(x + rightX + offset, y + rightY), // right
|
||||
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||
pointFrom(x + leftX - offset, y + leftY), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
if (element.type === "ellipse") {
|
||||
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const rx = width / 2;
|
||||
const ry = height / 2;
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(cx, cy - ry - offset), // top
|
||||
pointFrom(cx + rx + offset, cy), // right
|
||||
pointFrom(cx, cy + ry + offset), // bottom
|
||||
pointFrom(cx - rx - offset, cy), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x - offset, y - offset), // top-left
|
||||
pointFrom(x + width + offset, y - offset), // top-right
|
||||
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
};
|
||||
|
||||
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||
const innerCorners = getCornerPoints(innerElement, offset);
|
||||
|
||||
// Check if all corner points of the inner element are inside the outer element
|
||||
return innerCorners.every((corner) =>
|
||||
isPointInElement(corner, outerElement, elementsMap),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,14 +10,7 @@ export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "text" ||
|
||||
type === "embeddable";
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -33,7 +34,6 @@ export const MINIMAL_CROP_SIZE = 10;
|
||||
|
||||
export const cropElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandle: TransformHandleType,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
@@ -63,7 +63,7 @@ export const cropElement = (
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
elementCenterPoint(element, elementsMap),
|
||||
elementCenterPoint(element),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
|
||||
+207
-601
File diff suppressed because it is too large
Load Diff
@@ -6,33 +6,27 @@ import {
|
||||
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructLinearOrFreeDrawElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import { elementCenterPoint } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "selection":
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
@@ -40,15 +34,11 @@ export const distanceToElement = (
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||
return distanceToRectanguloidElement(element, p);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, elementsMap, p);
|
||||
return distanceToDiamondElement(element, p);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, elementsMap, p);
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
return distanceToEllipseElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,10 +52,9 @@ export const distanceToElement = (
|
||||
*/
|
||||
const distanceToRectanguloidElement = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
@@ -91,10 +80,9 @@ const distanceToRectanguloidElement = (
|
||||
*/
|
||||
const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
@@ -120,24 +108,12 @@ const distanceToDiamondElement = (
|
||||
*/
|
||||
const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const center = elementCenterPoint(element);
|
||||
return ellipseDistanceFromPoint(
|
||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||
pointRotateRads(p, center, -element.angle as Radians),
|
||||
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,9 +1,7 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
@@ -16,7 +14,6 @@ export const distributeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -24,11 +21,7 @@ export const distributeElements = (
|
||||
: (["minY", "midY", "maxY", "height"] as const);
|
||||
|
||||
const bounds = getCommonBoundingBox(selectedElements);
|
||||
const groups = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState,
|
||||
)
|
||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
type Bounds,
|
||||
TEXT_AUTOWRAP_THRESHOLD,
|
||||
getGridPoint,
|
||||
getFontString,
|
||||
DRAGGING_THRESHOLD,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -15,7 +13,7 @@ import type {
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
@@ -103,26 +102,9 @@ export const dragSelectedElements = (
|
||||
gridSize,
|
||||
);
|
||||
|
||||
const elementsToUpdateIds = new Set(
|
||||
Array.from(elementsToUpdate, (el) => el.id),
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
const isArrow = !isArrowElement(element);
|
||||
const isStartBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.startBinding
|
||||
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: false);
|
||||
const isEndBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.endBinding
|
||||
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: false);
|
||||
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
if (!isArrowElement(element)) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
// skip arrow labels since we calculate its position during render
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
@@ -139,33 +121,6 @@ export const dragSelectedElements = (
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
} else if (
|
||||
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||
// is the single element being dragged to avoid accidentally unbinding
|
||||
// the arrow when the user just wants to select it.
|
||||
|
||||
elementsToUpdate.size > 1 ||
|
||||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||
DRAGGING_THRESHOLD ||
|
||||
(!element.startBinding && !element.endBinding)
|
||||
) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
const shouldUnbindStart =
|
||||
element.startBinding && !isStartBoundElementSelected;
|
||||
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||
// have weird situations, like 0 lenght arrow when the user moves
|
||||
// the arrow outside a filled shape suddenly forcing the arrow start
|
||||
// and end point to jump "outside" the shape.
|
||||
if (shouldUnbindStart) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
if (shouldUnbindEnd) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,26 +14,25 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
type Bounds,
|
||||
BinaryHeap,
|
||||
invariant,
|
||||
isAnyTrue,
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindPointToSnapToElementOutline,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
getBindingGap,
|
||||
maxBindingDistance_simple,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
snapToMid,
|
||||
getHoveredElementForBinding,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import { distanceToBindableElement } from "./distance";
|
||||
import {
|
||||
compareHeading,
|
||||
flipHeading,
|
||||
@@ -52,9 +51,10 @@ import {
|
||||
type ExcalidrawElbowArrowElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
Arrowhead,
|
||||
@@ -63,7 +63,6 @@ import type {
|
||||
FixedPointBinding,
|
||||
FixedSegment,
|
||||
NonDeletedExcalidrawElement,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||
@@ -360,12 +359,6 @@ const handleSegmentRelease = (
|
||||
null,
|
||||
);
|
||||
|
||||
if (!restoredPoints || restoredPoints.length < 2) {
|
||||
throw new Error(
|
||||
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||
);
|
||||
}
|
||||
|
||||
const nextPoints: GlobalPoint[] = [];
|
||||
|
||||
// First part of the arrow are the old points
|
||||
@@ -713,7 +706,7 @@ const handleEndpointDrag = (
|
||||
endGlobalPoint: GlobalPoint,
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
) => {
|
||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||
@@ -748,15 +741,8 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second point connection and add the start point
|
||||
{
|
||||
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||
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 secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||
const secondIsHorizontal = headingIsHorizontal(
|
||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||
@@ -815,19 +801,10 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second to last point connection
|
||||
{
|
||||
const secondToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||
);
|
||||
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 secondToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||
const thirdToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||
thirdToLastPoint,
|
||||
@@ -921,6 +898,50 @@ export const updateElbowArrowPoints = (
|
||||
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) {
|
||||
invariant(
|
||||
!updates.points || updates.points.length >= 2,
|
||||
@@ -1244,7 +1265,6 @@ const getElbowArrowData = (
|
||||
const startGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
angle: 0,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
@@ -1253,13 +1273,11 @@ const getElbowArrowData = (
|
||||
arrow.startBinding?.fixedPoint,
|
||||
origStartGlobalPoint,
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
angle: 0,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
@@ -1268,7 +1286,6 @@ const getElbowArrowData = (
|
||||
arrow.endBinding?.fixedPoint,
|
||||
origEndGlobalPoint,
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
@@ -1276,16 +1293,12 @@ const getElbowArrowData = (
|
||||
endGlobalPoint,
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
startGlobalPoint,
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1302,12 +1315,11 @@ const getElbowArrowData = (
|
||||
const startElementBounds = hoveredStartElement
|
||||
? aabbForElement(
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1315,12 +1327,11 @@ const getElbowArrowData = (
|
||||
const endElementBounds = hoveredEndElement
|
||||
? aabbForElement(
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1331,7 +1342,6 @@ const getElbowArrowData = (
|
||||
hoveredEndElement
|
||||
? aabbForElement(
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
|
||||
)
|
||||
: endPointBounds,
|
||||
@@ -1341,7 +1351,6 @@ const getElbowArrowData = (
|
||||
hoveredStartElement
|
||||
? aabbForElement(
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
|
||||
)
|
||||
: startPointBounds,
|
||||
@@ -1367,8 +1376,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1383,13 +1392,13 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
|
||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
||||
);
|
||||
const startDonglePosition = getDonglePosition(
|
||||
dynamicAABBs[0],
|
||||
@@ -2098,7 +2107,16 @@ const normalizeArrowElementUpdate = (
|
||||
nextFixedSegments: readonly FixedSegment[] | null,
|
||||
startIsSpecial?: 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 offsetY = global[0][1];
|
||||
let points = global.map((p) =>
|
||||
@@ -2211,28 +2229,35 @@ const getGlobalPoint = (
|
||||
fixedPointRatio: [number, number] | undefined | null,
|
||||
initialPoint: GlobalPoint,
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (element && elementsMap) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
if (element) {
|
||||
const snapPoint = bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return snapToMid(element, snapPoint);
|
||||
}
|
||||
|
||||
return initialPoint;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
return getGlobalFixedPointForBindableElement(
|
||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
||||
fixedPointRatio || [0, 0],
|
||||
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;
|
||||
@@ -2243,8 +2268,6 @@ const getBindPointHeading = (
|
||||
otherPoint: GlobalPoint,
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): Heading =>
|
||||
getHeadingForElbowArrowSnap(
|
||||
p,
|
||||
@@ -2253,8 +2276,7 @@ const getBindPointHeading = (
|
||||
hoveredElement &&
|
||||
aabbForElement(
|
||||
hoveredElement,
|
||||
elementsMap,
|
||||
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
|
||||
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
@@ -2262,21 +2284,21 @@ const getBindPointHeading = (
|
||||
],
|
||||
),
|
||||
origPoint,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
|
||||
const getHoveredElement = (
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
return getHoveredElementForBinding(
|
||||
origPoint,
|
||||
tupleToCoors(origPoint),
|
||||
elements,
|
||||
elementsMap,
|
||||
(element) => maxBindingDistance_simple(zoom),
|
||||
zoom,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
/^(?: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]*$/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
@@ -56,35 +56,6 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<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([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -142,8 +113,7 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const startTime = parseYouTubeTimestamp(originalLink);
|
||||
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PendingExcalidrawElements,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindBindingElement } from "./binding";
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import { aabbForElement } from "./bounds";
|
||||
import { aabbForElement } from "./shapes";
|
||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||
import {
|
||||
isBindableElement,
|
||||
@@ -95,11 +95,10 @@ const getNodeRelatives = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(
|
||||
node,
|
||||
aabbForElement(node, elementsMap),
|
||||
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||
);
|
||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
] as Readonly<GlobalPoint>);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
@@ -446,14 +445,8 @@ const createBindingArrow = (
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
bindBindingElement(
|
||||
bindingArrow,
|
||||
startBindingElement,
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
);
|
||||
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
ExcalidrawElement,
|
||||
FractionalIndex,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
export class InvalidFractionalIndexError extends Error {
|
||||
@@ -162,15 +161,9 @@ export const syncMovedIndices = (
|
||||
|
||||
// try generatating indices, throws on invalid movedElements
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
const elementsCandidates = elements.map((x) => {
|
||||
const elementUpdates = elementsUpdates.get(x);
|
||||
|
||||
if (elementUpdates) {
|
||||
return { ...x, index: elementUpdates.index };
|
||||
}
|
||||
|
||||
return x;
|
||||
});
|
||||
const elementsCandidates = elements.map((x) =>
|
||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
||||
);
|
||||
|
||||
// ensure next indices are valid before mutation, throws on invalid ones
|
||||
validateFractionalIndices(
|
||||
@@ -184,8 +177,8 @@ export const syncMovedIndices = (
|
||||
);
|
||||
|
||||
// split mutation so we don't end up in an incosistent state
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to default sync
|
||||
@@ -196,7 +189,7 @@ export const syncMovedIndices = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@@ -207,32 +200,13 @@ export const syncInvalidIndices = (
|
||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||
|
||||
for (const [element, { index }] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, { index });
|
||||
for (const [element, update] of elementsUpdates) {
|
||||
mutateElement(element, elementsMap, update);
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -1,546 +0,0 @@
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
type LocalPoint,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
lineSegment,
|
||||
type GlobalPoint,
|
||||
type Polygon,
|
||||
polygonFromPoints,
|
||||
} from "@excalidraw/math";
|
||||
import { debugDrawLine, debugDrawPolygon } from "@excalidraw/common";
|
||||
|
||||
import type { ExcalidrawFreeDrawElement } from "./types";
|
||||
|
||||
// Number of segments to approximate each semicircular cap
|
||||
const CAP_SEGMENTS = 20;
|
||||
|
||||
// Minimum radius to avoid degenerate shapes
|
||||
const MIN_RADIUS = 0.05;
|
||||
|
||||
// Pressure to radius multiplier (scaled by strokeWidth)
|
||||
const PRESSURE_RADIUS_MULTIPLIER = 2.0;
|
||||
|
||||
// Minimum distance between points to avoid numerical instability
|
||||
const MIN_POINT_DISTANCE = 0.001;
|
||||
|
||||
// Epsilon for filtering near-duplicate points in polygons
|
||||
const EPSILON = 0.01;
|
||||
|
||||
// Simple union implementation taking advantage of the following facts:
|
||||
// - The ovoids are generated in sequence along the stroke path
|
||||
// - Each ovoid overlaps only with its immediate neighbors
|
||||
// - The ovoids are convex shapes
|
||||
|
||||
// Therefore, we can simply stitch together the outer edges of the ovoids
|
||||
// by taking the first half of the first ovoid and the second half of the last ovoid,
|
||||
// and connecting them with the outer edges of the intermediate ovoids. The overlapping
|
||||
// ovoid caps are always the same radius at the shared points, so they align perfectly.
|
||||
// It should be easy to find the closest point to the side of the previous side segment and
|
||||
// one of the closest points on the next ovoid's start cap.
|
||||
function chainOvoidsIntoSinglePolygon<P extends LocalPoint | GlobalPoint>(
|
||||
records: {
|
||||
polygon: Polygon<P>;
|
||||
firstPoint: P;
|
||||
secondPoint: P;
|
||||
}[],
|
||||
): Polygon<P> | null {
|
||||
if (records.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (records.length === 1) {
|
||||
return records[0].polygon;
|
||||
}
|
||||
|
||||
const capPointCount = CAP_SEGMENTS + 1;
|
||||
|
||||
const isClosedPolygon = (points: P[]) => {
|
||||
if (points.length < 2) {
|
||||
return false;
|
||||
}
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return first[0] === last[0] && first[1] === last[1];
|
||||
};
|
||||
|
||||
const openPolygon = (points: P[]) =>
|
||||
isClosedPolygon(points) ? points.slice(0, -1) : points.slice();
|
||||
|
||||
const distanceSq = (a: P, b: P) => {
|
||||
const dx = a[0] - b[0];
|
||||
const dy = a[1] - b[1];
|
||||
return dx * dx + dy * dy;
|
||||
};
|
||||
|
||||
const distanceToSegmentSq = (p: P, a: P, b: P) => {
|
||||
const abx = b[0] - a[0];
|
||||
const aby = b[1] - a[1];
|
||||
const apx = p[0] - a[0];
|
||||
const apy = p[1] - a[1];
|
||||
const abLenSq = abx * abx + aby * aby;
|
||||
|
||||
if (abLenSq === 0) {
|
||||
return distanceSq(p, a);
|
||||
}
|
||||
|
||||
const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq));
|
||||
const closest = pointFrom<P>(a[0] + abx * t, a[1] + aby * t);
|
||||
return distanceSq(p, closest);
|
||||
};
|
||||
|
||||
const closestIndexToSegment = (points: P[], a: P, b: P) => {
|
||||
let bestIndex = 0;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const dist = distanceToSegmentSq(points[i], a, b);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex;
|
||||
};
|
||||
|
||||
const pushIfDistinct = (points: P[], point: P) => {
|
||||
if (points.length === 0) {
|
||||
points.push(point);
|
||||
return;
|
||||
}
|
||||
if (distanceSq(points[points.length - 1], point) > EPSILON * EPSILON) {
|
||||
points.push(point);
|
||||
}
|
||||
};
|
||||
|
||||
const ovoids = records.map((record) => {
|
||||
const open = openPolygon(record.polygon);
|
||||
|
||||
if (open.length < capPointCount * 2) {
|
||||
return {
|
||||
cap1: open,
|
||||
cap2: [] as P[],
|
||||
p1Right: open[0],
|
||||
p1Left: open[open.length - 1],
|
||||
p2Left: open[0],
|
||||
p2Right: open[open.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
const cap1 = open.slice(0, capPointCount);
|
||||
const cap2 = open.slice(capPointCount, capPointCount * 2);
|
||||
|
||||
return {
|
||||
cap1,
|
||||
cap2,
|
||||
p1Right: cap1[0],
|
||||
p1Left: cap1[cap1.length - 1],
|
||||
p2Left: cap2[0],
|
||||
p2Right: cap2[cap2.length - 1],
|
||||
};
|
||||
});
|
||||
|
||||
const leftChain: P[] = [];
|
||||
const rightChain: P[] = [];
|
||||
|
||||
ovoids[0].cap1.forEach((point) => pushIfDistinct(leftChain, point));
|
||||
pushIfDistinct(rightChain, ovoids[0].p1Right);
|
||||
|
||||
for (let i = 0; i < ovoids.length; i++) {
|
||||
const current = ovoids[i];
|
||||
|
||||
pushIfDistinct(leftChain, current.p2Left);
|
||||
pushIfDistinct(rightChain, current.p2Right);
|
||||
|
||||
if (i + 1 >= ovoids.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = ovoids[i + 1];
|
||||
|
||||
const leftIndex = closestIndexToSegment(
|
||||
next.cap1,
|
||||
current.p1Left,
|
||||
current.p2Left,
|
||||
);
|
||||
|
||||
for (let j = leftIndex; j < next.cap1.length; j++) {
|
||||
pushIfDistinct(leftChain, next.cap1[j]);
|
||||
}
|
||||
|
||||
const rightIndex = closestIndexToSegment(
|
||||
next.cap1,
|
||||
current.p1Right,
|
||||
current.p2Right,
|
||||
);
|
||||
|
||||
for (let j = rightIndex; j >= 0; j--) {
|
||||
pushIfDistinct(rightChain, next.cap1[j]);
|
||||
}
|
||||
}
|
||||
|
||||
const lastOvoid = ovoids[ovoids.length - 1];
|
||||
lastOvoid.cap2.forEach((point) => pushIfDistinct(leftChain, point));
|
||||
|
||||
const rightChainReversed = rightChain.slice(0, -1).reverse();
|
||||
const outline = filterNearDuplicates<P>([
|
||||
...leftChain,
|
||||
...rightChainReversed,
|
||||
]);
|
||||
|
||||
return polygonFromPoints<P>(outline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the radius for a point based on pressure and strokeWidth.
|
||||
* Pressure is typically in [0, 1] range, default to 0.5 if simulating.
|
||||
*/
|
||||
function getRadiusForPressure(pressure: number, strokeWidth: number): number {
|
||||
return Math.max(
|
||||
MIN_RADIUS,
|
||||
pressure * strokeWidth * PRESSURE_RADIUS_MULTIPLIER,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate points along a semicircular arc (dome/cap).
|
||||
* The arc goes from startAngle to endAngle (counterclockwise).
|
||||
*
|
||||
* @param center - Center point of the arc
|
||||
* @param radius - Radius of the arc
|
||||
* @param startAngle - Start angle in radians
|
||||
* @param endAngle - End angle in radians (counterclockwise from start)
|
||||
* @param segments - Number of segments to divide the arc into
|
||||
* @returns Array of points along the arc
|
||||
*/
|
||||
function generateArcPoints<P extends LocalPoint | GlobalPoint>(
|
||||
center: LocalPoint,
|
||||
radius: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
segments: number,
|
||||
): P[] {
|
||||
const points: P[] = [];
|
||||
const angleStep = (endAngle - startAngle) / segments;
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const angle = startAngle + i * angleStep;
|
||||
points.push(
|
||||
pointFrom<P>(
|
||||
center[0] + radius * Math.cos(angle),
|
||||
center[1] + radius * Math.sin(angle),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ovoid shape between two consecutive points.
|
||||
* The ovoid consists of:
|
||||
* - A semicircular cap at the first point (facing away from point 2)
|
||||
* - A semicircular cap at the second point (facing away from point 1)
|
||||
* - Connecting lines (tangent lines between the two circles)
|
||||
*
|
||||
* @param p1 - First point
|
||||
* @param r1 - Radius at first point (from pressure)
|
||||
* @param p2 - Second point
|
||||
* @param r2 - Radius at second point (from pressure)
|
||||
* @param forcePerpendicularCap1 - Force cap1 to be perpendicular (for stroke start)
|
||||
* @param forcePerpendicularCap2 - Force cap2 to be perpendicular (for stroke end)
|
||||
* @returns Array of points forming the ovoid polygon
|
||||
*/
|
||||
function createOvoid<P extends LocalPoint | GlobalPoint>(
|
||||
p1: LocalPoint,
|
||||
r1: number,
|
||||
p2: LocalPoint,
|
||||
r2: number,
|
||||
forcePerpendicularCap1: boolean = false,
|
||||
forcePerpendicularCap2: boolean = false,
|
||||
): Polygon<P> {
|
||||
const dist = pointDistance(p1, p2);
|
||||
|
||||
// If points are too close, create a circle at the midpoint
|
||||
if (dist < MIN_POINT_DISTANCE) {
|
||||
const avgRadius = (r1 + r2) / 2;
|
||||
return polygonFromPoints<P>(
|
||||
generateArcPoints(p1, avgRadius, 0, Math.PI * 2, CAP_SEGMENTS * 2),
|
||||
);
|
||||
}
|
||||
|
||||
// Direction vector from p1 to p2
|
||||
const dirVec = vectorFromPoint(p2, p1);
|
||||
const normalizedDir = vectorNormalize(dirVec);
|
||||
|
||||
// Calculate the angle of the direction vector
|
||||
const baseAngle = Math.atan2(normalizedDir[1], normalizedDir[0]);
|
||||
|
||||
// For connecting the circles with tangent lines when radii differ,
|
||||
// we need to compute the tangent points
|
||||
// When r1 != r2, the tangent lines are not perpendicular to the center line
|
||||
|
||||
// Tangent angle offset (when radii differ)
|
||||
const radiusDiff = r1 - r2;
|
||||
const tangentAngleOffset =
|
||||
dist > Math.abs(radiusDiff) ? Math.asin(radiusDiff / dist) : 0;
|
||||
|
||||
// Compute tangent points on each circle
|
||||
// The perpendicular offset needs to be adjusted for the tangent angle
|
||||
const tangentPerpAngle = baseAngle + Math.PI / 2 + tangentAngleOffset;
|
||||
const purePerpAngle = baseAngle + Math.PI / 2;
|
||||
|
||||
// Cap at p1 (semicircle facing AWAY from p2, i.e., toward baseAngle + PI)
|
||||
// Use perpendicular cap for stroke start, otherwise use tangent-adjusted
|
||||
const p1PerpAngle = forcePerpendicularCap1 ? purePerpAngle : tangentPerpAngle;
|
||||
const p1RightAngle = p1PerpAngle;
|
||||
const p1LeftAngle = p1PerpAngle + Math.PI;
|
||||
const cap1Points = generateArcPoints<P>(
|
||||
p1,
|
||||
r1,
|
||||
p1RightAngle,
|
||||
p1LeftAngle,
|
||||
CAP_SEGMENTS,
|
||||
);
|
||||
|
||||
// Cap at p2 (semicircle facing AWAY from p1, i.e., toward baseAngle)
|
||||
// Use perpendicular cap for stroke end, otherwise use tangent-adjusted
|
||||
const p2PerpAngle = forcePerpendicularCap2 ? purePerpAngle : tangentPerpAngle;
|
||||
const p2LeftAngle = p2PerpAngle + Math.PI;
|
||||
const p2RightAngle = p2LeftAngle + Math.PI;
|
||||
const cap2Points = generateArcPoints<P>(
|
||||
p2,
|
||||
r2,
|
||||
p2LeftAngle,
|
||||
p2RightAngle,
|
||||
CAP_SEGMENTS,
|
||||
);
|
||||
|
||||
// Assemble the ovoid polygon:
|
||||
// cap1 goes around the back of p1, cap2 goes around the front of p2
|
||||
// The arc endpoints naturally connect with the tangent lines
|
||||
const ovoidPoints: P[] = [
|
||||
...cap1Points, // p1's back cap
|
||||
...cap2Points, // p2's front cap
|
||||
];
|
||||
|
||||
// Filter out near-duplicate consecutive points to avoid numerical issues
|
||||
return polygonFromPoints<P>(filterNearDuplicates<P>(ovoidPoints));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out consecutive points that are too close together.
|
||||
* This prevents numerical instability in polygon boolean operations.
|
||||
*/
|
||||
function filterNearDuplicates<P extends LocalPoint | GlobalPoint>(
|
||||
points: P[],
|
||||
): P[] {
|
||||
if (points.length < 2) {
|
||||
return points;
|
||||
}
|
||||
|
||||
const filtered: P[] = [points[0]];
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = filtered[filtered.length - 1];
|
||||
const curr = points[i];
|
||||
const dx = curr[0] - prev[0];
|
||||
const dy = curr[1] - prev[1];
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
// Only add if far enough from previous point
|
||||
if (distSq > EPSILON * EPSILON) {
|
||||
filtered.push(curr);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if last point is too close to first point
|
||||
if (filtered.length > 2) {
|
||||
const first = filtered[0];
|
||||
const last = filtered[filtered.length - 1];
|
||||
const dx = last[0] - first[0];
|
||||
const dy = last[1] - first[1];
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
if (distSq < EPSILON * EPSILON) {
|
||||
filtered.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return filtered.length >= 3 ? filtered : points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the outline of a freedraw element using the ovoid-union approach.
|
||||
*
|
||||
* This creates ovoid shapes between each consecutive pair of points,
|
||||
* where each ovoid is defined by:
|
||||
* - Semicircular caps at each point (radius based on pressure)
|
||||
* - Connecting tangent lines between the caps
|
||||
*
|
||||
* All ovoids are then unioned together to form the final outline.
|
||||
*
|
||||
* @param element - The freedraw element to generate outline for
|
||||
* @returns Array of [x, y] points representing the outline polygon
|
||||
*/
|
||||
export function generateFreeDrawOvoidOutline(
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): [number, number][] {
|
||||
const { x, y, points, pressures, simulatePressure, strokeWidth } = element;
|
||||
|
||||
// Debug draw the raw segments from the freedraw element points
|
||||
const colors = ["red", "green", "blue", "orange", "purple"];
|
||||
|
||||
points.forEach((pt, i) => {
|
||||
if (i === points.length - 1) {
|
||||
return;
|
||||
}
|
||||
debugDrawLine(
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(x + pt[0], y + pt[1]),
|
||||
pointFrom<GlobalPoint>(x + points[i + 1][0], y + points[i + 1][1]),
|
||||
),
|
||||
{
|
||||
color: colors[i % colors.length],
|
||||
permanent: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Single point: just return a circle
|
||||
if (points.length === 1) {
|
||||
const pressure = simulatePressure ? 0.5 : pressures[0] ?? 0.5;
|
||||
const radius = getRadiusForPressure(pressure, strokeWidth);
|
||||
const circlePoints = generateArcPoints(
|
||||
points[0] as LocalPoint,
|
||||
radius,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
CAP_SEGMENTS * 2,
|
||||
);
|
||||
return circlePoints.map((p) => [p[0], p[1]]);
|
||||
}
|
||||
|
||||
// Generate ovoids for each consecutive pair of points
|
||||
const ovoids: Polygon<LocalPoint>[] = [];
|
||||
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p1 = points[i] as LocalPoint;
|
||||
const p2 = points[i + 1] as LocalPoint;
|
||||
|
||||
// Get pressures (use 0.5 as default when simulating)
|
||||
const pressure1 = simulatePressure ? 0.5 : pressures[i] ?? 0.5;
|
||||
const pressure2 = simulatePressure ? 0.5 : pressures[i + 1] ?? 0.5;
|
||||
|
||||
const r1 = getRadiusForPressure(pressure1, strokeWidth);
|
||||
const r2 = getRadiusForPressure(pressure2, strokeWidth);
|
||||
|
||||
// Force perpendicular caps at stroke endpoints
|
||||
const isFirstSegment = i === 0;
|
||||
const isLastSegment = i === points.length - 2;
|
||||
|
||||
const ovoidPoints = createOvoid<LocalPoint>(
|
||||
p1,
|
||||
r1,
|
||||
p2,
|
||||
r2,
|
||||
isFirstSegment, // Force perpendicular cap at stroke start
|
||||
isLastSegment, // Force perpendicular cap at stroke end
|
||||
);
|
||||
|
||||
// Draw the ovoid with different colors for debugging
|
||||
debugDrawPolygon(
|
||||
ovoidPoints.map((p) => pointFrom<GlobalPoint>(x + p[0], y + p[1])),
|
||||
{
|
||||
color: colors[i % colors.length],
|
||||
permanent: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (ovoidPoints.length >= 3) {
|
||||
ovoids.push(ovoidPoints);
|
||||
}
|
||||
}
|
||||
|
||||
if (ovoids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Union all ovoids together
|
||||
const result = chainOvoidsIntoSinglePolygon<LocalPoint>(
|
||||
ovoids.map((poly, i) => ({
|
||||
polygon: poly,
|
||||
firstPoint: points[i],
|
||||
secondPoint: points[i + 1],
|
||||
})),
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return the first (outer) polygon
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the ovoid outline to an SVG path string. Uses quadratic curves
|
||||
* for smoothing.
|
||||
*/
|
||||
export function generateFreeDrawOvoidSvgPath(
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
): string {
|
||||
const points = generateFreeDrawOvoidOutline(element);
|
||||
|
||||
if (points.length === 0) {
|
||||
console.warn("No outline points generated for freedraw element");
|
||||
return "";
|
||||
}
|
||||
|
||||
if (points.length < 3) {
|
||||
console.warn("Not enough outline points to form a closed path");
|
||||
return "";
|
||||
}
|
||||
|
||||
// Use a similar approach to the original getSvgPathFromStroke
|
||||
// but with the ovoid-generated points
|
||||
const med = (a: number[], b: number[]) => [
|
||||
(a[0] + b[0]) / 2,
|
||||
(a[1] + b[1]) / 2,
|
||||
];
|
||||
|
||||
const pathData = points
|
||||
// Build a closed, smoothed SVG path from the outline polygon
|
||||
.reduce(
|
||||
(acc: (string | number[])[], point, i, arr) => {
|
||||
if (i === points.length - 1) {
|
||||
// For the last point, add a line-to ("L") back to the first point and close
|
||||
// ("Z").
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
// Use a single quadratic command ("Q") and then emit point + midpoint pairs
|
||||
// so each segment curves through the current point toward the midpoint of
|
||||
// the next segment.
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
// Start with a move-to ("M") to the first point.
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
// Trim excessive float precision to keep the path string compact/stable.
|
||||
.replace(/(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g, "$1");
|
||||
|
||||
return pathData;
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
|
||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||
|
||||
import type {
|
||||
@@ -404,78 +402,3 @@ export const getNewGroupIdsForDuplication = (
|
||||
|
||||
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()));
|
||||
};
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
type Bounds,
|
||||
} from "@excalidraw/common";
|
||||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
@@ -24,7 +19,7 @@ import type {
|
||||
Vector,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds } from "./bounds";
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -28,9 +29,6 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||
|
||||
// string hash function (using djb2). Not cryptographically secure, use only
|
||||
// for versioning and such.
|
||||
// note: hashes individual code units (not code points),
|
||||
// but for hashing purposes this is fine as it iterates through every code unit
|
||||
// (as such, no need to encode to byte string first)
|
||||
export const hashString = (s: string): number => {
|
||||
let hash: number = 5381;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
@@ -54,6 +52,27 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
||||
const _clearElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ExcalidrawElement[] =>
|
||||
getNonDeletedElements(elements).map((element) =>
|
||||
isLinearElementType(element.type)
|
||||
? { ...element, lastCommittedPoint: null }
|
||||
: element,
|
||||
);
|
||||
|
||||
export const clearElementsForDatabase = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForExport = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
@@ -78,13 +97,14 @@ export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./positionElementsOnGrid";
|
||||
export * from "./renderElement";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
export * from "./Scene";
|
||||
export * from "./selection";
|
||||
export * from "./shape";
|
||||
export * from "./Shape";
|
||||
export * from "./ShapeCache";
|
||||
export * from "./shapes";
|
||||
export * from "./showSelectedShapeActions";
|
||||
export * from "./sizeHelpers";
|
||||
export * from "./sortElements";
|
||||
@@ -92,7 +112,6 @@ export * from "./store";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
export * from "./transform";
|
||||
export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "updated"
|
||||
"id" | "version" | "versionNonce" | "updated"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -46,13 +46,16 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fixedSegments, fileId } = updates as any;
|
||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
||||
updates as any;
|
||||
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
(Object.keys(updates).length === 0 || // normalization case
|
||||
typeof points !== "undefined" || // repositioning
|
||||
typeof fixedSegments !== "undefined") // segment fixing
|
||||
typeof fixedSegments !== "undefined" || // segment fixing
|
||||
typeof startBinding !== "undefined" ||
|
||||
typeof endBinding !== "undefined") // manual binding to element
|
||||
) {
|
||||
updates = {
|
||||
...updates,
|
||||
@@ -134,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version = updates.version ?? element.version + 1;
|
||||
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
return element;
|
||||
@@ -169,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
version: updates.version ?? element.version + 1,
|
||||
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||
updated: getUpdatedTimestamp(),
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -25,8 +25,6 @@ import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
@@ -47,7 +45,6 @@ import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -452,6 +449,7 @@ export const newFreeDrawElement = (
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -459,29 +457,17 @@ export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
polygon?: ExcalidrawLineElement["polygon"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
const element = {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: 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>(
|
||||
@@ -500,6 +486,7 @@ export const newArrowElement = <T extends boolean>(
|
||||
return {
|
||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
@@ -514,6 +501,7 @@ export const newArrowElement = <T extends boolean>(
|
||||
return {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
// TODO rewrite (mostly vibe-coded)
|
||||
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||
elements: TElement[] | TElement[][],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
padding = 50,
|
||||
): TElement[] => {
|
||||
// Ensure there are elements to position
|
||||
if (!elements || elements.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res: TElement[] = [];
|
||||
// Normalize input to work with atomic units (groups of elements)
|
||||
// If elements is a flat array, treat each element as its own atomic unit
|
||||
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
||||
? (elements as TElement[][])
|
||||
: (elements as TElement[]).map((element) => [element]);
|
||||
|
||||
// Determine the number of columns for atomic units
|
||||
// A common approach for a "grid-like" layout without specific column constraints
|
||||
// is to aim for a roughly square arrangement.
|
||||
const numUnits = atomicUnits.length;
|
||||
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
||||
|
||||
// Group atomic units into rows based on the calculated number of columns
|
||||
const rows: TElement[][][] = [];
|
||||
for (let i = 0; i < numUnits; i += numColumns) {
|
||||
rows.push(atomicUnits.slice(i, i + numColumns));
|
||||
}
|
||||
|
||||
// Calculate properties for each row (total width, max height)
|
||||
// and the total actual height of all row content.
|
||||
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
||||
const rowProperties = rows.map((rowUnits) => {
|
||||
let rowWidth = 0;
|
||||
let maxUnitHeightInRow = 0;
|
||||
|
||||
const unitBounds = rowUnits.map((unit) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
||||
return {
|
||||
elements: unit,
|
||||
bounds: [minX, minY, maxX, maxY] as const,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
});
|
||||
|
||||
unitBounds.forEach((unitBound, index) => {
|
||||
rowWidth += unitBound.width;
|
||||
// Add padding between units in the same row, but not after the last one
|
||||
if (index < unitBounds.length - 1) {
|
||||
rowWidth += padding;
|
||||
}
|
||||
if (unitBound.height > maxUnitHeightInRow) {
|
||||
maxUnitHeightInRow = unitBound.height;
|
||||
}
|
||||
});
|
||||
|
||||
totalGridActualHeight += maxUnitHeightInRow;
|
||||
return {
|
||||
unitBounds,
|
||||
width: rowWidth,
|
||||
maxHeight: maxUnitHeightInRow,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate the total height of the grid including padding between rows
|
||||
const totalGridHeightWithPadding =
|
||||
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
||||
|
||||
// Calculate the starting Y position to center the entire grid vertically around centerY
|
||||
let currentY = centerY - totalGridHeightWithPadding / 2;
|
||||
|
||||
// Position atomic units row by row
|
||||
rowProperties.forEach((rowProp) => {
|
||||
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
||||
|
||||
// Calculate the starting X for the current row to center it horizontally around centerX
|
||||
let currentX = centerX - rowWidth / 2;
|
||||
|
||||
unitBounds.forEach((unitBound) => {
|
||||
// Calculate the offset needed to position this atomic unit
|
||||
const [originalMinX, originalMinY] = unitBound.bounds;
|
||||
const offsetX = currentX - originalMinX;
|
||||
const offsetY = currentY - originalMinY;
|
||||
|
||||
// Apply the offset to all elements in this atomic unit
|
||||
unitBound.elements.forEach((element) => {
|
||||
res.push(
|
||||
newElementWith(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
} as ElementUpdate<TElement>),
|
||||
);
|
||||
});
|
||||
|
||||
// Move X for the next unit in the row
|
||||
currentX += unitBound.width + padding;
|
||||
});
|
||||
|
||||
// Move Y to the starting position for the next row
|
||||
// This accounts for the tallest unit in the current row and the inter-row padding
|
||||
currentY += rowMaxHeight + padding;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
@@ -1,13 +1,7 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import {
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
import { isRightAngleRads } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
@@ -20,8 +14,6 @@ import {
|
||||
getFontString,
|
||||
isRTL,
|
||||
getVerticalOffset,
|
||||
invariant,
|
||||
applyDarkModeFilter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -40,7 +32,7 @@ import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { getUncroppedImageElement } from "./cropElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import {
|
||||
@@ -62,9 +54,9 @@ import {
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { getCornerRadius } from "./utils";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { ShapeCache } from "./shape";
|
||||
import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -78,8 +70,16 @@ import type {
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
// color scheme (it's still not quite there and the colors look slightly
|
||||
// desatured, alas...)
|
||||
export const IMAGE_INVERT_FILTER =
|
||||
"invert(100%) hue-rotate(180deg) saturate(1.25)";
|
||||
|
||||
const isPendingImageElement = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
@@ -87,17 +87,25 @@ const isPendingImageElement = (
|
||||
isInitializedImageElement(element) &&
|
||||
!renderConfig.imageCache.has(element.fileId);
|
||||
|
||||
const shouldResetImageFilter = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
return (
|
||||
appState.theme === THEME.DARK &&
|
||||
isInitializedImageElement(element) &&
|
||||
!isPendingImageElement(element, renderConfig) &&
|
||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
||||
);
|
||||
};
|
||||
|
||||
const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
switch (element.type) {
|
||||
case "freedraw":
|
||||
return element.strokeWidth * 12;
|
||||
case "text":
|
||||
return element.fontSize / 2;
|
||||
case "arrow":
|
||||
if (element.endArrowhead || element.endArrowhead) {
|
||||
return 40;
|
||||
}
|
||||
return 20;
|
||||
default:
|
||||
return 20;
|
||||
}
|
||||
@@ -204,7 +212,7 @@ const generateElementCanvas = (
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom: Zoom,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
appState: StaticCanvasAppState,
|
||||
): ExcalidrawElementWithCanvas | null => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
@@ -251,7 +259,12 @@ const generateElementCanvas = (
|
||||
|
||||
const rc = rough.canvas(canvas);
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
// in dark theme, revert the image color filter
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = IMAGE_INVERT_FILTER;
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
|
||||
context.restore();
|
||||
|
||||
@@ -386,6 +399,7 @@ const drawElementOnCanvas = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
@@ -395,8 +409,7 @@ const drawElementOnCanvas = (
|
||||
case "ellipse": {
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
|
||||
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
|
||||
rc.draw(ShapeCache.get(element)!);
|
||||
break;
|
||||
}
|
||||
case "arrow":
|
||||
@@ -404,31 +417,26 @@ const drawElementOnCanvas = (
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
|
||||
ShapeCache.generateElementShape(element, renderConfig).forEach(
|
||||
(shape) => {
|
||||
rc.draw(shape);
|
||||
},
|
||||
);
|
||||
ShapeCache.get(element)!.forEach((shape) => {
|
||||
rc.draw(shape);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
// Draw directly to canvas
|
||||
context.save();
|
||||
context.fillStyle = element.strokeColor;
|
||||
|
||||
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
||||
const path = getFreeDrawPath2D(element) as Path2D;
|
||||
const fillShape = ShapeCache.get(element);
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (typeof shape === "string") {
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fill(new Path2D(shape));
|
||||
} else {
|
||||
rc.draw(shape);
|
||||
}
|
||||
if (fillShape) {
|
||||
rc.draw(fillShape);
|
||||
}
|
||||
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fill(path);
|
||||
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
@@ -486,10 +494,7 @@ const drawElementOnCanvas = (
|
||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||
context.save();
|
||||
context.font = getFontString(element);
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
@@ -540,7 +545,7 @@ const generateElementWithCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig
|
||||
? appState.zoom
|
||||
@@ -597,7 +602,7 @@ const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
appState: StaticCanvasAppState,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
@@ -715,7 +720,7 @@ export const renderElement = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
@@ -742,17 +747,12 @@ export const renderElement = (
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||
: FRAME_STYLE.strokeColor;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.LIGHT
|
||||
? "#7affd7"
|
||||
: applyDarkModeFilter("#1d8264");
|
||||
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
|
||||
}
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
@@ -775,6 +775,11 @@ export const renderElement = (
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, null);
|
||||
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
@@ -785,7 +790,7 @@ export const renderElement = (
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
context.restore();
|
||||
} else {
|
||||
const elementWithCanvas = generateElementWithCanvas(
|
||||
@@ -818,6 +823,10 @@ export const renderElement = (
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, renderConfig);
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
@@ -840,6 +849,9 @@ export const renderElement = (
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = "none";
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
@@ -871,7 +883,13 @@ export const renderElement = (
|
||||
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||
drawElementOnCanvas(
|
||||
element,
|
||||
tempRc,
|
||||
tempCanvasContext,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
|
||||
tempCanvasContext.translate(shiftX, shiftY);
|
||||
|
||||
@@ -910,7 +928,7 @@ export const renderElement = (
|
||||
}
|
||||
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
@@ -1002,58 +1020,69 @@ export const renderElement = (
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
|
||||
|
||||
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 generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
||||
const svgPathData = getFreeDrawSvgPath(element);
|
||||
const path = new Path2D(svgPathData);
|
||||
pathsCache.set(element, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
||||
return pathsCache.get(element);
|
||||
}
|
||||
|
||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
// Consider changing the options for simulated pressure vs real pressure
|
||||
const options: StrokeOptions = {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
};
|
||||
|
||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
||||
}
|
||||
|
||||
function med(A: number[], B: number[]) {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
}
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
function getSvgPathFromStroke(points: number[][]): string {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
pointCenter,
|
||||
normalizeRadians,
|
||||
pointFrom,
|
||||
pointFromPair,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
type LocalPoint,
|
||||
@@ -20,11 +21,7 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
getArrowLocalFixedPoints,
|
||||
unbindBindingElement,
|
||||
updateBoundElements,
|
||||
} from "./binding";
|
||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
@@ -39,7 +36,6 @@ import {
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
getMinTextElementWidth,
|
||||
@@ -50,7 +46,6 @@ import {
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
@@ -79,9 +74,7 @@ import type {
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawArrowElement,
|
||||
} from "./types";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
|
||||
// Returns true when transform (resizing/rotation) happened
|
||||
export const transformElements = (
|
||||
@@ -111,6 +104,18 @@ export const transformElements = (
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
element,
|
||||
scene,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
updateBoundElements(element, scene);
|
||||
return true;
|
||||
} else if (transformHandleType) {
|
||||
const elementId = selectedElements[0].id;
|
||||
const latestElement = elementsMap.get(elementId);
|
||||
@@ -145,9 +150,6 @@ export const transformElements = (
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTextElement(element)) {
|
||||
updateBoundElements(element, scene);
|
||||
}
|
||||
return true;
|
||||
} else if (selectedElements.length > 1) {
|
||||
if (transformHandleType === "rotation") {
|
||||
@@ -227,40 +229,13 @@ const rotateSingleElement = (
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
|
||||
angle,
|
||||
};
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
update = {
|
||||
...update,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
|
||||
if (element.startBinding) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
if (element.endBinding) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
scene.mutateElement(element, { angle });
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
textElement,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
scene.mutateElement(textElement, {
|
||||
angle,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
scene.mutateElement(textElement, { angle });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -307,50 +282,151 @@ export const measureFontSizeFromWidth = (
|
||||
};
|
||||
};
|
||||
|
||||
export const resizeSingleTextElement = (
|
||||
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
scene: Scene,
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
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;
|
||||
|
||||
const metricsWidth = element.width * (nextHeight / element.height);
|
||||
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
|
||||
const newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
metricsWidth,
|
||||
nextHeight,
|
||||
origElement.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
shouldResizeFromCenter,
|
||||
if (scale > 0) {
|
||||
const nextWidth = element.width * scale;
|
||||
const nextHeight = element.height * scale;
|
||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
||||
if (metrics === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTopLeft = [x1, y1];
|
||||
const startBottomRight = [x2, y2];
|
||||
const startCenter = [cx, cy];
|
||||
|
||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = pointFrom<GlobalPoint>(
|
||||
startBottomRight[0] - Math.abs(nextWidth),
|
||||
startBottomRight[1] - Math.abs(nextHeight),
|
||||
);
|
||||
}
|
||||
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, {
|
||||
fontSize: metrics.size,
|
||||
width: metricsWidth,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
getFontString({
|
||||
fontSize: element.fontSize,
|
||||
@@ -359,7 +435,17 @@ export const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const newWidth = Math.max(minWidth, nextWidth);
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
|
||||
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(
|
||||
element.originalText,
|
||||
@@ -372,27 +458,49 @@ export const resizeSingleTextElement = (
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const newHeight = metrics.height;
|
||||
const eleNewHeight = metrics.height;
|
||||
|
||||
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
newWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
const newOrigin = getResizedOrigin(
|
||||
previousOrigin,
|
||||
origElement.width,
|
||||
origElement.height,
|
||||
newWidth,
|
||||
newHeight,
|
||||
element.angle,
|
||||
transformHandleType,
|
||||
false,
|
||||
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> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newOrigin.x,
|
||||
y: newOrigin.y,
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
text,
|
||||
autoResize: false,
|
||||
};
|
||||
@@ -419,11 +527,6 @@ const rotateMultipleElements = (
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
|
||||
const rotatedElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
>(elements.map((element) => [element.id, element]));
|
||||
|
||||
for (const element of elements) {
|
||||
if (!isFrameLikeElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -454,30 +557,11 @@ const rotateMultipleElements = (
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
}
|
||||
if (element.endBinding) {
|
||||
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundText,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
scene.mutateElement(boundText, {
|
||||
x,
|
||||
y,
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
});
|
||||
}
|
||||
@@ -737,18 +821,6 @@ export const resizeSingleElement = (
|
||||
shouldInformMutation?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||
return resizeSingleTextElement(
|
||||
origElement,
|
||||
latestElement,
|
||||
scene,
|
||||
handleDirection,
|
||||
shouldResizeFromCenter,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
);
|
||||
}
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
@@ -878,32 +950,13 @@ export const resizeSingleElement = (
|
||||
Number.isFinite(newOrigin.x) &&
|
||||
Number.isFinite(newOrigin.y)
|
||||
) {
|
||||
let updates: ElementUpdate<ExcalidrawElement> = {
|
||||
const updates = {
|
||||
...newOrigin,
|
||||
width: Math.abs(nextWidth),
|
||||
height: Math.abs(nextHeight),
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
if (isBindingElement(latestElement)) {
|
||||
if (latestElement.startBinding) {
|
||||
updates = {
|
||||
...updates,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
|
||||
if (latestElement.startBinding) {
|
||||
unbindBindingElement(latestElement, "start", scene);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestElement.endBinding) {
|
||||
updates = {
|
||||
...updates,
|
||||
endBinding: null,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(latestElement, updates, {
|
||||
informMutation: shouldInformMutation,
|
||||
isDragging: false,
|
||||
@@ -921,7 +974,10 @@ export const resizeSingleElement = (
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene);
|
||||
updateBoundElements(latestElement, scene, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1455,36 +1511,24 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
const resizedElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
|
||||
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { 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, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (!resizedElementsMap.has(element.startBinding.elementId)) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
}
|
||||
if (element.endBinding) {
|
||||
if (!resizedElementsMap.has(element.endBinding.elementId)) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
|
||||
@@ -5,25 +5,22 @@ import {
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
SIDE_RESIZING_THRESHOLD,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
getOmitSidesForEditorInterface,
|
||||
getOmitSidesForDevice,
|
||||
canResizeFromSides,
|
||||
} from "./transformHandles";
|
||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
@@ -54,7 +51,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
y: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
editorInterface: EditorInterface,
|
||||
device: Device,
|
||||
): MaybeTransformHandleType => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
@@ -66,7 +63,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
zoom,
|
||||
elementsMap,
|
||||
pointerType,
|
||||
getOmitSidesForEditorInterface(editorInterface),
|
||||
getOmitSidesForDevice(device),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -89,7 +86,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
return filter[0] as TransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
if (canResizeFromSides(device)) {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
@@ -135,7 +132,7 @@ export const getElementWithTransformHandleType = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
elementsMap: ElementsMap,
|
||||
editorInterface: EditorInterface,
|
||||
device: Device,
|
||||
) => {
|
||||
return elements.reduce((result, element) => {
|
||||
if (result) {
|
||||
@@ -149,7 +146,7 @@ export const getElementWithTransformHandleType = (
|
||||
scenePointerY,
|
||||
zoom,
|
||||
pointerType,
|
||||
editorInterface,
|
||||
device,
|
||||
);
|
||||
return transformHandleType ? { element, transformHandleType } : null;
|
||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||
@@ -163,14 +160,14 @@ export const getTransformHandleTypeFromCoords = <
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
editorInterface: EditorInterface,
|
||||
device: Device,
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0 as Radians,
|
||||
zoom,
|
||||
pointerType,
|
||||
getOmitSidesForEditorInterface(editorInterface),
|
||||
getOmitSidesForDevice(device),
|
||||
);
|
||||
|
||||
const found = Object.keys(transformHandles).find((key) => {
|
||||
@@ -186,7 +183,7 @@ export const getTransformHandleTypeFromCoords = <
|
||||
return found as MaybeTransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
if (canResizeFromSides(device)) {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
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,28 +2,14 @@ import {
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
viewportCoordsToSceneCoords,
|
||||
} 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 { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
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
|
||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||
// - could also be part of `_clearElements`
|
||||
@@ -31,18 +17,8 @@ export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
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.points.length < 2;
|
||||
}
|
||||
|
||||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
@@ -158,42 +134,13 @@ export const getLockedLinearCursorAlignSize = (
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
customAngle?: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
const angle = Math.atan2(height, width) as Radians;
|
||||
let lockedAngle = (Math.round(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
|
||||
+51
-116
@@ -19,21 +19,9 @@ import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||
|
||||
import {
|
||||
syncInvalidIndicesImmutable,
|
||||
hashElementsVersion,
|
||||
hashString,
|
||||
isInitializedImageElement,
|
||||
isImageElement,
|
||||
} from "./index";
|
||||
import { hashElementsVersion, hashString } from "./index";
|
||||
|
||||
import type { ApplyToOptions } from "./delta";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
SceneElementsMap,
|
||||
} from "./types";
|
||||
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
||||
|
||||
export const CaptureUpdateAction = {
|
||||
/**
|
||||
@@ -76,9 +64,8 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// for internal use by history
|
||||
// internally used by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@@ -118,7 +105,7 @@ export class Store {
|
||||
params:
|
||||
| {
|
||||
action: CaptureUpdateActionType;
|
||||
elements: readonly ExcalidrawElement[] | undefined;
|
||||
elements: SceneElementsMap | undefined;
|
||||
appState: AppState | ObservedAppState | undefined;
|
||||
}
|
||||
| {
|
||||
@@ -142,21 +129,13 @@ export class Store {
|
||||
} else {
|
||||
// immediately create an immutable change of the scheduled updates,
|
||||
// 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(
|
||||
this.app.scene.getElementsMapIncludingDeleted(),
|
||||
this.app.state,
|
||||
);
|
||||
|
||||
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||
action,
|
||||
// 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.elements,
|
||||
params.appState,
|
||||
);
|
||||
|
||||
@@ -234,12 +213,22 @@ 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
|
||||
storeDelta = delta;
|
||||
} else {
|
||||
storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
|
||||
// calculate the deltas based on the previous and next 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()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@@ -516,24 +505,6 @@ export class StoreDelta {
|
||||
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.
|
||||
*/
|
||||
@@ -552,35 +523,38 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
const elements = ElementsDelta.create(added, removed, updated, {
|
||||
shouldRedistribute: false,
|
||||
});
|
||||
|
||||
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;
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse store delta, creates new instance of `StoreDelta`.
|
||||
*/
|
||||
public static inverse(delta: StoreDelta) {
|
||||
public static inverse(delta: StoreDelta): StoreDelta {
|
||||
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.
|
||||
*/
|
||||
@@ -588,12 +562,11 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options?: ApplyToOptions,
|
||||
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
StoreSnapshot.empty().elements,
|
||||
options,
|
||||
prevSnapshot.elements,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
@@ -605,32 +578,6 @@ export class StoreDelta {
|
||||
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() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@@ -740,10 +687,11 @@ export class StoreSnapshot {
|
||||
nextElements.set(id, changedElement);
|
||||
}
|
||||
|
||||
const nextAppState = getObservedAppState({
|
||||
...this.appState,
|
||||
...change.appState,
|
||||
});
|
||||
const nextAppState = Object.assign(
|
||||
{},
|
||||
this.appState,
|
||||
change.appState,
|
||||
) as ObservedAppState;
|
||||
|
||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||
// by default we assume that change is different from what we have in the snapshot
|
||||
@@ -899,7 +847,7 @@ export class StoreSnapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if there are any changed elements.
|
||||
* Detect if there any changed elements.
|
||||
*/
|
||||
private detectChangedElements(
|
||||
nextElements: SceneElementsMap,
|
||||
@@ -934,14 +882,6 @@ export class StoreSnapshot {
|
||||
!prevElement || // element was added
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -996,31 +936,26 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElement: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
};
|
||||
|
||||
export const getObservedAppState = (
|
||||
appState: AppState | ObservedAppState,
|
||||
): ObservedAppState => {
|
||||
export const getObservedAppState = (appState: AppState): ObservedAppState => {
|
||||
const observedAppState = {
|
||||
name: appState.name,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
selectedGroupIds: appState.selectedGroupIds,
|
||||
editingLinearElementId: appState.editingLinearElement?.elementId || null,
|
||||
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
@@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
|
||||
x =
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -346,7 +326,10 @@ export const getContainerCenter = (
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
type EditorInterface,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
@@ -8,10 +9,10 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
Device,
|
||||
InteractiveCanvasAppState,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
@@ -109,21 +111,20 @@ const generateTransformHandle = (
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
};
|
||||
|
||||
export const canResizeFromSides = (editorInterface: EditorInterface) => {
|
||||
if (
|
||||
editorInterface.formFactor === "phone" &&
|
||||
editorInterface.userAgent.isMobileDevice
|
||||
) {
|
||||
export const canResizeFromSides = (device: Device) => {
|
||||
if (device.viewport.isMobile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (device.isTouchScreen && (isAndroid || isIOS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getOmitSidesForEditorInterface = (
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
export const getOmitSidesForDevice = (device: Device) => {
|
||||
if (canResizeFromSides(device)) {
|
||||
return DEFAULT_OMIT_SIDES;
|
||||
}
|
||||
|
||||
@@ -325,15 +326,11 @@ export const getTransformHandles = (
|
||||
);
|
||||
};
|
||||
|
||||
export const hasBoundingBox = (
|
||||
export const shouldShowBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing ||
|
||||
appState.selectedLinearElement?.isDragging
|
||||
) {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
@@ -348,7 +345,5 @@ export const hasBoundingBox = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||
// (also prob best for simplicity's sake)
|
||||
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
|
||||
return element.points.length > 2;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -26,7 +25,8 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
@@ -108,12 +108,6 @@ export const isLinearElement = (
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isLineElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLineElement => {
|
||||
return element != null && element.type === "line";
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
@@ -126,15 +120,6 @@ export const isElbowArrow = (
|
||||
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 = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
@@ -160,7 +145,7 @@ export const isLinearElementType = (
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
@@ -355,6 +340,24 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding | FixedPointBinding,
|
||||
): binding is FixedPointBinding => {
|
||||
return (
|
||||
Object.hasOwn(binding, "fixedPoint") &&
|
||||
(binding as FixedPointBinding).fixedPoint != null
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Move this to @excalidraw/math
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
box.length === 4 &&
|
||||
typeof box[0] === "number" &&
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
|
||||
export const getLinearElementSubType = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): ExcalidrawLinearElementSubType => {
|
||||
@@ -369,26 +372,3 @@ export const getLinearElementSubType = (
|
||||
}
|
||||
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,8 +195,7 @@ export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawSelectionElement;
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
@@ -279,27 +278,26 @@ export type ExcalidrawTextElementWithContainer = {
|
||||
|
||||
export type FixedPoint = [number, number];
|
||||
|
||||
export type BindMode = "inside" | "orbit" | "skip";
|
||||
|
||||
export type FixedPointBinding = {
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
|
||||
// Determines whether the arrow remains outside the shape or is allowed to
|
||||
// go all the way inside the shape up to the exact fixed point.
|
||||
mode: BindMode;
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
type Index = number;
|
||||
export type FixedPointBinding = Merge<
|
||||
PointBinding,
|
||||
{
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
}
|
||||
>;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
Index,
|
||||
number,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
@@ -321,22 +319,17 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "arrow";
|
||||
points: readonly LocalPoint[];
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "line";
|
||||
polygon: boolean;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: Index;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
@@ -349,9 +342,9 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
ExcalidrawArrowElement,
|
||||
{
|
||||
elbowed: true;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
/**
|
||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||
* order to temporarily hide the first segment of the arrow without losing
|
||||
@@ -377,6 +370,7 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
+248
-513
@@ -1,23 +1,8 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
invariant,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointTranslate,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
@@ -25,336 +10,250 @@ import {
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
import { elementCenterPoint } from "@excalidraw/common";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { elementCenterPoint, getDiamondPoints } from "./bounds";
|
||||
import { getCornerRadius } from "./shapes";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||
|
||||
const ElementShapesCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
||||
>();
|
||||
|
||||
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
offset: number,
|
||||
): ElementShape | undefined => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.delete(element);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return shapes.get(offset);
|
||||
};
|
||||
|
||||
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: ElementShape,
|
||||
offset: number,
|
||||
) => {
|
||||
const record = ElementShapesCache.get(element);
|
||||
|
||||
if (!record) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { version, shapes } = record;
|
||||
|
||||
if (version !== element.version) {
|
||||
ElementShapesCache.set(element, {
|
||||
version: element.version,
|
||||
shapes: new Map([[offset, shape]]),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
shapes.set(offset, shape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||
*
|
||||
* @param element The linear element to deconstruct
|
||||
* @returns The rotated in components.
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
|
||||
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||
const op = ops[idx];
|
||||
const prevPoint =
|
||||
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
||||
switch (op.op) {
|
||||
case "move":
|
||||
continue;
|
||||
case "lineTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
case "bcurveTo":
|
||||
if (!prevPoint) {
|
||||
throw new Error("prevPoint is undefined");
|
||||
}
|
||||
|
||||
curves.push(
|
||||
curve<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + prevPoint[0],
|
||||
element.y + prevPoint[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[0],
|
||||
element.y + op.data[1],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[2],
|
||||
element.y + op.data[3],
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + op.data[4],
|
||||
element.y + op.data[5],
|
||||
),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
default: {
|
||||
console.error("Unknown op type", op.op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shape = [lines, curves] as ElementShape;
|
||||
setElementShapesCacheEntry(element, shape, 0);
|
||||
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves **unrotated**.
|
||||
* line segments and curves.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
let radius = getCornerRadius(
|
||||
const roundness = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (radius === 0) {
|
||||
radius = 0.01;
|
||||
if (roundness <= 0) {
|
||||
const r = rectangle(
|
||||
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(
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width, element.y + element.height),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||
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] + radius),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||
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] + radius, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||
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] - radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
|
||||
const baseCorners = [
|
||||
curve(
|
||||
left[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
const offsets = [
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
top[0],
|
||||
offset,
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
top[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
offset,
|
||||
), //TOP RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
||||
),
|
||||
right[0],
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
right[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
bottom[1],
|
||||
offset,
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
bottom[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
||||
),
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
left[0],
|
||||
offset,
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
const corners = [
|
||||
curve(
|
||||
pointFromVector(offsets[0], left[1]),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], top[0]),
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
pointFromVector(offsets[1], top[1]),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], right[0]),
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[2], right[1]),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], bottom[1]),
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[3], bottom[0]),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], left[0]),
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
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],
|
||||
),
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
const shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
* Get the building components of a diamond element in the form of
|
||||
* line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||
: (topX - leftX) * 0.01;
|
||||
const horizontalRadius = element.roundness
|
||||
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||
: (rightY - topY) * 0.01;
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
||||
|
||||
if (element.roundness?.type == null) {
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
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 [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
@@ -363,258 +262,94 @@ export function deconstructDiamondElement(
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const baseCorners = [
|
||||
const offsets = [
|
||||
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(
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
right,
|
||||
right,
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // RIGHT
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
bottom,
|
||||
bottom,
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // BOTTOM
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
left,
|
||||
left,
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // LEFT
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
top,
|
||||
top,
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const corners = baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
|
||||
);
|
||||
|
||||
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],
|
||||
),
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
|
||||
const shape = [sides, corners.flat()] as ElementShape;
|
||||
|
||||
setElementShapesCacheEntry(element, shape, offset);
|
||||
|
||||
return shape;
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
const getDiagonalsForBindableElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
// for rectangles, shrink the diagonals a bit because there's something
|
||||
// going on with the focus points around the corners. Ask Mark for details.
|
||||
const OFFSET_PX = element.type === "rectangle" ? 15 : 0;
|
||||
const shrinkSegment = (seg: LineSegment<GlobalPoint>) => {
|
||||
const v = vectorNormalize(vectorFromPoint(seg[1], seg[0]));
|
||||
const offset = vectorScale(v, OFFSET_PX);
|
||||
return lineSegment<GlobalPoint>(
|
||||
pointTranslate(seg[0], offset),
|
||||
pointTranslate(seg[1], vectorScale(offset, -1)),
|
||||
);
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const diagonalOne = shrinkSegment(
|
||||
isRectangularElement(element)
|
||||
? lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
)
|
||||
: lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
const diagonalTwo = shrinkSegment(
|
||||
isRectangularElement(element)
|
||||
? lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
)
|
||||
: lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : arrow.points.length - 2,
|
||||
elementsMap,
|
||||
);
|
||||
const b = pointFromVector<GlobalPoint>(
|
||||
vectorScale(
|
||||
vectorFromPoint(point, a),
|
||||
2 * pointDistance(a, point) +
|
||||
Math.max(
|
||||
pointDistance(diagonalOne[0], diagonalOne[1]),
|
||||
pointDistance(diagonalTwo[0], diagonalTwo[1]),
|
||||
),
|
||||
),
|
||||
a,
|
||||
);
|
||||
const intersector = lineSegment<GlobalPoint>(point, b);
|
||||
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
|
||||
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
|
||||
const d1 = p1 && pointDistance(a, p1);
|
||||
const d2 = p2 && pointDistance(a, p2);
|
||||
|
||||
let p = null;
|
||||
if (d1 != null && d2 != null) {
|
||||
p = d1 < d2 ? p1 : p2;
|
||||
} else {
|
||||
p = p1 || p2 || null;
|
||||
}
|
||||
|
||||
return p && isPointInElement(p, element, elementsMap) ? p : null;
|
||||
};
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isFrameLikeElement, isTextElement } from "./typeChecks";
|
||||
import { isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
import { getElementsInGroup } from "./groups";
|
||||
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
|
||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||
return element.frameId === frameId || element.id === frameId;
|
||||
@@ -146,51 +139,6 @@ const getContiguousFrameRangeElements = (
|
||||
return allElements.slice(rangeStart, rangeEnd + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the arrow element above any bindable elements it intersects with or
|
||||
* hovers over.
|
||||
*/
|
||||
export const moveArrowAboveBindable = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
|
||||
const containerElement = isTextElement(hoveredElement)
|
||||
? getContainerElement(hoveredElement, elementsMap)
|
||||
: null;
|
||||
|
||||
const bindableIds = [
|
||||
hoveredElement.id,
|
||||
boundTextElement?.id,
|
||||
containerElement?.id,
|
||||
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
|
||||
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
|
||||
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
|
||||
|
||||
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
|
||||
const updatedElements = Array.from(elements);
|
||||
const arrow = updatedElements.splice(arrowIdx, 1)[0];
|
||||
updatedElements.splice(bindableIdx, 0, arrow);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns next candidate index that's available to be moved to. Currently that
|
||||
* is a non-deleted element, and not inside a group (unless we're editing it).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user