Compare commits

...

22 Commits

Author SHA1 Message Date
dwelle 77edb39a7e seed 2025-06-10 23:52:11 +02:00
dwelle 56c9ea9259 stuff 2025-06-10 23:30:37 +02:00
dwelle 0d34177761 liquid glass 2025-06-10 23:10:10 +02:00
Marcel Mraz a7b64f02b3 fix: remove image preview on image insertion (#9626)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-10 21:31:11 +02:00
Marcel Mraz 0d4abd1ddc fix: add history capture for paste and drop of images and embeds (#9605) 2025-06-10 14:28:16 +02:00
Sachintha Lakmin 9e77373c81 fix: add generic font family fallbacks before Segoe UI Emoji to fix glyph rendering on windows (#9425) 2025-06-10 13:43:39 +02:00
Marcel Mraz d108053351 feat: various delta improvements (#9571) 2025-06-09 09:55:35 +02:00
David Luzar d4e85a9480 feat: use enter to edit line points & update hints (#9630)
feat: use enter to edit line points & update hints
2025-06-07 18:05:20 +02:00
David Luzar 08cd4c4f9a test: improve getTextEditor test helper (#9629)
* test: improve getTextEditor test helper

* fix test
2025-06-07 17:45:37 +02:00
cheapster 469caadb87 fix: prevent double-click to edit/create text scenarios on line (#9597)
* fix : double click on line enables line editor

* fix : prevent double-click to edit/create text
when inside line editor

* refactor: use lineCheck instead of arrowCheck in
doubleClick handler to align with updated logic

* fix: replace negative arrowCheck with lineCheck in
dbl click handler and fix double-click bind text
test in linearElementEditor tests

* clean up test

* simplify check

* add tests

* prevent text editing on dblclick when inside arrow editor

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-06-07 17:08:35 +02:00
Márk Tolmács ca1a4f25e7 feat: Precise hit testing (#9488) 2025-06-07 12:56:32 +02:00
Sujal Gupta 56c05b3099 fix: prevent search menu from opening when dialog is open (#9279) 2025-06-03 15:53:00 +02:00
Aarav Dayal 6c0ff7fc5c docs: added the correct CSS import for nextjs dynamic first import integration example (#9584)
Added the correct CSS import for nextjs dynamic first import integration example

This is with reference to [this](https://github.com/excalidraw/excalidraw/issues/9562)
2025-05-29 22:03:20 +02:00
Muhammad Khuzaima Umair 7cad3645a0 perf: Simplify normalizeRadians function (#9572)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-28 15:58:42 +02:00
Márk Tolmács 5921ebc416 fix: Regression in long press context menu closure (#9588) 2025-05-28 13:38:47 +02:00
Márk Tolmács 864353be5f feat: Try to preserve line angle on SHIFT+drag (#9570) 2025-05-27 12:39:45 +02:00
cheapster db2911c6c4 fix: ghost point issue when moving a shape after dragging a point in the line editor (#9530)
fix: ghost point issue when moving a shape after
dragging a point in the line editor
2025-05-26 21:34:41 +02:00
David Luzar fc3e062074 feat: do not break polygon on point delete inside line editor (#9580)
* feat: do not break polygon on point delete inside line editor

* fix: polygon point highlighting when selected point == 0
2025-05-26 16:51:47 +02:00
zsviczian 87c87a9fb1 feat: line polygons (#9477)
* Loop Lock/Unlock

* fixed condition. 4 line points are required for the action to be available

* extracted updateLoopLock to improve readability. Removed unnecessary SVG attributes

* lint + added loopLock to restore.ts

* added  loopLock to newElement, updated test snapshots

* lint

* dislocate enpoint when breaking the loop.

* change icon & turn into a state style button

* POC: auto-transform to polygon on bg set

* keep polygon icon constant

* do not split points on de-polygonizing & highlight overlapping points

* rewrite color picker to support no (mixed) colors & fix focus handling

* refactor

* tweak point rendering inside line editor

* do not disable polygon when creating new points via alt

* auto-enable polygon when aligning start/end points

* TBD: remove bg color when disabling polygon

* TBD: only show polygon button for enabled polygons

* fix polygon behavior when adding/removing/moving points within line editor

* convert to polygon when creating line

* labels tweak

* add to command palette

* loopLock -> polygon

* restore `polygon` state on type conversions

* update snapshots

* naming

* break polygon on restore/finalize if invalid & prevent creation

* snapshots

* fix: merge issue and forgotten debug

* snaps

* do not merge points for 3-point lines

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-26 11:14:55 +02:00
Márk Tolmács 4dc205537c feat: Call actionFinalize at the end of arrow creation and drag (#9453)
* First iter

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Restore binding

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* More actionFinalize

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Additional fixes

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* New elbow arrow is removed if  too small

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Remove very small arrows

* Still allow loops

* Restore tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Update history snapshot

* More history snapshot updates

* keep invisible 2-point lines/freedraw elements

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-05-25 22:28:24 +02:00
David Luzar cc571c4681 chore: init CLAUDE.md (#9563)
* chore: init CLAUDE.md

* Add Copilot instructions

* update gitignore

* simplify

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-25 21:23:40 +02:00
Marcel Mraz 14d512f321 Fix import.meta.env.MODE being undefined in host apps 2025-05-22 15:25:48 +02:00
109 changed files with 7315 additions and 4986 deletions
+2
View File
@@ -1,3 +1,5 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
+2
View File
@@ -1,3 +1,5 @@
MODE="production"
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/ VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
+45
View File
@@ -0,0 +1,45 @@
# Project coding standards
## Generic Communication Guidelines
- Be succint and be aware that expansive generative AI answers are costly and slow
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
- Stop apologising if corrected, just provide the correct information or code
- Prefer code unless asked for explanation
- Stop summarizing what you've changed after modifications unless asked for
## TypeScript Guidelines
- Use TypeScript for all new code
- Where possible, prefer implementations without allocation
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
- Prefer immutable data (const, readonly)
- Use optional chaining (?.) and nullish coalescing (??) operators
## React Guidelines
- Use functional components with hooks
- Follow the React hooks rules (no conditional hooks)
- Keep components small and focused
- Use CSS modules for component styling
## Naming Conventions
- Use PascalCase for component names, interfaces, and type aliases
- Use camelCase for variables, functions, and methods
- Use ALL_CAPS for constants
## Error Handling
- Use try/catch blocks for async operations
- Implement proper error boundaries in React components
- Always log errors with contextual information
## Testing
- Always attempt to fix #problems
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
## Types
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
+1
View File
@@ -26,3 +26,4 @@ coverage
dev-dist dev-dist
html html
meta*.json meta*.json
.claude
+34
View File
@@ -0,0 +1,34 @@
# CLAUDE.md
## Project Structure
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
- **`examples/`** - Integration examples (NextJS, browser script)
## Development Workflow
1. **Package Development**: Work in `packages/*` for editor features
2. **App Development**: Work in `excalidraw-app/` for app-specific features
3. **Testing**: Always run `yarn test:update` before committing
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
## Development Commands
```bash
yarn test:typecheck # TypeScript type checking
yarn test:update # Run all tests (with snapshot updates)
yarn fix # Auto-fix formatting and linting issues
```
## Architecture Notes
### Package System
- Uses Yarn workspaces for monorepo management
- Internal packages use path aliases (see `vitest.config.mts`)
- Build system uses esbuild for packages, Vite for the app
- TypeScript throughout with strict configuration
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts ```ts
( (
tool: ( tool: (
| ( | { type: ToolType }
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }, ) & { locked?: boolean },
) => {}; ) => {};
@@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` | | `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface | | `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor ## setCursor
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic( const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw, async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{ {
+271
View File
@@ -4,6 +4,7 @@ import {
TTDDialogTrigger, TTDDialogTrigger,
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
convertToExcalidrawElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -471,6 +472,276 @@ const ExcalidrawWrapper = () => {
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
if (data.scene) {
data.scene.elements = data.scene.elements?.length
? data.scene.elements
: convertToExcalidrawElements([
{
id: "MyWwgGwbjyZoxKSa2pfBI",
type: "rectangle",
x: 43.5,
y: 52.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ff8787",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 246249428,
version: 427,
versionNonce: 2079096276,
boundElements: [],
link: null,
locked: false,
},
{
id: "ABor4pClpJJs6adQb4iqs",
type: "rectangle",
x: 357.5,
y: 481.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#b2f2bb",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1800887636,
version: 445,
versionNonce: 650486612,
boundElements: [],
link: null,
locked: false,
},
{
id: "-FGknlZxdFGYZNoUr2lG5",
type: "rectangle",
x: 1280.5,
y: 47.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#69db7c",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 883462868,
version: 532,
versionNonce: 912136404,
boundElements: [],
link: null,
locked: false,
},
{
id: "XQ3bRtQnS_ydtq13c_p99",
type: "rectangle",
x: 863.5,
y: 334.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#a5d8ff",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 2033653844,
version: 459,
versionNonce: 1984683604,
boundElements: [],
link: null,
locked: false,
},
{
id: "GmC3zgvVq3xUd2Qn0cNX3",
type: "rectangle",
x: 897.5,
y: 697.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ffec99",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 81325524,
version: 488,
versionNonce: 1332016084,
boundElements: [],
link: null,
locked: false,
},
{
id: "8nvmvwbmFmNAcLAmwZryR",
type: "rectangle",
x: 456.5,
y: 229.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#eebefa",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 950276948,
version: 649,
versionNonce: 532633940,
boundElements: [],
link: null,
locked: false,
},
{
id: "7dyXEvhPDLA0-e3XCaM2H",
type: "rectangle",
x: 1446.5,
y: 609.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#d0bfff",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1659799764,
version: 570,
versionNonce: 313593556,
boundElements: [],
link: null,
locked: false,
},
{
id: "gJubXeAjkEAr6yywtiZ0T",
type: "rectangle",
x: 1182.5,
y: 800.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#96f2d7",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 391007828,
version: 563,
versionNonce: 2026902612,
boundElements: [],
link: null,
locked: false,
},
{
id: "pSB2Izj9osR_XxVCzyVnD",
type: "rectangle",
x: 1477.5,
y: 295.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#9775fa",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 110715860,
version: 590,
versionNonce: 535980500,
boundElements: [],
link: null,
locked: false,
},
{
id: "yc7IKdtbnjLRaxJI5_q7m",
type: "rectangle",
x: 696.5,
y: 21.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#4dabf7",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1457899860,
version: 587,
versionNonce: 724438868,
boundElements: [],
link: null,
locked: false,
},
{
id: "7KjY1vcMQm_i_i05wowTC",
type: "rectangle",
x: 447.5,
y: 771.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ffc9c9",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 2072608468,
version: 610,
versionNonce: 1792607444,
boundElements: [],
link: null,
locked: false,
},
]);
}
initialStatePromiseRef.current.promise.resolve(data.scene); initialStatePromiseRef.current.promise.resolve(data.scene);
}); });
+1 -74
View File
@@ -205,6 +205,7 @@ describe("collaboration", () => {
// with explicit undo (as addition) we expect our item to be restored from the snapshot! // with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([ expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props), expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }), expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -247,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }), expect.objectContaining({ ...rect2Props, isDeleted: true }),
]); ]);
}); });
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
}); });
}); });
+39 -2
View File
@@ -147,19 +147,49 @@ export const FONT_FAMILY = {
Assistant: 10, Assistant: 10,
}; };
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = { export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100, [CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000, [WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
}; };
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = ( export const getFontFamilyFallbacks = (
fontFamily: number, fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => { ): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) { switch (fontFamily) {
case FONT_FAMILY.Excalifont: case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT]; return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default: default:
return [WINDOWS_EMOJI_FALLBACK_FONT]; return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
} }
}; };
@@ -477,3 +507,10 @@ export enum UserIdleState {
AWAY = "away", AWAY = "away",
IDLE = "idle", IDLE = "idle",
} }
/**
* distance at which we merge points instead of adding a new merge-point
* when converting a line to a polygon (merge currently means overlaping
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
+7 -9
View File
@@ -1,10 +1,12 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
ExcalidrawElement, ExcalidrawElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -101,7 +103,6 @@ export const getFontFamilyString = ({
}) => { }) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) { if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id) return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`) .map((x) => `, ${x}`)
.join("")}`; .join("")}`;
@@ -712,8 +713,8 @@ export const arrayToObject = <T>(
array: readonly T[], array: readonly T[],
groupBy?: (value: T) => string | number, groupBy?: (value: T) => string | number,
) => ) =>
array.reduce((acc, value) => { array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : String(value)] = value; acc[groupBy ? groupBy(value) : idx] = value;
return acc; return acc;
}, {} as { [key: string]: T }); }, {} as { [key: string]: T });
@@ -1240,16 +1241,13 @@ export const castArray = <T>(value: T | T[]): T[] =>
export const elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
const { x, y, width, height } = element; const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
const centerXPoint = x + width / 2 + xOffset; return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
}; };
/** hack for Array.isArray type guard not working with readonly value[] */ /** hack for Array.isArray type guard not working with readonly value[] */
+184 -3
View File
@@ -1,8 +1,17 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
@@ -20,7 +29,12 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement"; import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import {
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementBounds,
} from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -28,10 +42,11 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -303,6 +318,172 @@ const getArrowheadShapes = (
} }
}; };
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
getElementBounds(element, elementsMap, true),
);
switch (element.type) {
case "line":
case "arrow": {
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
return generator.path(generateElbowArrowShape(points, 16), options)
.sets[0].ops;
} else if (!element.roundness) {
return points.map((point, idx) => {
const p = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
case "freedraw": {
if (element.points.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
/** /**
* Generates the roughjs shape for given element. * Generates the roughjs shape for given element.
* *
+142 -127
View File
@@ -27,8 +27,6 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@@ -41,7 +39,7 @@ import {
doBoundsIntersect, doBoundsIntersect,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
@@ -63,7 +61,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@@ -131,6 +128,7 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
@@ -141,6 +139,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@@ -150,6 +149,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@@ -176,6 +176,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene, scene: Scene,
elementsMap: ElementsMap,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@@ -216,43 +217,29 @@ const bindOrUnbindLinearElementEdge = (
} }
}; };
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
};
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => (["start", "end"] as const).map((edge) => {
getOriginalBindingIfStillCloseOfLinearElementEdge( const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
linearElement, const elementId =
edge as "start" | "end", edge === "start"
elementsMap, ? linearElement.startBinding?.elementId
zoom, : linearElement.endBinding?.elementId;
), if (elementId) {
); const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = ( const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
@@ -268,7 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@@ -279,7 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: "keep"; : "keep";
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@@ -311,7 +298,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@@ -322,7 +309,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@@ -441,22 +428,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = ( const normalizePointBinding = (
binding: { focus: number; gap: number }, binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
) => { ) => ({
let gap = binding.gap; ...binding,
const maxGap = maxBindingGap( gap: Math.min(
hoveredElement, binding.gap,
hoveredElement.width, maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
hoveredElement.height, ),
); });
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
@@ -488,6 +466,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(),
), ),
}; };
} }
@@ -703,8 +682,13 @@ const calculateFocusAndGap = (
); );
return { return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), focus: determineFocusDistance(
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), hoveredElement,
elementsMap,
adjacentPoint,
edgePoint,
),
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
}; };
}; };
@@ -874,6 +858,7 @@ export const getHeadingForElbowArrowSnap = (
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -882,11 +867,16 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading; return otherPointHeading;
} }
const distance = getDistanceForBinding(origPoint, bindableElement, zoom); const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement)), vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
); );
} }
@@ -896,9 +886,10 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToElement(bindableElement, elementsMap, point);
const bindDistance = maxBindingGap( const bindDistance = maxBindingGap(
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
@@ -913,12 +904,13 @@ export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
if (isDevEnv() || isTestEnv()) { if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
} }
const aabb = aabbForElement(bindableElement); const aabb = aabbForElement(bindableElement, elementsMap);
const localP = const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>( const globalP = pointFrom<GlobalPoint>(
@@ -926,7 +918,7 @@ export const bindPointToSnapToElementOutline = (
arrow.y + localP[1], arrow.y + localP[1],
); );
const edgePoint = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP) ? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP; : globalP;
const elbowed = isElbowArrow(arrow); const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
@@ -945,26 +937,31 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP), headingForPointFromElement(bindableElement, aabb, globalP),
); );
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : edgePoint[1], !isHorizontal ? center[1] : snapPoint[1],
);
const intersector = lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
); );
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment( elementsMap,
otherPoint, intersector,
pointFromVector( FIXED_BINDING_DISTANCE,
vectorScale( ).sort(pointDistanceSq)[0];
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
),
)[0];
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment( lineSegment(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@@ -991,31 +988,15 @@ export const bindPointToSnapToElementOutline = (
return edgePoint; return edgePoint;
} }
if (elbowed) { return elbowed ? intersection : edgePoint;
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
}; };
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@@ -1108,35 +1089,34 @@ export const avoidRectangularCorner = (
export const snapToMid = ( export const snapToMid = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
const center = elementCenterPoint(element, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80); const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80);
if ( if (
nonRotated[0] <= x + width / 2 && nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
@@ -1146,8 +1126,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
@@ -1157,8 +1137,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[1] >= y + height / 2 && nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
@@ -1167,7 +1147,7 @@ export const snapToMid = (
angle, angle,
); );
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1; const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>( const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance, x + width / 4 - distance,
y + height / 4 - distance, y + height / 4 - distance,
@@ -1184,27 +1164,28 @@ export const snapToMid = (
x + (3 * width) / 4 + distance, x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance, y + (3 * height) / 4 + distance,
); );
if ( if (
pointDistance(topLeft, nonRotated) < pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topLeft, center, angle); return pointRotateRads(topLeft, center, angle);
} }
if ( if (
pointDistance(topRight, nonRotated) < pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topRight, center, angle); return pointRotateRads(topRight, center, angle);
} }
if ( if (
pointDistance(bottomLeft, nonRotated) < pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomLeft, center, angle); return pointRotateRads(bottomLeft, center, angle);
} }
if ( if (
pointDistance(bottomRight, nonRotated) < pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomRight, center, angle); return pointRotateRads(bottomRight, center, angle);
} }
@@ -1239,8 +1220,9 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
@@ -1266,6 +1248,7 @@ const updateBoundPoint = (
); );
const focusPointAbsolute = determineFocusPoint( const focusPointAbsolute = determineFocusPoint(
bindableElement, bindableElement,
elementsMap,
binding.focus, binding.focus,
adjacentPoint, adjacentPoint,
); );
@@ -1284,7 +1267,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
const center = elementCenterPoint(bindableElement); const center = elementCenterPoint(bindableElement, elementsMap);
const interceptorLength = const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
@@ -1292,6 +1275,7 @@ const updateBoundPoint = (
const intersections = [ const intersections = [
...intersectElementWithLineSegment( ...intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@@ -1342,6 +1326,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>, linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@@ -1353,6 +1338,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap,
); );
const globalMidPoint = pointFrom( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
@@ -1396,7 +1382,7 @@ const maybeCalculateNewGapWhenScaling = (
return { ...currentBinding, gap: newGap }; return { ...currentBinding, gap: newGap };
}; };
const getElligibleElementForBindingElement = ( const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
@@ -1548,14 +1534,38 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element);
const shape = getElementShape(element, elementsMap); // PERF: Run a cheap test to see if the binding element
return ( // is even close to the element
isPointOnShape(pointFrom(x, y), shape, threshold) || const bounds = [
(fullShape === true && x - threshold,
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) y - threshold,
x + threshold,
y + threshold,
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
); );
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@@ -1575,7 +1585,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements // bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32), Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight // keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET, BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
); );
}; };
@@ -1586,12 +1596,13 @@ export const maxBindingGap = (
// of the element. // of the element.
const determineFocusDistance = ( const determineFocusDistance = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: GlobalPoint, a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint, b: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (pointsEqual(a, b)) { if (pointsEqual(a, b)) {
return 0; return 0;
@@ -1716,12 +1727,13 @@ const determineFocusDistance = (
const determineFocusPoint = ( const determineFocusPoint = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// The oriented, relative distance from the center of `element` of the // The oriented, relative distance from the center of `element` of the
// returned focusPoint // returned focusPoint
focus: number, focus: number,
adjacentPoint: GlobalPoint, adjacentPoint: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (focus === 0) { if (focus === 0) {
return center; return center;
@@ -2144,6 +2156,7 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
@@ -2152,7 +2165,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
}; };
@@ -2176,6 +2189,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
@@ -2186,6 +2200,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],
+35 -3
View File
@@ -102,9 +102,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { static getBounds(
const cachedBounds = ElementBounds.boundsCache.get(element); element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if ( if (
cachedBounds?.version && cachedBounds?.version &&
@@ -115,6 +129,23 @@ export class ElementBounds {
) { ) {
return cachedBounds.bounds; return cachedBounds.bounds;
} }
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap); const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, { ElementBounds.boundsCache.set(element, {
@@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = ( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => { ): Bounds => {
return ElementBounds.getBounds(element, elementsMap); return ElementBounds.getBounds(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (
+318 -132
View File
@@ -2,51 +2,60 @@ import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
line,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ellipse, ellipse,
ellipseLineIntersectionPoints, ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes"; import { isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds"; import {
type Bounds,
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectangleElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
@@ -72,45 +81,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { export type HitTestArgs = {
x: number; point: GlobalPoint;
y: number;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape<Point>; threshold: number;
threshold?: number; elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ export const hitElementItself = ({
x, point,
y,
element, element,
shape, threshold,
threshold = 10, elementsMap,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs<Point>) => { }: HitTestArgs) => {
let hit = shouldTestInside(element) // Hit test against a frame's name
? // Since `inShape` tests STRICTLY againt the insides of a shape const hitFrameName = frameNameBound
// we would need `onShape` as well to include the "borders" ? isPointWithinBounds(
isPointInShape(pointFrom(x, y), shape) || pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
isPointOnShape(pointFrom(x, y), shape, threshold) point,
: isPointOnShape(pointFrom(x, y), shape, threshold); pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// hit test against a frame's name // Hit test against the extended, rotated bounding box of the element first
if (!hit && frameNameBound) { const bounds = getElementBounds(element, elementsMap, true);
hit = isPointInShape(pointFrom(x, y), { const hitBounds = isPointWithinBounds(
type: "polygon", pointFrom(bounds[0] - threshold, bounds[1] - threshold),
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) pointRotateRads(
.data as Polygon<Point>, point,
}); getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
} }
return hit; // Do the precise (and relatively costly) hit test
const hitElement = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
x: number, point: GlobalPoint,
y: number,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
@@ -120,37 +148,42 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds( return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = (
Point extends GlobalPoint | LocalPoint, hitArgs: HitTestArgs,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) =>
return ( !hitElementItself(hitArgs) &&
!hitElementItself(hitArgs) && // bound text is considered part of the element (even if it's outside the bounding box)
// bound text is considered part of the element (even if it's outside the bounding box) !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
!hitElementBoundText( hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( export const hitElementBoundText = (
x: number, point: GlobalPoint,
y: number, element: ExcalidrawElement,
textShape: GeometricShape<Point> | null, elementsMap: ElementsMap,
): boolean => { ): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape); const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
}; };
/** /**
@@ -163,9 +196,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
*/ */
export const intersectElementWithLineSegment = ( export const intersectElementWithLineSegment = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>, line: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "image": case "image":
@@ -173,23 +223,88 @@ export const intersectElementWithLineSegment = (
case "iframe": case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "selection":
case "magicframe": case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset); return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond": case "diamond":
return intersectDiamondWithLineSegment(element, line, offset); return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse": case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset); return intersectEllipseWithLineSegment(
default: element,
throw new Error(`Unimplemented element type '${element.type}'`); elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(
element,
elementsMap,
line,
onlyFirst,
);
} }
}; };
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
onlyFirst = false,
): GlobalPoint[] => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = ( const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>( const rotatedA = pointRotateRads<GlobalPoint>(
@@ -206,34 +321,37 @@ const intersectRectanguloidWithLineSegment = (
// Get the element's building components we can test against // Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset); const [sides, corners] = deconstructRectanguloidElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space for (const s of sides) {
sides const intersection = lineSegmentIntersectionPoints(
.map((s) => lineSegment(rotatedA, rotatedB),
lineSegmentIntersectionPoints( s,
lineSegment<GlobalPoint>(rotatedA, rotatedB), );
s, if (intersection) {
), intersections.push(pointRotateRads(intersection, center, element.angle));
)
.filter((x) => x != null) if (onlyFirst) {
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)) return intersections;
// 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 for (const t of corners) {
.flatMap((t) => const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
) if (hits.length > 0) {
.filter((i) => i != null) for (const j of hits) {
.map((j) => pointRotateRads(j, center, element.angle)), intersections.push(pointRotateRads(j, center, element.angle));
) }
// Remove duplicates
.filter( if (onlyFirst) {
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, return intersections;
) }
); }
}
return intersections;
}; };
/** /**
@@ -245,43 +363,51 @@ const intersectRectanguloidWithLineSegment = (
*/ */
const intersectDiamondWithLineSegment = ( const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element, offset); const [sides, corners] = deconstructDiamondElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
sides
.map((s) => for (const s of sides) {
lineSegmentIntersectionPoints( const intersection = lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
s, s,
), );
) if (intersection) {
.filter((p): p is GlobalPoint => p != null) intersections.push(pointRotateRads(intersection, center, element.angle));
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)) if (onlyFirst) {
.concat( return intersections;
curves }
.flatMap((p) => }
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), }
)
.filter((p) => p != null) for (const t of corners) {
// Rotate back intersection points const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
.map((p) => pointRotateRads(p, center, element.angle)),
) if (hits.length > 0) {
// Remove duplicates for (const j of hits) {
.filter( intersections.push(pointRotateRads(j, center, element.angle));
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, }
)
); if (onlyFirst) {
return intersections;
}
}
}
return intersections;
}; };
/** /**
@@ -293,16 +419,76 @@ const intersectDiamondWithLineSegment = (
*/ */
const intersectEllipseWithLineSegment = ( const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints( return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset), ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle)); ).map((p) => pointRotateRads(p, center, element.angle));
}; };
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};
+2 -1
View File
@@ -34,6 +34,7 @@ export const MINIMAL_CROP_SIZE = 10;
export const cropElement = ( export const cropElement = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType, transformHandle: TransformHandleType,
naturalWidth: number, naturalWidth: number,
naturalHeight: number, naturalHeight: number,
@@ -63,7 +64,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
-element.angle as Radians, -element.angle as Radians,
); );
+262 -93
View File
@@ -5,11 +5,12 @@ import {
isDevEnv, isDevEnv,
isShallowEqual, isShallowEqual,
isTestEnv, isTestEnv,
randomInteger,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
@@ -18,7 +19,12 @@ import type {
SceneElementsMap, SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; import type {
DTO,
Mutable,
SubtypeOf,
ValueOf,
} from "@excalidraw/common/utility-types";
import type { import type {
AppState, AppState,
@@ -51,6 +57,8 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene"; import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -73,13 +81,20 @@ export class Delta<T> {
public static create<T>( public static create<T>(
deleted: Partial<T>, deleted: Partial<T>,
inserted: Partial<T>, inserted: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>, modifier?: (
modifierOptions?: "deleted" | "inserted", delta: Partial<T>,
partialType: "deleted" | "inserted",
) => Partial<T>,
modifierOptions?: "deleted" | "inserted" | "both",
) { ) {
const modifiedDeleted = const modifiedDeleted =
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; modifier && modifierOptions !== "inserted"
? modifier(deleted, "deleted")
: deleted;
const modifiedInserted = const modifiedInserted =
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; modifier && modifierOptions !== "deleted"
? modifier(inserted, "inserted")
: inserted;
return new Delta(modifiedDeleted, modifiedInserted); return new Delta(modifiedDeleted, modifiedInserted);
} }
@@ -113,11 +128,7 @@ export class Delta<T> {
// - we do this only on previously detected changed elements // - we do this only on previously detected changed elements
// - we do shallow compare only on the first level of properties (not going any deeper) // - we do shallow compare only on the first level of properties (not going any deeper)
// - # of properties is reasonably small // - # of properties is reasonably small
for (const key of this.distinctKeysIterator( for (const key of this.getDifferences(prevObject, nextObject)) {
"full",
prevObject,
nextObject,
)) {
deleted[key as keyof T] = prevObject[key]; deleted[key as keyof T] = prevObject[key];
inserted[key as keyof T] = nextObject[key]; inserted[key as keyof T] = nextObject[key];
} }
@@ -256,12 +267,14 @@ export class Delta<T> {
arrayToObject(deletedArray, groupBy), arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy), arrayToObject(insertedArray, groupBy),
), ),
(x) => x,
); );
const insertedDifferences = arrayToObject( const insertedDifferences = arrayToObject(
Delta.getRightDifferences( Delta.getRightDifferences(
arrayToObject(deletedArray, groupBy), arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy), arrayToObject(insertedArray, groupBy),
), ),
(x) => x,
); );
if ( if (
@@ -320,6 +333,42 @@ export class Delta<T> {
return !!anyDistinctKey; return !!anyDistinctKey;
} }
/**
* Compares if shared properties of object1 and object2 contain any different value (aka inner join).
*/
public static isInnerDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = !!this.distinctKeysIterator(
"inner",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Compares if any properties of object1 and object2 contain any different value (aka full join).
*/
public static isDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = !!this.distinctKeysIterator(
"full",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/** /**
* Returns sorted object1 keys that have distinct values. * Returns sorted object1 keys that have distinct values.
*/ */
@@ -346,6 +395,32 @@ export class Delta<T> {
).sort(); ).sort();
} }
/**
* Returns sorted keys of shared object1 and object2 properties that have distinct values (aka inner join).
*/
public static getInnerDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("inner", object1, object2, skipShallowCompare),
).sort();
}
/**
* Returns sorted keys that have distinct values between object1 and object2 (aka full join).
*/
public static getDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("full", object1, object2, skipShallowCompare),
).sort();
}
/** /**
* Iterator comparing values of object properties based on the passed joining strategy. * Iterator comparing values of object properties based on the passed joining strategy.
* *
@@ -354,7 +429,7 @@ export class Delta<T> {
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that. * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
*/ */
private static *distinctKeysIterator<T extends {}>( private static *distinctKeysIterator<T extends {}>(
join: "left" | "right" | "full", join: "left" | "right" | "inner" | "full",
object1: T, object1: T,
object2: T, object2: T,
skipShallowCompare = false, skipShallowCompare = false,
@@ -369,6 +444,8 @@ export class Delta<T> {
keys = Object.keys(object1); keys = Object.keys(object1);
} else if (join === "right") { } else if (join === "right") {
keys = Object.keys(object2); keys = Object.keys(object2);
} else if (join === "inner") {
keys = Object.keys(object1).filter((key) => key in object2);
} else if (join === "full") { } else if (join === "full") {
keys = Array.from( keys = Array.from(
new Set([...Object.keys(object1), ...Object.keys(object2)]), new Set([...Object.keys(object1), ...Object.keys(object2)]),
@@ -382,17 +459,17 @@ export class Delta<T> {
} }
for (const key of keys) { for (const key of keys) {
const object1Value = object1[key as keyof T]; const value1 = object1[key as keyof T];
const object2Value = object2[key as keyof T]; const value2 = object2[key as keyof T];
if (object1Value !== object2Value) { if (value1 !== value2) {
if ( if (
!skipShallowCompare && !skipShallowCompare &&
typeof object1Value === "object" && typeof value1 === "object" &&
typeof object2Value === "object" && typeof value2 === "object" &&
object1Value !== null && value1 !== null &&
object2Value !== null && value2 !== null &&
isShallowEqual(object1Value, object2Value) isShallowEqual(value1, value2)
) { ) {
continue; continue;
} }
@@ -617,14 +694,20 @@ export class AppStateDelta implements DeltaContainer<AppState> {
break; break;
case "croppingElementId": { case "croppingElementId": {
const croppingElementId = nextAppState[key]; const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) { if (!croppingElementId) {
// previously there was a croppingElementId (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
nextAppState[key] = null; const element = nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
} }
break; break;
} }
case "editingGroupId": case "editingGroupId":
@@ -858,10 +941,17 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>, Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
"seed"
>; export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>;
};
type ApplyToFlags = {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
};
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -944,13 +1034,33 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted; }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static satisfiesCommmonInvariants = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
!!(
deleted.version &&
inserted.version &&
// versions are required integers
Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) &&
// versions should be positive, zero included
deleted.version >= 0 &&
inserted.version >= 0 &&
// versions should never be the same
deleted.version !== inserted.version
);
private static validate( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean, satifiesSpecialInvariants: (delta: Delta<ElementPartial>) => boolean,
) { ) {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) { if (
!this.satisfiesCommmonInvariants(delta) ||
!satifiesSpecialInvariants(delta)
) {
console.error( console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`, `Broken invariant for "${type}" delta, element "${id}", delta:`,
delta, delta,
@@ -986,7 +1096,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial; const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { isDeleted: true } as ElementPartial;
const inserted = {
isDeleted: true,
version: prevElement.version + 1,
versionNonce: randomInteger(),
} as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
deleted, deleted,
@@ -1002,7 +1117,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const prevElement = prevElements.get(nextElement.id); const prevElement = prevElements.get(nextElement.id);
if (!prevElement) { if (!prevElement) {
const deleted = { isDeleted: true } as ElementPartial; const deleted = {
isDeleted: true,
version: nextElement.version - 1,
versionNonce: randomInteger(),
} as ElementPartial;
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false, isDeleted: false,
@@ -1087,16 +1207,40 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
/** /**
* Update delta/s based on the existing elements. * Update delta/s based on the existing elements.
* *
* @param elements current elements * @param nextElements current elements
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s * @returns new instance with modified delta/s
*/ */
public applyLatestChanges( public applyLatestChanges(
elements: SceneElementsMap, prevElements: SceneElementsMap,
modifierOptions: "deleted" | "inserted", nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): ElementsDelta { ): ElementsDelta {
const modifier = const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => { (
prevElement: OrderedExcalidrawElement | undefined,
nextElement: OrderedExcalidrawElement | undefined,
) =>
(partial: ElementPartial, partialType: "deleted" | "inserted") => {
let element: OrderedExcalidrawElement | undefined;
switch (partialType) {
case "deleted":
element = prevElement;
break;
case "inserted":
element = nextElement;
break;
}
// the element wasn't found -> don't update the partial
if (!element) {
console.error(
`Element not found when trying to apply latest changes`,
);
return partial;
}
const latestPartial: { [key: string]: unknown } = {}; const latestPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial) as Array<keyof typeof partial>) { for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
@@ -1120,19 +1264,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {}; const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) { for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id); const prevElement = prevElements.get(id);
const nextElement = nextElements.get(id);
if (existingElement) { let latestDelta: Delta<ElementPartial> | null = null;
const modifiedDelta = Delta.create(
if (prevElement || nextElement) {
latestDelta = Delta.create(
delta.deleted, delta.deleted,
delta.inserted, delta.inserted,
modifier(existingElement), modifier(prevElement, nextElement),
modifierOptions, modifierOptions,
); );
modifiedDeltas[id] = modifiedDelta;
} else { } else {
modifiedDeltas[id] = delta; latestDelta = delta;
}
// it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
modifiedDeltas[id] = latestDelta;
} }
} }
@@ -1150,12 +1300,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
elementsSnapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
}; };
@@ -1164,13 +1317,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
nextElements, nextElements,
elementsSnapshot, snapshot,
options,
flags, flags,
); );
const addedElements = applyDeltas("added", this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas("removed", this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas("updated", this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(elements, nextElements);
@@ -1229,18 +1383,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createApplier = private static createApplier =
( (
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"],
flags: { options: ApplyToOptions,
containsVisibleDifference: boolean; flags: ApplyToFlags,
containsZindexDifference: boolean;
},
) => ) =>
( (deltas: Record<string, Delta<ElementPartial>>) => {
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
type,
nextElements, nextElements,
snapshot, snapshot,
flags, flags,
@@ -1250,7 +1398,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsDelta.applyDelta(element, delta, flags); const newElement = ElementsDelta.applyDelta(
element,
delta,
options,
flags,
);
nextElements.set(newElement.id, newElement); nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement); acc.set(newElement.id, newElement);
} }
@@ -1261,13 +1415,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createGetter = private static createGetter =
( (
type: "added" | "removed" | "updated",
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"],
flags: { flags: ApplyToFlags,
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) => ) =>
(id: string, partial: ElementPartial) => { (id: string, partial: ElementPartial) => {
let element = elements.get(id); let element = elements.get(id);
@@ -1281,10 +1431,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
flags.containsZindexDifference = true; flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change // as the element was force deleted, we need to check if adding it back results in a visible change
if ( if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true; flags.containsVisibleDifference = true;
} }
} else { } else {
@@ -1304,16 +1451,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
flags: { options: ApplyToOptions,
containsVisibleDifference: boolean; flags: ApplyToFlags,
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) { ) {
const { boundElements, ...directlyApplicablePartial } = delta.inserted; const directlyApplicablePartial: Mutable<ElementPartial> = {};
// some properties are not directly applicable, such as:
// - boundElements which contains only diff)
// - version & versionNonce, if we don't want to return to previous versions
for (const key of Object.keys(delta.inserted) as Array<
keyof typeof delta.inserted
>) {
if (key === "boundElements") {
continue;
}
if (options.excludedProperties.has(key)) {
continue;
}
const value = delta.inserted[key];
Reflect.set(directlyApplicablePartial, key, value);
}
if ( if (
delta.deleted.boundElements?.length || delta.deleted.boundElements?.length ||
@@ -1331,19 +1490,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}); });
} }
// TODO: this looks wrong, shouldn't be here
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) { if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change // strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
@@ -1650,6 +1796,29 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
): [ElementPartial, ElementPartial] { ): [ElementPartial, ElementPartial] {
try { try {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
// don't diff the points as:
// - we can't ensure the multiplayer order consistency without fractional index on each point
// - we prefer to not merge the points, as it might just lead to unexpected / incosistent results
const deletedPoints =
(
deleted as ElementPartial<
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
>
).points ?? [];
const insertedPoints =
(
inserted as ElementPartial<
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
>
).points ?? [];
if (!Delta.isDifferent(deletedPoints, insertedPoints)) {
// delete the points from delta if there is no difference, otherwise leave them as they were captured due to consistency
Reflect.deleteProperty(deleted, "points");
Reflect.deleteProperty(inserted, "points");
}
} catch (e) { } catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements delta.`); console.error(`Couldn't postprocess elements delta.`);
@@ -1665,7 +1834,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static stripIrrelevantProps( private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>, partial: Partial<OrderedExcalidrawElement>,
): ElementPartial { ): ElementPartial {
const { id, updated, version, versionNonce, ...strippedPartial } = partial; const { id, updated, ...strippedPartial } = partial;
return strippedPartial; return strippedPartial;
} }
+37 -9
View File
@@ -12,21 +12,27 @@ import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type { import type {
ExcalidrawBindableElement, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export const distanceToBindableElement = ( export const distanceToElement = (
element: ExcalidrawBindableElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
switch (element.type) { switch (element.type) {
case "selection":
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
return distanceToRectanguloidElement(element, p); return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond": case "diamond":
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, elementsMap, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@@ -52,9 +62,10 @@ export const distanceToBindableElement = (
*/ */
const distanceToRectanguloidElement = ( const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
*/ */
const distanceToDiamondElement = ( const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@@ -108,12 +120,28 @@ const distanceToDiamondElement = (
*/ */
const distanceToEllipseElement = ( const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint( return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle // Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians), pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2), ellipse(center, element.width / 2, element.height / 2),
); );
}; };
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};
+23 -56
View File
@@ -29,10 +29,9 @@ import {
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@@ -898,50 +897,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points }; return { points: updates.points ?? arrow.points };
} }
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {
invariant( invariant(
!updates.points || updates.points.length >= 2, !updates.points || updates.points.length >= 2,
@@ -1273,6 +1228,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
hoveredStartElement, hoveredStartElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
@@ -1286,6 +1242,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
hoveredEndElement, hoveredEndElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
@@ -1293,12 +1250,14 @@ const getElbowArrowData = (
endGlobalPoint, endGlobalPoint,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@@ -1315,6 +1274,7 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
@@ -1327,6 +1287,7 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
@@ -1342,6 +1303,7 @@ const getElbowArrowData = (
hoveredEndElement hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
) )
: endPointBounds, : endPointBounds,
@@ -1351,6 +1313,7 @@ const getElbowArrowData = (
hoveredStartElement hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
) )
: startPointBounds, : startPointBounds,
@@ -1397,8 +1360,8 @@ const getElbowArrowData = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement), hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -2229,34 +2192,35 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (element) { if (element && elementsMap) {
const snapPoint = bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (element) { if (element && elementsMap) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
element, element,
elementsMap,
); );
// NOTE: Resize scales the binding position point too, so we need to update it // NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs( return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) - distanceToElement(element, elementsMap, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@@ -2268,6 +2232,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading => ): Heading =>
getHeadingForElbowArrowSnap( getHeadingForElbowArrowSnap(
p, p,
@@ -2276,7 +2241,8 @@ const getBindPointHeading = (
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number, number,
number, number,
number, number,
@@ -2284,6 +2250,7 @@ const getBindPointHeading = (
], ],
), ),
origPoint, origPoint,
elementsMap,
); );
const getHoveredElement = ( const getHoveredElement = (
+5 -4
View File
@@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>; ) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(
edgePoint[0] + el.x, node,
edgePoint[1] + el.y, aabbForElement(node, elementsMap),
] as Readonly<GlobalPoint>); [edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({ acc.push({
relative, relative,
+35 -9
View File
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { mutateElement } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
@@ -11,6 +11,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap,
} from "./types"; } from "./types";
export class InvalidFractionalIndexError extends Error { export class InvalidFractionalIndexError extends Error {
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements // try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) => const elementsCandidates = elements.map((x) => {
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x, const elementUpdates = elementsUpdates.get(x);
);
if (elementUpdates) {
return { ...x, index: elementUpdates.index };
}
return x;
});
// ensure next indices are valid before mutation, throws on invalid ones // ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices( validateFractionalIndices(
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
); );
// split mutation so we don't end up in an incosistent state // split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
} catch (e) { } catch (e) {
// fallback to default sync // fallback to default sync
@@ -189,7 +196,7 @@ export const syncMovedIndices = (
}; };
/** /**
* Synchronizes all invalid fractional indices with the array order by mutating passed elements. * Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
* *
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself. * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/ */
@@ -200,13 +207,32 @@ export const syncInvalidIndices = (
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
return elements as OrderedExcalidrawElement[]; return elements as OrderedExcalidrawElement[];
}; };
/**
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndicesImmutable = (
elements: readonly ExcalidrawElement[],
): SceneElementsMap | undefined => {
const syncedElements = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index }] of elementsUpdates) {
syncedElements.set(element.id, newElementWith(element, { index }));
}
return syncedElements as SceneElementsMap;
};
/** /**
* Get contiguous groups of indices of passed moved elements. * Get contiguous groups of indices of passed moved elements.
* *
+147 -69
View File
@@ -63,10 +63,13 @@ import {
getControlPointsForBezierCurve, getControlPointsForBezierCurve,
mapIntervalToBezierT, mapIntervalToBezierT,
getBezierXY, getBezierXY,
toggleLinePolygonState,
} from "./shapes"; } from "./shapes";
import { getLockedLinearCursorAlignSize } from "./sizeHelpers"; import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import { isLineElement } from "./typeChecks";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
import type { Bounds } from "./bounds"; import type { Bounds } from "./bounds";
@@ -85,6 +88,35 @@ import type {
PointsPositionUpdates, PointsPositionUpdates,
} from "./types"; } from "./types";
/**
* Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase.
*
* Also returns the offsets - [0,0] if no normalization needed.
*
* @private
*/
const getNormalizedPoints = ({
points,
}: {
points: ExcalidrawLinearElement["points"];
}): {
points: LocalPoint[];
offsetX: number;
offsetY: number;
} => {
const offsetX = points[0][0];
const offsetY = points[0][1];
return {
points: points.map((p) => {
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
offsetX,
offsetY,
};
};
export class LinearElementEditor { export class LinearElementEditor {
public readonly elementId: ExcalidrawElement["id"] & { public readonly elementId: ExcalidrawElement["id"] & {
_brand: "excalidrawLinearElementId"; _brand: "excalidrawLinearElementId";
@@ -117,6 +149,7 @@ export class LinearElementEditor {
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
constructor( constructor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
@@ -127,7 +160,11 @@ export class LinearElementEditor {
}; };
if (!pointsEqual(element.points[0], pointFrom(0, 0))) { if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
console.error("Linear element is not normalized", Error().stack); console.error("Linear element is not normalized", Error().stack);
LinearElementEditor.normalizePoints(element, elementsMap); mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
);
} }
this.selectedPointsIndices = null; this.selectedPointsIndices = null;
this.lastUncommittedPoint = null; this.lastUncommittedPoint = null;
@@ -150,6 +187,7 @@ export class LinearElementEditor {
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -253,6 +291,7 @@ export class LinearElementEditor {
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) { if (!element) {
return null; return null;
} }
@@ -293,6 +332,12 @@ export class LinearElementEditor {
const selectedIndex = selectedPointsIndices[0]; const selectedIndex = selectedPointsIndices[0];
const referencePoint = const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
customLineAngle =
linearElementEditor.customLineAngle ??
Math.atan2(
element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
@@ -300,6 +345,7 @@ export class LinearElementEditor {
referencePoint, referencePoint,
pointFrom(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -421,6 +467,7 @@ export class LinearElementEditor {
? lastClickedPoint ? lastClickedPoint
: -1, : -1,
isDragging: true, isDragging: true,
customLineAngle,
}; };
} }
@@ -459,6 +506,18 @@ export class LinearElementEditor {
selectedPoint === element.points.length - 1 selectedPoint === element.points.length - 1
) { ) {
if (isPathALoop(element.points, appState.zoom.value)) { if (isPathALoop(element.points, appState.zoom.value)) {
if (isLineElement(element)) {
scene.mutateElement(
element,
{
...toggleLinePolygonState(element, true),
},
{
informMutation: false,
isDragging: false,
},
);
}
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
element, element,
scene, scene,
@@ -503,6 +562,8 @@ export class LinearElementEditor {
return { return {
...editingLinearElement, ...editingLinearElement,
...bindings, ...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding // if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift, // shift, deselect all points except the one clicked. If holding shift,
// toggle the point. // toggle the point.
@@ -524,6 +585,7 @@ export class LinearElementEditor {
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
}; };
} }
@@ -946,9 +1008,7 @@ export class LinearElementEditor {
if (!event.altKey) { if (!event.altKey) {
if (lastPoint === lastUncommittedPoint) { if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app.scene, [ LinearElementEditor.deletePoints(element, app, [points.length - 1]);
points.length - 1,
]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -999,7 +1059,7 @@ export class LinearElementEditor {
]), ]),
); );
} else { } else {
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]); LinearElementEditor.addPoints(element, app.scene, [newPoint]);
} }
return { return {
...appState.editingLinearElement, ...appState.editingLinearElement,
@@ -1142,40 +1202,23 @@ export class LinearElementEditor {
/** /**
* Normalizes line points so that the start point is at [0,0]. This is * Normalizes line points so that the start point is at [0,0]. This is
* expected in various parts of the codebase. Also returns new x/y to account * expected in various parts of the codebase.
* for the potential normalization. *
* Also returns normalized x and y coords to account for the normalization
* of the points.
*/ */
static getNormalizedPoints(element: ExcalidrawLinearElement): { static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
points: LocalPoint[]; const { points, offsetX, offsetY } = getNormalizedPoints(element);
x: number;
y: number;
} {
const { points } = element;
const offsetX = points[0][0];
const offsetY = points[0][1];
return { return {
points: points.map((p) => { points,
return pointFrom(p[0] - offsetX, p[1] - offsetY);
}),
x: element.x + offsetX, x: element.x + offsetX,
y: element.y + offsetY, y: element.y + offsetY,
}; };
} }
// element-mutating methods // element-mutating methods
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
static normalizePoints(
element: NonDeleted<ExcalidrawLinearElement>,
elementsMap: ElementsMap,
) {
mutateElement(
element,
elementsMap,
LinearElementEditor.getNormalizedPoints(element),
);
}
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState { static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant( invariant(
appState.editingLinearElement, appState.editingLinearElement,
@@ -1254,41 +1297,42 @@ export class LinearElementEditor {
static deletePoints( static deletePoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, app: AppClassProperties,
pointIndices: readonly number[], pointIndices: readonly number[],
) { ) {
let offsetX = 0; const isUncommittedPoint =
let offsetY = 0; app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const isDeletingOriginPoint = pointIndices.includes(0); const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
});
// if deleting first point, make the next to be [0,0] and recalculate const isPolygon = isLineElement(element) && element.polygon;
// positions of the rest with respect to it
if (isDeletingOriginPoint) { // keep polygon intact if deleting start/end point or uncommitted point
const firstNonDeletedPoint = element.points.find((point, idx) => { if (
return !pointIndices.includes(idx); isPolygon &&
}); (isUncommittedPoint ||
if (firstNonDeletedPoint) { pointIndices.includes(0) ||
offsetX = firstNonDeletedPoint[0]; pointIndices.includes(element.points.length - 1))
offsetY = firstNonDeletedPoint[1]; ) {
} nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
} }
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => { const {
if (!pointIndices.includes(idx)) { points: normalizedPoints,
acc.push( offsetX,
!acc.length offsetY,
? pointFrom(0, 0) } = getNormalizedPoints({ points: nextPoints });
: pointFrom(p[0] - offsetX, p[1] - offsetY),
);
}
return acc;
}, []);
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, app.scene,
nextPoints, normalizedPoints,
offsetX, offsetX,
offsetY, offsetY,
); );
@@ -1297,16 +1341,27 @@ export class LinearElementEditor {
static addPoints( static addPoints(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
scene: Scene, scene: Scene,
targetPoints: { point: LocalPoint }[], addedPoints: LocalPoint[],
) { ) {
const offsetX = 0; const nextPoints = [...element.points, ...addedPoints];
const offsetY = 0;
if (isLineElement(element) && element.polygon) {
nextPoints[0] = pointFrom(
nextPoints[nextPoints.length - 1][0],
nextPoints[nextPoints.length - 1][1],
);
}
const {
points: normalizedPoints,
offsetX,
offsetY,
} = getNormalizedPoints({ points: nextPoints });
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
LinearElementEditor._updatePoints( LinearElementEditor._updatePoints(
element, element,
scene, scene,
nextPoints, normalizedPoints,
offsetX, offsetX,
offsetY, offsetY,
); );
@@ -1323,17 +1378,37 @@ export class LinearElementEditor {
) { ) {
const { points } = element; const { points } = element;
// if polygon, move start and end points together
if (isLineElement(element) && element.polygon) {
const firstPointUpdate = pointUpdates.get(0);
const lastPointUpdate = pointUpdates.get(points.length - 1);
if (firstPointUpdate) {
pointUpdates.set(points.length - 1, {
point: pointFrom(
firstPointUpdate.point[0],
firstPointUpdate.point[1],
),
isDragging: firstPointUpdate.isDragging,
});
} else if (lastPointUpdate) {
pointUpdates.set(0, {
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
isDragging: lastPointUpdate.isDragging,
});
}
}
// in case we're moving start point, instead of modifying its position // in case we're moving start point, instead of modifying its position
// which would break the invariant of it being at [0,0], we move // which would break the invariant of it being at [0,0], we move
// all the other points in the opposite direction by delta to // all the other points in the opposite direction by delta to
// offset it. We do the same with actual element.x/y position, so // offset it. We do the same with actual element.x/y position, so
// this hacks are completely transparent to the user. // this hacks are completely transparent to the user.
const [deltaX, deltaY] =
const updatedOriginPoint =
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0); pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
const [offsetX, offsetY] = pointFrom<LocalPoint>(
deltaX - points[0][0], const [offsetX, offsetY] = updatedOriginPoint;
deltaY - points[0][1],
);
const nextPoints = isElbowArrow(element) const nextPoints = isElbowArrow(element)
? [ ? [
@@ -1503,6 +1578,7 @@ export class LinearElementEditor {
isDragging: options?.isDragging ?? false, isDragging: options?.isDragging ?? false,
}); });
} else { } else {
// TODO do we need to get precise coords here just to calc centers?
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, element.points); const prevCoords = getElementPointsCoords(element, element.points);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2; const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
@@ -1511,7 +1587,7 @@ export class LinearElementEditor {
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2; const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
const dX = prevCenterX - nextCenterX; const dX = prevCenterX - nextCenterX;
const dY = prevCenterY - nextCenterY; const dY = prevCenterY - nextCenterY;
const rotated = pointRotateRads( const rotatedOffset = pointRotateRads(
pointFrom(offsetX, offsetY), pointFrom(offsetX, offsetY),
pointFrom(dX, dY), pointFrom(dX, dY),
element.angle, element.angle,
@@ -1519,8 +1595,8 @@ export class LinearElementEditor {
scene.mutateElement(element, { scene.mutateElement(element, {
...otherUpdates, ...otherUpdates,
points: nextPoints, points: nextPoints,
x: element.x + rotated[0], x: element.x + rotatedOffset[0],
y: element.y + rotated[1], y: element.y + rotatedOffset[1],
}); });
} }
} }
@@ -1531,6 +1607,7 @@ export class LinearElementEditor {
referencePoint: LocalPoint, referencePoint: LocalPoint,
scenePointer: GlobalPoint, scenePointer: GlobalPoint,
gridSize: NullableGridSize, gridSize: NullableGridSize,
customLineAngle?: number,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
@@ -1556,6 +1633,7 @@ export class LinearElementEditor {
referencePointCoords[1], referencePointCoords[1],
gridX, gridX,
gridY, gridY,
customLineAngle,
); );
return pointRotateRads( return pointRotateRads(
+5 -5
View File
@@ -23,7 +23,7 @@ import type {
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "updated"
>; >;
/** /**
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; element.version = updates.version ?? element.version + 1;
element.versionNonce = randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
return element; return element;
@@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return { return {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
}; };
}; };
+16 -1
View File
@@ -25,6 +25,8 @@ import { getBoundTextMaxWidth } from "./textElement";
import { normalizeText, measureText } from "./textMeasurements"; import { normalizeText, measureText } from "./textMeasurements";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
import { isLineElement } from "./typeChecks";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@@ -45,6 +47,7 @@ import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
} from "./types"; } from "./types";
export type ElementConstructorOpts = MarkOptional< export type ElementConstructorOpts = MarkOptional<
@@ -457,9 +460,10 @@ export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];
points?: ExcalidrawLinearElement["points"]; points?: ExcalidrawLinearElement["points"];
polygon?: ExcalidrawLineElement["polygon"];
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
return { const element = {
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts), ..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
points: opts.points || [], points: opts.points || [],
lastCommittedPoint: null, lastCommittedPoint: null,
@@ -468,6 +472,17 @@ export const newLinearElement = (
startArrowhead: null, startArrowhead: null,
endArrowhead: null, endArrowhead: null,
}; };
if (isLineElement(element)) {
const lineElement: NonDeleted<ExcalidrawLineElement> = {
...element,
polygon: opts.polygon ?? false,
};
return lineElement;
}
return element;
}; };
export const newArrowElement = <T extends boolean>( export const newArrowElement = <T extends boolean>(
+50 -1
View File
@@ -5,6 +5,7 @@ import {
ROUNDNESS, ROUNDNESS,
invariant, invariant,
elementCenterPoint, elementCenterPoint,
LINE_POLYGON_POINT_MERGE_DISTANCE,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
isPoint, isPoint,
@@ -35,10 +36,13 @@ import { ShapeCache } from "./ShapeCache";
import { getElementAbsoluteCoords, type Bounds } from "./bounds"; import { getElementAbsoluteCoords, type Bounds } from "./bounds";
import { canBecomePolygon } from "./typeChecks";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawLineElement,
NonDeleted, NonDeleted,
} from "./types"; } from "./types";
@@ -287,6 +291,7 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
*/ */
export const aabbForElement = ( export const aabbForElement = (
element: Readonly<ExcalidrawElement>, element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number], offset?: [number, number, number, number],
) => { ) => {
const bbox = { const bbox = {
@@ -298,7 +303,7 @@ export const aabbForElement = (
midY: element.y + element.height / 2, midY: element.y + element.height / 2,
}; };
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY), pointFrom(bbox.minX, bbox.minY),
center, center,
@@ -396,3 +401,47 @@ export const isPathALoop = (
} }
return false; return false;
}; };
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;
};
+58 -5
View File
@@ -2,14 +2,28 @@ import {
SHIFT_LOCKING_ANGLE, SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math";
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types"; import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getCommonBounds, getElementBounds } from "./bounds";
import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
} from "./typeChecks";
import type { ElementsMap, ExcalidrawElement } from "./types"; import type { ElementsMap, ExcalidrawElement } from "./types";
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted // TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize' // - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements` // - could also be part of `_clearElements`
@@ -17,8 +31,18 @@ export const isInvisiblySmallElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
): boolean => { ): boolean => {
if (isLinearElement(element) || isFreeDrawElement(element)) { if (isLinearElement(element) || isFreeDrawElement(element)) {
return element.points.length < 2; return (
element.points.length < 2 ||
(element.points.length === 2 &&
isArrowElement(element) &&
pointsEqual(
element.points[0],
element.points[element.points.length - 1],
INVISIBLY_SMALL_ELEMENT_SIZE,
))
);
} }
return element.width === 0 && element.height === 0; return element.width === 0 && element.height === 0;
}; };
@@ -134,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number, originY: number,
x: number, x: number,
y: number, y: number,
customAngle?: number,
) => { ) => {
let width = x - originX; let width = x - originX;
let height = y - originY; let height = y - originY;
const lockedAngle = const angle = Math.atan2(height, width) as Radians;
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) * let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE; SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) { if (lockedAngle === 0) {
height = 0; height = 0;
+78 -48
View File
@@ -19,9 +19,17 @@ import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta"; import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index"; import {
syncInvalidIndicesImmutable,
hashElementsVersion,
hashString,
} from "./index";
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types"; import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export const CaptureUpdateAction = { export const CaptureUpdateAction = {
/** /**
@@ -105,7 +113,7 @@ export class Store {
params: params:
| { | {
action: CaptureUpdateActionType; action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined; elements: readonly ExcalidrawElement[] | undefined;
appState: AppState | ObservedAppState | undefined; appState: AppState | ObservedAppState | undefined;
} }
| { | {
@@ -133,9 +141,15 @@ export class Store {
this.app.scene.getElementsMapIncludingDeleted(), this.app.scene.getElementsMapIncludingDeleted(),
this.app.state, this.app.state,
); );
const scheduledSnapshot = currentSnapshot.maybeClone( const scheduledSnapshot = currentSnapshot.maybeClone(
action, action,
params.elements, // let's sync invalid indices first, so that we could detect this change
// also have the synced elements immutable, so that we don't mutate elements,
// that are already in the scene, otherwise we wouldn't see any change
params.elements
? syncInvalidIndicesImmutable(params.elements)
: undefined,
params.appState, params.appState,
); );
@@ -213,16 +227,7 @@ export class Store {
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again // using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
storeDelta = delta; storeDelta = delta;
} else { } else {
// calculate the deltas based on the previous and next snapshot storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
} }
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
@@ -505,6 +510,24 @@ export class StoreDelta {
return new this(opts.id, elements, appState); return new this(opts.id, elements, appState);
} }
/**
* Calculate the delta between the previous and next snapshot.
*/
public static calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const elementsDelta = nextSnapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsDelta.empty();
const appStateDelta = nextSnapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateDelta.empty();
return this.create(elementsDelta, appStateDelta);
}
/** /**
* Restore a store delta instance from a DTO. * Restore a store delta instance from a DTO.
*/ */
@@ -524,9 +547,7 @@ export class StoreDelta {
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated, { const elements = ElementsDelta.create(added, removed, updated);
shouldRedistribute: false,
});
return new this(id, elements, AppStateDelta.empty()); return new this(id, elements, AppStateDelta.empty());
} }
@@ -534,27 +555,10 @@ export class StoreDelta {
/** /**
* Inverse store delta, creates new instance of `StoreDelta`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: StoreDelta): StoreDelta { public static inverse(delta: StoreDelta) {
return this.create(delta.elements.inverse(), delta.appState.inverse()); return this.create(delta.elements.inverse(), delta.appState.inverse());
} }
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(elements, modifierOptions),
delta.appState,
{
id: delta.id,
},
);
}
/** /**
* Apply the delta to the passed elements and appState, does not modify the snapshot. * Apply the delta to the passed elements and appState, does not modify the snapshot.
*/ */
@@ -562,12 +566,9 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] =
elements, delta.elements.applyTo(elements);
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@@ -578,6 +579,28 @@ export class StoreDelta {
return [nextElements, nextAppState, appliedVisibleChanges]; return [nextElements, nextAppState, appliedVisibleChanges];
} }
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(
prevElements,
nextElements,
modifierOptions,
),
delta.appState,
{
id: delta.id,
},
);
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@@ -687,11 +710,10 @@ export class StoreSnapshot {
nextElements.set(id, changedElement); nextElements.set(id, changedElement);
} }
const nextAppState = Object.assign( const nextAppState = getObservedAppState({
{}, ...this.appState,
this.appState, ...change.appState,
change.appState, });
) as ObservedAppState;
return StoreSnapshot.create(nextElements, nextAppState, { return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot // by default we assume that change is different from what we have in the snapshot
@@ -944,18 +966,26 @@ const getDefaultObservedAppState = (): ObservedAppState => {
}; };
}; };
export const getObservedAppState = (appState: AppState): ObservedAppState => { export const getObservedAppState = (
appState: AppState | ObservedAppState,
): ObservedAppState => {
const observedAppState = { const observedAppState = {
name: appState.name, name: appState.name,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
editingLinearElementId:
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
null,
selectedLinearElementId:
(appState as AppState).selectedLinearElement?.elementId ??
(appState as ObservedAppState).selectedLinearElementId ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+41
View File
@@ -1,5 +1,7 @@
import { ROUNDNESS, assertNever } from "@excalidraw/common"; import { ROUNDNESS, assertNever } from "@excalidraw/common";
import { pointsEqual } from "@excalidraw/math";
import type { ElementOrToolType } from "@excalidraw/excalidraw/types"; import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
import type { MarkNonNullable } from "@excalidraw/common/utility-types"; import type { MarkNonNullable } from "@excalidraw/common/utility-types";
@@ -25,6 +27,7 @@ import type {
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
ExcalidrawLineElement,
PointBinding, PointBinding,
FixedPointBinding, FixedPointBinding,
ExcalidrawFlowchartNodeElement, ExcalidrawFlowchartNodeElement,
@@ -108,6 +111,12 @@ export const isLinearElement = (
return element != null && isLinearElementType(element.type); return element != null && isLinearElementType(element.type);
}; };
export const isLineElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawLineElement => {
return element != null && element.type === "line";
};
export const isArrowElement = ( export const isArrowElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawArrowElement => {
@@ -120,6 +129,15 @@ export const isElbowArrow = (
return isArrowElement(element) && element.elbowed; return isArrowElement(element) && element.elbowed;
}; };
/**
* sharp or curved arrow, but not elbow
*/
export const isSimpleArrow = (
element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => {
return isArrowElement(element) && !element.elbowed;
};
export const isSharpArrow = ( export const isSharpArrow = (
element?: ExcalidrawElement, element?: ExcalidrawElement,
): element is ExcalidrawArrowElement => { ): element is ExcalidrawArrowElement => {
@@ -372,3 +390,26 @@ export const getLinearElementSubType = (
} }
return "line"; return "line";
}; };
/**
* Checks if current element points meet all the conditions for polygon=true
* (this isn't a element type check, for that use isLineElement).
*
* If you want to check if points *can* be turned into a polygon, use
* canBecomePolygon(points).
*/
export const isValidPolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
};
export const canBecomePolygon = (
points: ExcalidrawLineElement["points"],
): boolean => {
return (
points.length > 3 ||
// 3-point polygons can't have all points in a single line
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
+12 -3
View File
@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
@@ -296,8 +297,10 @@ export type FixedPointBinding = Merge<
} }
>; >;
type Index = number;
export type PointsPositionUpdates = Map< export type PointsPositionUpdates = Map<
number, Index,
{ point: LocalPoint; isDragging?: boolean } { point: LocalPoint; isDragging?: boolean }
>; >;
@@ -326,10 +329,16 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
endArrowhead: Arrowhead | null; endArrowhead: Arrowhead | null;
}>; }>;
export type ExcalidrawLineElement = ExcalidrawLinearElement &
Readonly<{
type: "line";
polygon: boolean;
}>;
export type FixedSegment = { export type FixedSegment = {
start: LocalPoint; start: LocalPoint;
end: LocalPoint; end: LocalPoint;
index: number; index: Index;
}; };
export type ExcalidrawArrowElement = ExcalidrawLinearElement & export type ExcalidrawArrowElement = ExcalidrawLinearElement &
+314 -239
View File
@@ -1,28 +1,168 @@
import { import {
curve, curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment, lineSegment,
pointFrom, pointFrom,
pointFromVector, pointFromArray,
rectangle, rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { Curve, LineSegment } from "@excalidraw/math";
import { getCornerRadius } from "./shapes"; import { getCornerRadius } from "./shapes";
import { getDiamondPoints } from "./bounds"; import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./Shape";
import type { import type {
ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
>();
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
offset: number,
): ElementShape | undefined => {
const record = ElementShapesCache.get(element);
if (!record) {
return undefined;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.delete(element);
return undefined;
}
return shapes.get(offset);
};
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
shape: ElementShape,
offset: number,
) => {
const record = ElementShapesCache.get(element);
if (!record) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
shapes.set(offset, shape);
};
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element, elementsMap) as {
op: string;
data: number[];
}[];
const lines = [];
const curves = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
lines.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
curves.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
const shape = [lines, curves] as ElementShape;
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
/** /**
* Get the building components of a rectanguloid element in the form of * Get the building components of a rectanguloid element in the form of
* line segments and curves. * line segments and curves.
@@ -35,175 +175,132 @@ export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius( const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height), Math.min(element.width, element.height),
element, element,
); );
if (roundness <= 0) { if (radius === 0) {
const r = rectangle( radius = 0.01;
pointFrom(element.x - offset, element.y - offset),
pointFrom(
element.x + element.width + offset,
element.y + element.height + offset,
),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const sides = [top, right, bottom, left];
return [sides, []];
} }
const center = elementCenterPoint(element);
const r = rectangle( const r = rectangle(
pointFrom(element.x, element.y), pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height), pointFrom(element.x + element.width, element.y + element.height),
); );
const top = lineSegment<GlobalPoint>( const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
); );
const right = lineSegment<GlobalPoint>( const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
); );
const bottom = lineSegment<GlobalPoint>( const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
); );
const left = lineSegment<GlobalPoint>( const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
); );
const offsets = [ const baseCorners = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
curve( curve(
pointFromVector(offsets[0], left[1]), left[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
pointFrom<GlobalPoint>( left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
pointFrom<GlobalPoint>( top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
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[0],
), // TOP LEFT ), // TOP LEFT
curve( curve(
pointFromVector(offsets[1], top[1]), top[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
pointFrom<GlobalPoint>( top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
pointFrom<GlobalPoint>( right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
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]), right[0],
), // TOP RIGHT ), // TOP RIGHT
curve( curve(
pointFromVector(offsets[2], right[1]), right[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
pointFrom<GlobalPoint>( right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
pointFrom<GlobalPoint>( bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
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[1],
), // BOTTOM RIGHT ), // BOTTOM RIGHT
curve( curve(
pointFromVector(offsets[3], bottom[0]), bottom[0],
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
pointFrom<GlobalPoint>( bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
pointFrom<GlobalPoint>( left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
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]), left[0],
), // BOTTOM LEFT ), // BOTTOM LEFT
]; ];
const sides = [ const corners =
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), offset > 0
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), ? baseCorners.map(
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), (corner) =>
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), curveCatmullRomCubicApproxPoints(
]; curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
return [sides, corners]; const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
/** /**
@@ -218,42 +315,20 @@ export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const cachedShape = getElementShapesCacheEntry(element, offset);
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) { if (cachedShape) {
const [top, right, bottom, left]: GlobalPoint[] = [ return cachedShape;
pointFrom(element.x + topX, element.y + topY - offset),
pointFrom(element.x + rightX + offset, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY + offset),
pointFrom(element.x + leftX - offset, element.y + leftY),
];
// Create the line segment parts of the diamond
// NOTE: Horizontal and vertical seems to be flipped here
const topRight = lineSegment<GlobalPoint>(
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
);
const bottomRight = lineSegment<GlobalPoint>(
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
);
const bottomLeft = lineSegment<GlobalPoint>(
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
);
const topLeft = lineSegment<GlobalPoint>(
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
);
return [[topRight, bottomRight, bottomLeft, topLeft], []];
} }
const center = elementCenterPoint(element); const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [ const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + topX, element.y + topY),
@@ -262,94 +337,94 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
const offsets = [ const baseCorners = [
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
];
const corners = [
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] - horizontalRadius,
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[0], right), right,
pointFromVector(offsets[0], right), right,
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] + horizontalRadius,
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), ),
), // RIGHT ), // RIGHT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] + verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[1], bottom), bottom,
pointFromVector(offsets[1], bottom), bottom,
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] - verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
), // BOTTOM ), // BOTTOM
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] + horizontalRadius,
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[2], left), left,
pointFromVector(offsets[2], left), left,
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] - horizontalRadius,
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), ),
), // LEFT ), // LEFT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] - verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[3], top), top,
pointFromVector(offsets[3], top), top,
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] + verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), ),
), // TOP ), // TOP
]; ];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [ const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), lineSegment<GlobalPoint>(
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), corners[0][corners[0].length - 1][3],
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), corners[1][0][0],
lineSegment<GlobalPoint>(corners[3][3], corners[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],
),
]; ];
return [sides, corners]; const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />
+10 -2
View File
@@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection // Add the created group to the current selection
mouse.restorePosition(0, 0); mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created // The second rectangle is already selected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down(); mouse.down();
mouse.up(100, 100); mouse.up(100, 100);
mouse.restorePosition(200, 200); mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created // The second group is already selected because it was the last group created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100); mouse.up(100, 100);
// Add group to current selection // Add group to current selection
mouse.restorePosition(0, 0); mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected // Select the nested group, the rectangle is already selected
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
+10 -16
View File
@@ -11,6 +11,10 @@ import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
const { h } = window; const { h } = window;
@@ -172,12 +176,12 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: 0, x: 0,
y: 0, y: 0,
size: 50, size: 49,
}); });
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50); mouse.downAt(49, 49);
mouse.moveTo(51, 0); mouse.moveTo(51, 0);
mouse.up(0, 0); mouse.up(0, 0);
@@ -244,18 +248,12 @@ describe("element binding", () => {
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "" } }); fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
}); });
@@ -285,18 +283,14 @@ describe("element binding", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id); expect(arrow.endBinding?.elementId).toBe(text.id);
}); });
+38
View File
@@ -0,0 +1,38 @@
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(87, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});
@@ -505,8 +505,6 @@ describe("group-related duplication", () => {
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50); mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
}); });
// console.log(h.elements);
assertElements(h.elements, [ assertElements(h.elements, [
{ id: frame.id }, { id: frame.id },
{ id: rectangle1.id, frameId: frame.id }, { id: rectangle1.id, frameId: frame.id },
@@ -1,6 +1,5 @@
import { pointCenter, pointFrom } from "@excalidraw/math"; import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react"; import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { import {
@@ -33,6 +32,11 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src"; import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src"; import { newArrowElement } from "../src";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -252,7 +256,49 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor when using double clicked with ctrl key", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
// ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
@@ -262,6 +308,37 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(null);
await getTextEditor();
});
it("shouldn't create text element on double click in line editor (arrow)", async () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
});
describe("Inside editor", () => { describe("Inside editor", () => {
it("should not drag line and add midpoint when dragged irrespective of threshold", () => { it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
@@ -990,12 +1067,10 @@ describe("Test Linear Elements", () => {
}); });
}); });
it("should match styles for text editor", () => { it("should match styles for text editor", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).toMatchSnapshot(); expect(editor).toMatchSnapshot();
}); });
@@ -1012,9 +1087,7 @@ describe("Test Linear Elements", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id); expect(text.containerId).toBe(arrow.id);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@@ -1042,9 +1115,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer; const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text"); expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id); expect(textElement.containerId).toBe(arrow.id);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@@ -1063,13 +1134,7 @@ describe("Test Linear Elements", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y); mouse.doubleClickAt(line.x, line.y);
expect(h.elements.length).toBe(1);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBeNull();
expect(line.boundElements).toBeNull();
}); });
// TODO fix #7029 and rewrite this test // TODO fix #7029 and rewrite this test
@@ -1234,9 +1299,7 @@ describe("Test Linear Elements", () => {
mouse.select(arrow); mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } }); fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@@ -1262,7 +1325,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0); expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@@ -1411,5 +1474,55 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20); expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).toBe(-20); expect(line.points[line.points.length - 1][1]).toBe(-20);
}); });
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(line);
const elementsMap = arrayToMap(h.elements);
const points = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate original angle between first and last point
const originalAngle = Math.atan2(
points[1][1] - points[0][1],
points[1][0] - points[0][0],
);
// Drag the second point (endpoint) with SHIFT key pressed
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
const endPoint = pointFrom<GlobalPoint>(
startPoint[0] + 4,
startPoint[1] + 4,
);
// Perform drag with SHIFT key modifier
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(startPoint[0], startPoint[1]);
mouse.moveTo(endPoint[0], endPoint[1]);
mouse.upAt(endPoint[0], endPoint[1]);
});
// Get updated points after drag
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate new angle
const newAngle = Math.atan2(
updatedPoints[1][1] - updatedPoints[0][1],
updatedPoints[1][0] - updatedPoints[0][0],
);
// The angle should be preserved (within a small tolerance for floating point precision)
const angleDifference = Math.abs(newAngle - originalAngle);
const tolerance = 0.01; // Small tolerance for floating point precision
expect(angleDifference).toBeLessThan(tolerance);
});
}); });
}); });
+6 -6
View File
@@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
@@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]); UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
const imageWidth = image.width; const imageWidth = image.width;
const scale = 20 / image.height; const scale = 20 / image.height;
@@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0); expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();
@@ -1,7 +1,5 @@
import { vi } from "vitest"; import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers"; import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3; const EPSILON_DIGITS = 3;
@@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS); expect(height).toBeCloseTo(0, EPSILON_DIGITS);
}); });
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
});
});
}); });
@@ -258,11 +258,7 @@ export const actionDeleteSelected = register({
: endBindingElement, : endBindingElement,
}; };
LinearElementEditor.deletePoints( LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
element,
app.scene,
selectedPointsIndices,
);
return { return {
elements, elements,
+133 -68
View File
@@ -3,10 +3,16 @@ import { pointFrom } from "@excalidraw/math";
import { import {
maybeBindLinearElement, maybeBindLinearElement,
bindOrUnbindLinearElement, bindOrUnbindLinearElement,
} from "@excalidraw/element"; isBindingEnabled,
import { LinearElementEditor } from "@excalidraw/element"; } from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import { isBindingElement, isLinearElement } from "@excalidraw/element"; import {
isBindingElement,
isFreeDrawElement,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common"; import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
import { isPathALoop } from "@excalidraw/element"; import { isPathALoop } from "@excalidraw/element";
@@ -15,6 +21,13 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
NonDeleted,
} from "@excalidraw/element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { done } from "../components/icons"; import { done } from "../components/icons";
@@ -28,11 +41,50 @@ export const actionFinalize = register({
name: "finalize", name: "finalize",
label: "", label: "",
trackEvent: false, trackEvent: false,
perform: (elements, appState, _, app) => { perform: (elements, appState, data, app) => {
const { interactiveCanvas, focusContainer, scene } = app; const { interactiveCanvas, focusContainer, scene } = app;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
if (data?.event && appState.selectedLinearElement) {
const linearElementEditor = LinearElementEditor.handlePointerUp(
data.event,
appState.selectedLinearElement,
appState,
app.scene,
);
const { startBindingElement, endBindingElement } = linearElementEditor;
const element = app.scene.getElement(linearElementEditor.elementId);
if (isBindingElement(element)) {
bindOrUnbindLinearElement(
element,
startBindingElement,
endBindingElement,
app.scene,
);
}
if (linearElementEditor !== appState.selectedLinearElement) {
let newElements = elements;
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id);
}
return {
elements: newElements,
appState: {
selectedLinearElement: {
...linearElementEditor,
selectedPointsIndices: null,
},
suggestedBindings: [],
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
}
}
if (appState.editingLinearElement) { if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } = const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement; appState.editingLinearElement;
@@ -47,6 +99,12 @@ export const actionFinalize = register({
scene, scene,
); );
} }
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
});
}
return { return {
elements: elements:
element.points.length < 2 || isInvisiblySmallElement(element) element.points.length < 2 || isInvisiblySmallElement(element)
@@ -64,91 +122,102 @@ export const actionFinalize = register({
let newElements = elements; let newElements = elements;
const pendingImageElement =
appState.pendingImageElementId &&
scene.getElement(appState.pendingImageElementId);
if (pendingImageElement) {
scene.mutateElement(
pendingImageElement,
{ isDeleted: true },
{ informMutation: false, isDragging: false },
);
}
if (window.document.activeElement instanceof HTMLElement) { if (window.document.activeElement instanceof HTMLElement) {
focusContainer(); focusContainer();
} }
const multiPointElement = appState.multiElement let element: NonDeleted<ExcalidrawElement> | null = null;
? appState.multiElement if (appState.multiElement) {
: appState.newElement?.type === "freedraw" element = appState.multiElement;
? appState.newElement } else if (
: null; appState.newElement?.type === "freedraw" ||
isBindingElement(appState.newElement)
) {
element = appState.newElement;
} else if (Object.keys(appState.selectedElementIds).length === 1) {
const candidate = elementsMap.get(
Object.keys(appState.selectedElementIds)[0],
) as NonDeleted<ExcalidrawLinearElement> | undefined;
if (candidate) {
element = candidate;
}
}
if (multiPointElement) { if (element) {
// pen and mouse have hover // pen and mouse have hover
if ( if (
multiPointElement.type !== "freedraw" && appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch" appState.lastPointerDownWith !== "touch"
) { ) {
const { points, lastCommittedPoint } = multiPointElement; const { points, lastCommittedPoint } = element;
if ( if (
!lastCommittedPoint || !lastCommittedPoint ||
points[points.length - 1] !== lastCommittedPoint points[points.length - 1] !== lastCommittedPoint
) { ) {
scene.mutateElement(multiPointElement, { scene.mutateElement(element, {
points: multiPointElement.points.slice(0, -1), points: element.points.slice(0, -1),
}); });
} }
} }
if (isInvisiblySmallElement(multiPointElement)) { if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter( newElements = newElements.filter((el) => el.id !== element!.id);
(el) => el.id !== multiPointElement.id,
);
} }
// If the multi point line closes the loop, if (isLinearElement(element) || isFreeDrawElement(element)) {
// set the last point to first point. // If the multi point line closes the loop,
// This ensures that loop remains closed at different scales. // set the last point to first point.
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value); // This ensures that loop remains closed at different scales.
if ( const isLoop = isPathALoop(element.points, appState.zoom.value);
multiPointElement.type === "line" ||
multiPointElement.type === "freedraw" if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
) { const linePoints = element.points;
if (isLoop) {
const linePoints = multiPointElement.points;
const firstPoint = linePoints[0]; const firstPoint = linePoints[0];
scene.mutateElement(multiPointElement, { const points: LocalPoint[] = linePoints.map((p, index) =>
points: linePoints.map((p, index) => index === linePoints.length - 1
index === linePoints.length - 1 ? pointFrom(firstPoint[0], firstPoint[1])
? pointFrom(firstPoint[0], firstPoint[1]) : p,
: p, );
), if (isLineElement(element)) {
scene.mutateElement(element, {
points,
polygon: true,
});
} else {
scene.mutateElement(element, {
points,
});
}
}
if (isLineElement(element) && !isValidPolygon(element.points)) {
scene.mutateElement(element, {
polygon: false,
}); });
} }
}
if ( if (
isBindingElement(multiPointElement) && isBindingElement(element) &&
!isLoop && !isLoop &&
multiPointElement.points.length > 1 element.points.length > 1 &&
) { isBindingEnabled(appState)
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( ) {
multiPointElement, const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
-1, element,
arrayToMap(elements), -1,
); arrayToMap(elements),
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene); );
maybeBindLinearElement(element, appState, { x, y }, scene);
}
} }
} }
if ( if (
(!appState.activeTool.locked && (!appState.activeTool.locked &&
appState.activeTool.type !== "freedraw") || appState.activeTool.type !== "freedraw") ||
!multiPointElement !element
) { ) {
resetCursor(interactiveCanvas); resetCursor(interactiveCanvas);
} }
@@ -175,7 +244,7 @@ export const actionFinalize = register({
activeTool: activeTool:
(appState.activeTool.locked || (appState.activeTool.locked ||
appState.activeTool.type === "freedraw") && appState.activeTool.type === "freedraw") &&
multiPointElement element
? appState.activeTool ? appState.activeTool
: activeTool, : activeTool,
activeEmbeddable: null, activeEmbeddable: null,
@@ -186,23 +255,19 @@ export const actionFinalize = register({
startBoundElement: null, startBoundElement: null,
suggestedBindings: [], suggestedBindings: [],
selectedElementIds: selectedElementIds:
multiPointElement && element &&
!appState.activeTool.locked && !appState.activeTool.locked &&
appState.activeTool.type !== "freedraw" appState.activeTool.type !== "freedraw"
? { ? {
...appState.selectedElementIds, ...appState.selectedElementIds,
[multiPointElement.id]: true, [element.id]: true,
} }
: appState.selectedElementIds, : appState.selectedElementIds,
// To select the linear element when user has finished mutipoint editing // To select the linear element when user has finished mutipoint editing
selectedLinearElement: selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement) element && isLinearElement(element)
? new LinearElementEditor( ? new LinearElementEditor(element, arrayToMap(newElements))
multiPointElement,
arrayToMap(newElements),
)
: appState.selectedLinearElement, : appState.selectedLinearElement,
pendingImageElementId: null,
}, },
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -1,19 +1,29 @@
import { LinearElementEditor } from "@excalidraw/element"; import { LinearElementEditor } from "@excalidraw/element";
import {
import { isElbowArrow, isLinearElement } from "@excalidraw/element"; isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawLinearElement } from "@excalidraw/element/types"; import type {
ExcalidrawLinearElement,
ExcalidrawLineElement,
} from "@excalidraw/element/types";
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"; import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { lineEditorIcon } from "../components/icons"; import { lineEditorIcon, polygonIcon } from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { ButtonIcon } from "../components/ButtonIcon";
import { newElementWith } from "../../element/src/mutateElement";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register"; import { register } from "./register";
export const actionToggleLinearEditor = register({ export const actionToggleLinearEditor = register({
@@ -83,3 +93,110 @@ export const actionToggleLinearEditor = register({
); );
}, },
}); });
export const actionTogglePolygon = register({
name: "togglePolygon",
category: DEFAULT_CATEGORIES.elements,
icon: polygonIcon,
keywords: ["loop"],
label: (elements, appState, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
const allPolygons = !selectedElements.some(
(element) => !isLineElement(element) || !element.polygon,
);
return allPolygons
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon";
},
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
return (
selectedElements.length > 0 &&
selectedElements.every(
(element) => isLineElement(element) && element.points.length >= 4,
)
);
},
perform(elements, appState, _, app) {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.some((element) => !isLineElement(element))) {
return false;
}
const targetElements = selectedElements as ExcalidrawLineElement[];
// if one element not a polygon, convert all to polygon
const nextPolygonState = targetElements.some((element) => !element.polygon);
const targetElementsMap = arrayToMap(targetElements);
return {
elements: elements.map((element) => {
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
return element;
}
return newElementWith(element, {
backgroundColor: nextPolygonState
? element.backgroundColor
: "transparent",
...toggleLinePolygonState(element, nextPolygonState),
});
}),
appState,
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
});
if (
selectedElements.length === 0 ||
selectedElements.some(
(element) =>
!isLineElement(element) ||
// only show polygon button if every selected element is already
// a polygon, effectively showing this button only to allow for
// disabling the polygon state
!element.polygon ||
element.points.length < 3,
)
) {
return null;
}
const allPolygon = selectedElements.every(
(element) => isLineElement(element) && element.polygon,
);
const label = t(
allPolygon
? "labels.polygon.breakPolygon"
: "labels.polygon.convertToPolygon",
);
return (
<ButtonIcon
icon={polygonIcon}
title={label}
aria-label={label}
active={allPolygon}
onClick={() => updateData(null)}
style={{ marginLeft: "auto" }}
/>
);
},
});
@@ -18,18 +18,16 @@ import {
arrayToMap, arrayToMap,
getFontFamilyString, getFontFamilyString,
getShortcutKey, getShortcutKey,
tupleToCoors,
getLineHeight, getLineHeight,
isTransparent,
reduceToCommonValue, reduceToCommonValue,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
import { import {
bindLinearElement, bindLinearElement,
bindPointToSnapToElementOutline,
calculateFixedPointForElbowArrowBinding, calculateFixedPointForElbowArrowBinding,
getHoveredElementForBinding,
updateBoundElements, updateBoundElements,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -47,6 +45,7 @@ import {
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -136,6 +135,8 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import { toggleLinePolygonState } from "../../element/src/shapes";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types"; import type { AppClassProperties, AppState, Primitive } from "../types";
@@ -349,22 +350,52 @@ export const actionChangeBackgroundColor = register({
name: "changeBackgroundColor", name: "changeBackgroundColor",
label: "labels.changeBackground", label: "labels.changeBackground",
trackEvent: false, trackEvent: false,
perform: (elements, appState, value) => { perform: (elements, appState, value, app) => {
return { if (!value.currentItemBackgroundColor) {
...(value.currentItemBackgroundColor && { return {
elements: changeProperty(elements, appState, (el) => appState: {
newElementWith(el, { ...appState,
...value,
},
captureUpdate: CaptureUpdateAction.EVENTUALLY,
};
}
let nextElements;
const selectedElements = app.scene.getSelectedElements(appState);
const shouldEnablePolygon =
!isTransparent(value.currentItemBackgroundColor) &&
selectedElements.every(
(el) => isLineElement(el) && canBecomePolygon(el.points),
);
if (shouldEnablePolygon) {
const selectedElementsMap = arrayToMap(selectedElements);
nextElements = elements.map((el) => {
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
return newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor, backgroundColor: value.currentItemBackgroundColor,
}), ...toggleLinePolygonState(el, true),
), });
}), }
return el;
});
} else {
nextElements = changeProperty(elements, appState, (el) =>
newElementWith(el, {
backgroundColor: value.currentItemBackgroundColor,
}),
);
}
return {
elements: nextElements,
appState: { appState: {
...appState, ...appState,
...value, ...value,
}, },
captureUpdate: !!value.currentItemBackgroundColor captureUpdate: CaptureUpdateAction.IMMEDIATELY,
? CaptureUpdateAction.IMMEDIATELY
: CaptureUpdateAction.EVENTUALLY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -1373,7 +1404,7 @@ export const actionChangeRoundness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => { PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
const targetElements = getTargetElements( const targetElements = getTargetElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
@@ -1417,6 +1448,7 @@ export const actionChangeRoundness = register({
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
{renderAction("togglePolygon")}
</div> </div>
</fieldset> </fieldset>
); );
@@ -1626,63 +1658,16 @@ export const actionChangeArrowType = register({
-1, -1,
elementsMap, elementsMap,
); );
const startHoveredElement = const startElement =
!newElement.startBinding && newElement.startBinding &&
getHoveredElementForBinding( (elementsMap.get(
tupleToCoors(startGlobalPoint), newElement.startBinding.elementId,
elements, ) as ExcalidrawBindableElement);
elementsMap, const endElement =
appState.zoom, newElement.endBinding &&
false, (elementsMap.get(
true, newElement.endBinding.elementId,
); ) as ExcalidrawBindableElement);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
newElement,
startHoveredElement,
"start",
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
newElement,
endHoveredElement,
"end",
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
app.scene,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startBinding = const startBinding =
startElement && newElement.startBinding startElement && newElement.startBinding
@@ -1693,6 +1678,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
startElement, startElement,
"start", "start",
elementsMap,
), ),
} }
: null; : null;
@@ -1705,6 +1691,7 @@ export const actionChangeArrowType = register({
newElement, newElement,
endElement, endElement,
"end", "end",
elementsMap,
), ),
} }
: null; : null;
@@ -1714,7 +1701,7 @@ export const actionChangeArrowType = register({
startBinding, startBinding,
endBinding, endBinding,
...updateElbowArrowPoints(newElement, elementsMap, { ...updateElbowArrowPoints(newElement, elementsMap, {
points: [finalStartPoint, finalEndPoint].map( points: [startGlobalPoint, endGlobalPoint].map(
(p): LocalPoint => (p): LocalPoint =>
pointFrom(p[0] - newElement.x, p[1] - newElement.y), pointFrom(p[0] - newElement.x, p[1] - newElement.y),
), ),
@@ -25,6 +25,10 @@ export const actionToggleSearchMenu = register({
predicate: (appState) => appState.gridModeEnabled, predicate: (appState) => appState.gridModeEnabled,
}, },
perform(elements, appState, _, app) { perform(elements, appState, _, app) {
if (appState.openDialog) {
return false;
}
if ( if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name && appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB appState.openSidebar.tab === CANVAS_SEARCH_TAB
+1
View File
@@ -179,6 +179,7 @@ export class ActionManager {
appProps={this.app.props} appProps={this.app.props}
app={this.app} app={this.app}
data={data} data={data}
renderAction={this.renderAction}
/> />
); );
} }
+6 -1
View File
@@ -142,7 +142,8 @@ export type ActionName =
| "cropEditor" | "cropEditor"
| "wrapSelectionInFrame" | "wrapSelectionInFrame"
| "toggleLassoTool" | "toggleLassoTool"
| "toggleShapeSwitch"; | "toggleShapeSwitch"
| "togglePolygon";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
@@ -151,6 +152,10 @@ export type PanelComponentProps = {
appProps: ExcalidrawProps; appProps: ExcalidrawProps;
data?: Record<string, any>; data?: Record<string, any>;
app: AppClassProperties; app: AppClassProperties;
renderAction: (
name: ActionName,
data?: PanelComponentProps["data"],
) => React.JSX.Element | null;
}; };
export interface Action { export interface Action {
+2 -3
View File
@@ -10,6 +10,7 @@ import {
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round, currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
@@ -108,7 +109,6 @@ export const getDefaultAppState = (): Omit<
value: 1 as NormalizedZoomValue, value: 1 as NormalizedZoomValue,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null, selectedLinearElement: null,
snapLines: [], snapLines: [],
@@ -237,7 +237,6 @@ const APP_STATE_STORAGE_CONF = (<
zenModeEnabled: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false }, zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false }, selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false }, snapLines: { browser: false, export: false, server: false },
+3 -1
View File
@@ -2,7 +2,8 @@
.undo-redo-buttons { .undo-redo-buttons {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest); // box-shadow: 0 0 0 1px var(--color-surface-lowest);
backdrop-filter: blur(40px);
} }
.zoom-button, .zoom-button,
@@ -12,6 +13,7 @@
font-size: 0.875rem !important; font-size: 0.875rem !important;
width: var(--lg-button-size); width: var(--lg-button-size);
height: var(--lg-button-size); height: var(--lg-button-size);
backdrop-filter: blur(40px);
svg { svg {
width: var(--lg-icon-size) !important; width: var(--lg-icon-size) !important;
@@ -352,7 +352,6 @@ export const ShapesSwitcher = ({
if (value === "image") { if (value === "image") {
app.setActiveTool({ app.setActiveTool({
type: value, type: value,
insertOnCanvasDirectly: pointerType !== "mouse",
}); });
} else { } else {
app.setActiveTool({ type: value }); app.setActiveTool({ type: value });
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ interface ButtonIconProps {
/** include standalone style (could interfere with parent styles) */ /** include standalone style (could interfere with parent styles) */
standalone?: boolean; standalone?: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
style?: React.CSSProperties;
} }
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>( export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
@@ -30,6 +31,7 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
data-testid={testId} data-testid={testId}
className={clsx(className, { standalone, active })} className={clsx(className, { standalone, active })}
onClick={onClick} onClick={onClick}
style={props.style}
> >
{icon} {icon}
</button> </button>
@@ -293,6 +293,7 @@ function CommandPaletteInner({
actionManager.actions.decreaseFontSize, actionManager.actions.decreaseFontSize,
actionManager.actions.toggleLinearEditor, actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor, actionManager.actions.cropEditor,
actionManager.actions.togglePolygon,
actionLink, actionLink,
actionCopyElementLink, actionCopyElementLink,
actionLinkToElement, actionLinkToElement,
@@ -502,7 +503,6 @@ function CommandPaletteInner({
if (value === "image") { if (value === "image") {
app.setActiveTool({ app.setActiveTool({
type: value, type: value,
insertOnCanvasDirectly: event.type === EVENT.KEYDOWN,
}); });
} else { } else {
app.setActiveTool({ type: value }); app.setActiveTool({ type: value });
@@ -4,6 +4,7 @@ import {
isFlowchartNodeElement, isFlowchartNodeElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement,
isTextBindableContainer, isTextBindableContainer,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -73,10 +74,6 @@ const getHints = ({
return t("hints.embeddable"); return t("hints.embeddable");
} }
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
return t("hints.placeImage");
}
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
if ( if (
@@ -138,7 +135,9 @@ const getHints = ({
? t("hints.lineEditor_pointSelected") ? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected"); : t("hints.lineEditor_nothingSelected");
} }
return t("hints.lineEditor_info"); return isLineElement(selectedElements[0])
? t("hints.lineEditor_line_info")
: t("hints.lineEditor_info");
} }
if ( if (
!appState.newElement && !appState.newElement &&
@@ -3,6 +3,8 @@
--padding: 0; --padding: 0;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor)); padding: calc(var(--padding) * var(--space-factor));
@@ -297,6 +297,10 @@ export const SearchMenu = () => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (app.state.openDialog) {
return;
}
if (!searchInputRef.current?.matches(":focus")) { if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) { if (app.state.openDialog) {
setAppState({ setAppState({
@@ -133,7 +133,6 @@ describe("binding with linear elements", () => {
const inputX = UI.queryStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204")); UI.updateInput(inputX, String("204"));
@@ -382,8 +381,7 @@ describe("stats for a non-generic element", () => {
it("text element", async () => { it("text element", async () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { act(() => {
editor.blur(); editor.blur();
@@ -576,8 +574,7 @@ describe("stats for multiple elements", () => {
// text, rectangle, frame // text, rectangle, frame
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { act(() => {
editor.blur(); editor.blur();
@@ -657,6 +654,7 @@ describe("stats for multiple elements", () => {
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -198,7 +198,6 @@ const getRelevantAppStateProps = (
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
selectionElement: appState.selectionElement, selectionElement: appState.selectionElement,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement, selectedLinearElement: appState.selectedLinearElement,
@@ -100,7 +100,6 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale, exportScale: appState.exportScale,
@@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
@@ -92,7 +92,7 @@ export const isPointHittingLink = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
hitElementBoundingBox(x, y, element, elementsMap) hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
) { ) {
return true; return true;
} }
+15
View File
@@ -129,6 +129,21 @@ export const PinIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const polygonIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 8m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M15 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M6.5 9.5l3.5 -3" />
<path d="M14 5.5l3 1.5" />
<path d="M18.5 10l-2.5 7" />
<path d="M13.5 17.5l-7 -5" />
</g>,
tablerIconProps,
);
// tabler-icons: lock-open (via Figma) // tabler-icons: lock-open (via Figma)
export const UnlockedIcon = createIcon( export const UnlockedIcon = createIcon(
<g> <g>
+7 -2
View File
@@ -386,6 +386,9 @@ body.excalidraw-cursor-resize * {
width: 12.5rem; width: 12.5rem;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
background: transparent !important;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
} }
.dropdown-select { .dropdown-select {
@@ -648,14 +651,16 @@ body.excalidraw-cursor-resize * {
.App-mobile-menu, .App-mobile-menu,
.App-menu__left { .App-menu__left {
--button-border: transparent; --button-border: transparent;
--button-bg: var(--color-surface-mid); // --button-bg: var(--color-surface-mid);
--button-bg: rgba(255, 255, 255, 0.3);
} }
@at-root .excalidraw.theme--dark#{&} { @at-root .excalidraw.theme--dark#{&} {
.App-mobile-menu, .App-mobile-menu,
.App-menu__left { .App-menu__left {
--button-hover-bg: #363541; --button-hover-bg: #363541;
--button-bg: var(--color-surface-high); // --button-bg: var(--color-surface-high);
--button-bg: rgba(0, 0, 0, 0.15);
} }
} }
+6 -7
View File
@@ -19,7 +19,7 @@
--input-border-color: #{$oc-gray-4}; --input-border-color: #{$oc-gray-4};
--input-hover-bg-color: #{$oc-gray-1}; --input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7}; --input-label-color: #{$oc-gray-7};
--island-bg-color: #ffffff; --island-bg-color: transparent !important;
--keybinding-color: var(--color-gray-40); --keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7}; --link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)}; --overlay-bg-color: #{transparentize($oc-white, 0.12)};
@@ -149,16 +149,16 @@
--color-surface-high: #f1f0ff; --color-surface-high: #f1f0ff;
--color-surface-mid: #f6f6f9; --color-surface-mid: #f6f6f9;
--color-surface-low: #ececf4; --color-surface-low: rgba(236, 236, 244, 0.1);
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f; --color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0; --color-brand-hover: #5753d0;
--color-on-primary-container: #030064; --color-on-primary-container: #030064;
--color-surface-primary-container: #e0dfff; --color-surface-primary-container: rgba(224, 223, 255, 0.7);
--color-brand-active: #4440bf; --color-brand-active: #4440bf;
--color-border-outline: #767680; --color-border-outline: #767680;
--color-border-outline-variant: #c5c5d0; --color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff; // --color-surface-primary-container: #e0dfff;
--color-badge: #0b6513; --color-badge: #0b6513;
--background-color-badge: #d3ffd2; --background-color-badge: #d3ffd2;
@@ -253,16 +253,15 @@
--color-logo-text: #e2dfff; --color-logo-text: #e2dfff;
--color-surface-high: #2e2d39; --color-surface-high: #2e2d39;
--color-surface-low: hsl(240, 8%, 15%); --color-surface-low: rgba(35, 35, 41, 0.1);
--color-surface-mid: hsl(240 6% 10%); --color-surface-mid: hsl(240 6% 10%);
--color-surface-lowest: hsl(0, 0%, 7%); --color-surface-lowest: hsl(0, 0%, 7%);
--color-on-surface: #e3e3e8; --color-on-surface: #e3e3e8;
--color-brand-hover: #bbb8ff; --color-brand-hover: #bbb8ff;
--color-on-primary-container: #e0dfff; --color-on-primary-container: #e0dfff;
--color-surface-primary-container: #403e6a; --color-surface-primary-container: rgba(64, 62, 106, 0.7);
--color-brand-active: #d0ccff; --color-brand-active: #d0ccff;
--color-border-outline: #8e8d9c; --color-border-outline: #8e8d9c;
--color-border-outline-variant: #46464f; --color-border-outline-variant: #46464f;
--color-surface-primary-container: #403e6a;
} }
} }
@@ -79,6 +79,10 @@
color: var(--button-color, var(--color-on-surface)); color: var(--button-color, var(--color-on-surface));
font-family: var(--ui-font); font-family: var(--ui-font);
// background: transparent !important;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
svg { svg {
width: var(--button-width, var(--lg-icon-size)); width: var(--button-width, var(--lg-icon-size));
height: var(--button-height, var(--lg-icon-size)); height: var(--button-height, var(--lg-icon-size));
@@ -183,6 +187,8 @@
border: none; border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest); box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
&:active { &:active {
box-shadow: 0 0 0 1px var(--color-brand-active); box-shadow: 0 0 0 1px var(--color-brand-active);
@@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"focus": 0, "focus": 0,
"gap": 4.545343408287929, "gap": 4.535423522449215,
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 14, "gap": 16,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -948,6 +948,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0, 0,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": Any<Number>, "seed": Any<Number>,
@@ -995,6 +996,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0, 0,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": null, "roundness": null,
"seed": Any<Number>, "seed": Any<Number>,
@@ -1538,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"endBinding": { "endBinding": {
"elementId": "B", "elementId": "B",
"focus": 0, "focus": 0,
"gap": 14, "gap": 32,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
+13 -3
View File
@@ -18,7 +18,7 @@ import {
normalizeLink, normalizeLink,
getLineHeight, getLineHeight,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element"; import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element";
import { import {
updateElbowArrowPoints, updateElbowArrowPoints,
@@ -34,6 +34,7 @@ import {
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -323,7 +324,8 @@ const restoreElement = (
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); ({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
} }
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
@@ -339,6 +341,13 @@ const restoreElement = (
points, points,
x, x,
y, y,
...(isLineElement(element)
? {
polygon: isValidPolygon(element.points)
? element.polygon ?? false
: false,
}
: {}),
...getSizeFromPoints(points), ...getSizeFromPoints(points),
}); });
case "arrow": { case "arrow": {
@@ -351,7 +360,8 @@ const restoreElement = (
: element.points; : element.points;
if (points[0][0] !== 0 || points[0][1] !== 0) { if (points[0][0] !== 0 || points[0][1] !== 0) {
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element)); ({ points, x, y } =
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
} }
const base = { const base = {
+1 -1
View File
@@ -781,7 +781,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: -0, focus: -0,
gap: 14, gap: 25,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {
+1 -1
View File
@@ -466,7 +466,7 @@ const bindLinearElementToElement = (
Object.assign( Object.assign(
linearElement, linearElement,
LinearElementEditor.getNormalizedPoints({ LinearElementEditor.getNormalizeElementPointsAndCoords({
...linearElement, ...linearElement,
points: newPoints, points: newPoints,
}), }),
+39 -70
View File
@@ -1,25 +1,19 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { getElementLineSegments } from "@excalidraw/element";
import { import {
lineSegment, computeBoundTextPosition,
lineSegmentIntersectionPoints, getBoundTextElement,
pointFrom, intersectElementWithLineSegment,
} from "@excalidraw/math"; isPointInElement,
} from "@excalidraw/element";
import { lineSegment, pointFrom } from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
import { getElementShape } from "@excalidraw/element";
import { shouldTestInside } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element";
import { isPointInShape } from "@excalidraw/utils/collision";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { GeometricShape } from "@excalidraw/utils/shape"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type {
ElementsSegmentsMap,
GlobalPoint,
LineSegment,
} from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animated-trail";
@@ -28,15 +22,9 @@ import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; import type App from "../components/App";
// just enough to form a segment; this is sufficient for eraser
const POINTS_ON_TRAIL = 2;
export class EraserTrail extends AnimatedTrail { export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set(); private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set(); private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
new Map();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, { super(animationFrameHandler, app, {
@@ -79,14 +67,21 @@ export class EraserTrail extends AnimatedTrail {
} }
private updateElementsToBeErased(restoreToErase?: boolean) { private updateElementsToBeErased(restoreToErase?: boolean) {
let eraserPath: GlobalPoint[] = const eraserPath: GlobalPoint[] =
super super
.getCurrentTrail() .getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || []; ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
if (eraserPath.length < 2) {
return [];
}
// for efficiency and avoid unnecessary calculations, // for efficiency and avoid unnecessary calculations,
// take only POINTS_ON_TRAIL points to form some number of segments // take only POINTS_ON_TRAIL points to form some number of segments
eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); const pathSegment = lineSegment<GlobalPoint>(
eraserPath[eraserPath.length - 1],
eraserPath[eraserPath.length - 2],
);
const candidateElements = this.app.visibleElements.filter( const candidateElements = this.app.visibleElements.filter(
(el) => !el.locked, (el) => !el.locked,
@@ -94,28 +89,13 @@ export class EraserTrail extends AnimatedTrail {
const candidateElementsMap = arrayToMap(candidateElements); const candidateElementsMap = arrayToMap(candidateElements);
const pathSegments = eraserPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
acc.push(lineSegment(eraserPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
if (pathSegments.length === 0) {
return [];
}
for (const element of candidateElements) { for (const element of candidateElements) {
// restore only if already added to the to-be-erased set // restore only if already added to the to-be-erased set
if (restoreToErase && this.elementsToErase.has(element.id)) { if (restoreToErase && this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@@ -148,12 +128,9 @@ export class EraserTrail extends AnimatedTrail {
} }
} else if (!restoreToErase && !this.elementsToErase.has(element.id)) { } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@@ -196,45 +173,37 @@ export class EraserTrail extends AnimatedTrail {
super.clearTrails(); super.clearTrails();
this.elementsToErase.clear(); this.elementsToErase.clear();
this.groupsToErase.clear(); this.groupsToErase.clear();
this.segmentsCache.clear();
} }
} }
const eraserTest = ( const eraserTest = (
pathSegments: LineSegment<GlobalPoint>[], pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
shapesCache: Map<string, GeometricShape<GlobalPoint>>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
app: App,
): boolean => { ): boolean => {
let shape = shapesCache.get(element.id); const lastPoint = pathSegment[1];
if (
if (!shape) { shouldTestInside(element) &&
shape = getElementShape<GlobalPoint>(element, elementsMap); isPointInElement(lastPoint, element, elementsMap)
shapesCache.set(element.id, shape); ) {
}
const lastPoint = pathSegments[pathSegments.length - 1][1];
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
return true; return true;
} }
let elementSegments = elementsSegments.get(element.id); const boundTextElement = getBoundTextElement(element, elementsMap);
if (!elementSegments) { return (
elementSegments = getElementLineSegments(element, elementsMap); intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
elementsSegments.set(element.id, elementSegments); .length > 0 ||
} (!!boundTextElement &&
intersectElementWithLineSegment(
return pathSegments.some((pathSegment) => {
elementSegments?.some( ...boundTextElement,
(elementSegment) => ...computeBoundTextPosition(element, boundTextElement, elementsMap),
lineSegmentIntersectionPoints( },
pathSegment, elementsMap,
elementSegment, pathSegment,
app.getElementHitThreshold(), 0,
) !== null, true,
), ).length > 0)
); );
}; };
+108 -45
View File
@@ -4,14 +4,81 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
StoreChange, StoreChange,
StoreDelta, StoreDelta,
type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { StoreSnapshot, Store } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types"; import type { SceneElementsMap } from "@excalidraw/element/types";
import type { AppState } from "./types"; import type { AppState } from "./types";
class HistoryEntry extends StoreDelta {} export class HistoryDelta extends StoreDelta {
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
public applyTo(
elements: SceneElementsMap,
appState: AppState,
snapshot: StoreSnapshot,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
elements,
// used to fallback into local snapshot in case we couldn't apply the delta
// due to a missing (force deleted) elements in the scene
snapshot.elements,
// we don't want to apply the `version` and `versionNonce` properties for history
// as we always need to end up with a new version due to collaboration,
// approaching each undo / redo as a new user action
{
excludedProperties: new Set(["version", "versionNonce"]),
},
);
const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
appState,
nextElements,
);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override inverse(delta: StoreDelta): HistoryDelta {
return super.inverse(delta) as HistoryDelta;
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
) {
return super.applyLatestChanges(
delta,
prevElements,
nextElements,
modifierOptions,
) as HistoryDelta;
}
}
export class HistoryChangedEvent { export class HistoryChangedEvent {
constructor( constructor(
@@ -25,8 +92,8 @@ export class History {
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
public readonly undoStack: HistoryEntry[] = []; public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryEntry[] = []; public readonly redoStack: HistoryDelta[] = [];
public get isUndoStackEmpty() { public get isUndoStackEmpty() {
return this.undoStack.length === 0; return this.undoStack.length === 0;
@@ -48,16 +115,16 @@ export class History {
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action. * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
*/ */
public record(delta: StoreDelta) { public record(delta: StoreDelta) {
if (delta.isEmpty() || delta instanceof HistoryEntry) { if (delta.isEmpty() || delta instanceof HistoryDelta) {
return; return;
} }
// construct history entry, so once it's emitted, it's not recorded again // construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(delta); const historyDelta = HistoryDelta.inverse(delta);
this.undoStack.push(entry); this.undoStack.push(historyDelta);
if (!entry.elements.isEmpty()) { if (!historyDelta.elements.isEmpty()) {
// don't reset redo stack on local appState changes, // don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries // as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes! // only reset on non empty elements changes!
@@ -74,7 +141,7 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements), (entry: HistoryDelta) => History.push(this.redoStack, entry),
); );
} }
@@ -83,20 +150,20 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements), (entry: HistoryDelta) => History.push(this.undoStack, entry),
); );
} }
private perform( private perform(
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
pop: () => HistoryEntry | null, pop: () => HistoryDelta | null,
push: (entry: HistoryEntry) => void, push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
try { try {
let historyEntry = pop(); let historyDelta = pop();
if (historyEntry === null) { if (historyDelta === null) {
return; return;
} }
@@ -108,41 +175,47 @@ export class History {
let nextAppState = appState; let nextAppState = appState;
let containsVisibleChange = false; let containsVisibleChange = false;
// iterate through the history entries in case they result in no visible changes // iterate through the history entries in case ;they result in no visible changes
while (historyEntry) { while (historyDelta) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
StoreDelta.applyTo( historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
historyEntry,
nextElements,
nextAppState,
prevSnapshot,
);
const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone( const nextSnapshot = prevSnapshot.maybeClone(
action, action,
nextElements, nextElements,
nextAppState, nextAppState,
); );
// schedule immediate capture, so that it's emitted for the sync purposes const change = StoreChange.create(prevSnapshot, nextSnapshot);
this.store.scheduleMicroAction({ const delta = HistoryDelta.applyLatestChanges(
action, historyDelta,
change: StoreChange.create(prevSnapshot, nextSnapshot), prevElements,
delta: historyEntry, nextElements,
}); );
if (!delta.isEmpty()) {
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({
action,
change,
delta,
});
historyDelta = delta;
}
prevSnapshot = nextSnapshot; prevSnapshot = nextSnapshot;
} finally { } finally {
// make sure to always push, even if the delta is corrupted push(historyDelta);
push(historyEntry);
} }
if (containsVisibleChange) { if (containsVisibleChange) {
break; break;
} }
historyEntry = pop(); historyDelta = pop();
} }
return [nextElements, nextAppState]; return [nextElements, nextAppState];
@@ -155,7 +228,7 @@ export class History {
} }
} }
private static pop(stack: HistoryEntry[]): HistoryEntry | null { private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) { if (!stack.length) {
return null; return null;
} }
@@ -169,18 +242,8 @@ export class History {
return null; return null;
} }
private static push( private static push(stack: HistoryDelta[], entry: HistoryDelta) {
stack: HistoryEntry[], const inversedEntry = HistoryDelta.inverse(entry);
entry: HistoryEntry, return stack.push(inversedEntry);
prevElements: SceneElementsMap,
) {
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry);
} }
} }
+1
View File
@@ -199,6 +199,7 @@ export class LassoTrail extends AnimatedTrail {
const { selectedElementIds } = getLassoSelectedElementIds({ const { selectedElementIds } = getLassoSelectedElementIds({
lassoPath, lassoPath,
elements: this.app.visibleElements, elements: this.app.visibleElements,
elementsMap: this.app.scene.getNonDeletedElementsMap(),
elementsSegments: this.elementsSegments, elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements, intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements, enclosedElements: this.enclosedElements,
+58 -26
View File
@@ -3,20 +3,25 @@ import { simplify } from "points-on-curve";
import { import {
polygonFromPoints, polygonFromPoints,
lineSegment, lineSegment,
lineSegmentIntersectionPoints,
polygonIncludesPointNonZero, polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import {
ElementsSegmentsMap, type Bounds,
GlobalPoint, computeBoundTextPosition,
LineSegment, doBoundsIntersect,
} from "@excalidraw/math/types"; getBoundTextElement,
import type { ExcalidrawElement } from "@excalidraw/element/types"; getElementBounds,
intersectElementWithLineSegment,
} from "@excalidraw/element";
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
export const getLassoSelectedElementIds = (input: { export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[]; lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
elementsSegments: ElementsSegmentsMap; elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>; intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>; enclosedElements: Set<ExcalidrawElement["id"]>;
@@ -27,6 +32,7 @@ export const getLassoSelectedElementIds = (input: {
const { const {
lassoPath, lassoPath,
elements, elements,
elementsMap,
elementsSegments, elementsSegments,
intersectedElements, intersectedElements,
enclosedElements, enclosedElements,
@@ -40,8 +46,26 @@ export const getLassoSelectedElementIds = (input: {
const unlockedElements = elements.filter((el) => !el.locked); const unlockedElements = elements.filter((el) => !el.locked);
// as the path might not enclose a shape anymore, clear before checking // as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear(); enclosedElements.clear();
intersectedElements.clear();
const lassoBounds = lassoPath.reduce(
(acc, item) => {
return [
Math.min(acc[0], item[0]),
Math.min(acc[1], item[1]),
Math.max(acc[2], item[0]),
Math.max(acc[3], item[1]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
) as Bounds;
for (const element of unlockedElements) { for (const element of unlockedElements) {
// First check if the lasso segment intersects the element's axis-aligned
// bounding box as it is much faster than checking intersection against
// the element's shape
const elementBounds = getElementBounds(element, elementsMap);
if ( if (
doBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
@@ -49,7 +73,7 @@ export const getLassoSelectedElementIds = (input: {
if (enclosed) { if (enclosed) {
enclosedElements.add(element.id); enclosedElements.add(element.id);
} else { } else {
const intersects = intersectionTest(path, element, elementsSegments); const intersects = intersectionTest(path, element, elementsMap);
if (intersects) { if (intersects) {
intersectedElements.add(element.id); intersectedElements.add(element.id);
} }
@@ -85,26 +109,34 @@ const enclosureTest = (
const intersectionTest = ( const intersectionTest = (
lassoPath: GlobalPoint[], lassoPath: GlobalPoint[],
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap, elementsMap: ElementsMap,
): boolean => { ): boolean => {
const elementSegments = elementsSegments.get(element.id); const lassoSegments = lassoPath
if (!elementSegments) { .slice(1)
return false; .map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
} .concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
const lassoSegments = lassoPath.reduce((acc, point, index) => { const boundTextElement = getBoundTextElement(element, elementsMap);
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) => return lassoSegments.some(
elementSegments.some( (lassoSegment) =>
(elementSegment) => intersectElementWithLineSegment(
// introduce a bit of tolerance to account for roughness and simplification of paths element,
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, elementsMap,
), lassoSegment,
0,
true,
).length > 0 ||
(!!boundTextElement &&
intersectElementWithLineSegment(
{
...boundTextElement,
...computeBoundTextPosition(element, boundTextElement, elementsMap),
},
elementsMap,
lassoSegment,
0,
true,
).length > 0),
); );
}; };
+5 -1
View File
@@ -141,6 +141,10 @@
"edit": "Edit line", "edit": "Edit line",
"editArrow": "Edit arrow" "editArrow": "Edit arrow"
}, },
"polygon": {
"breakPolygon": "Break polygon",
"convertToPolygon": "Convert to polygon"
},
"elementLock": { "elementLock": {
"lock": "Lock", "lock": "Lock",
"unlock": "Unlock", "unlock": "Unlock",
@@ -340,9 +344,9 @@
"resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center", "resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
"rotate": "You can constrain angles by holding SHIFT while rotating", "rotate": "You can constrain angles by holding SHIFT while rotating",
"lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points", "lineEditor_info": "Hold CtrlOrCmd and Double-click or press CtrlOrCmd + Enter to edit points",
"lineEditor_line_info": "Double-click or press Enter to edit points",
"lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move", "lineEditor_pointSelected": "Press Delete to remove point(s),\nCtrlOrCmd+D to duplicate, or drag to move",
"lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points", "lineEditor_nothingSelected": "Select a point to edit (hold SHIFT to select multiple),\nor hold Alt and click to add new points",
"placeImage": "Click to place the image, or click and drag to set its size manually",
"publishLibrary": "Publish your own library", "publishLibrary": "Publish your own library",
"bindTextToElement": "Press enter to add text", "bindTextToElement": "Press enter to add text",
"createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart", "createFlowchart": "Hold CtrlOrCmd and Arrow key to create a flowchart",
+101 -162
View File
@@ -5,17 +5,14 @@ import { getDiamondPoints } from "@excalidraw/element";
import { getCornerRadius } from "@excalidraw/element"; import { getCornerRadius } from "@excalidraw/element";
import { import {
bezierEquation,
curve, curve,
curveTangent, curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint, type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import type {
@@ -31,11 +28,14 @@ export const fillCircle = (
cx: number, cx: number,
cy: number, cy: number,
radius: number, radius: number,
stroke = true, stroke: boolean,
fill = true,
) => { ) => {
context.beginPath(); context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2); context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill(); if (fill) {
context.fill();
}
if (stroke) { if (stroke) {
context.stroke(); context.stroke();
} }
@@ -99,25 +99,14 @@ export const bootstrapCanvas = ({
function drawCatmullRomQuadraticApprox( function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
for (let i = 0; i < points.length - 1; i++) { ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const x =
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
const y =
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
ctx.lineTo(x, y);
} }
} }
} }
@@ -125,35 +114,13 @@ function drawCatmullRomQuadraticApprox(
function drawCatmullRomCubicApprox( function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < pointSets.length; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1]; const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
const p1 = points[i]; ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
} }
} }
} }
@@ -165,7 +132,10 @@ export const drawHighlightForRectWithRotation = (
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(
element,
window.h.app.scene.getElementsMapIncludingDeleted(),
),
element.angle, element.angle,
); );
@@ -184,25 +154,25 @@ export const drawHighlightForRectWithRotation = (
context.beginPath(); context.beginPath();
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
padding, padding,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width, radius), pointFrom(element.width, radius),
padding, padding,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
padding, padding,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height), pointFrom(radius, element.height),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
@@ -227,25 +197,25 @@ export const drawHighlightForRectWithRotation = (
// mask" on a filled shape for the diamond highlight, because stroking creates // mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees. // sharp inset edges on line joins < 90 degrees.
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius), pointFrom(element.width, radius),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(radius, element.height), pointFrom(radius, element.height),
@@ -322,7 +292,10 @@ export const drawHighlightForDiamondWithRotation = (
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(
element,
window.h.app.scene.getElementsMapIncludingDeleted(),
),
element.angle, element.angle,
); );
context.save(); context.save();
@@ -340,32 +313,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX - verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding, padding,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding, padding,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding, padding,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding, padding,
); );
@@ -373,13 +354,13 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
@@ -395,32 +376,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX + verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
@@ -428,66 +417,16 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
context.closePath(); context.closePath();
context.fill(); context.fill();
context.restore(); context.restore();
}; };
function offsetCubicBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
function offsetQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
@@ -1,5 +1,6 @@
import { import {
pointFrom, pointFrom,
pointsEqual,
type GlobalPoint, type GlobalPoint,
type LocalPoint, type LocalPoint,
type Radians, type Radians,
@@ -28,6 +29,7 @@ import {
isFrameLikeElement, isFrameLikeElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
isLineElement,
isTextElement, isTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
@@ -161,7 +163,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
point: Point, point: Point,
radius: number, radius: number,
isSelected: boolean, isSelected: boolean,
isPhantomPoint = false, isPhantomPoint: boolean,
isOverlappingPoint: boolean,
) => { ) => {
context.strokeStyle = "#5e5ad8"; context.strokeStyle = "#5e5ad8";
context.setLineDash([]); context.setLineDash([]);
@@ -176,8 +179,11 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
context, context,
point[0], point[0],
point[1], point[1],
radius / appState.zoom.value, (isOverlappingPoint
? radius * (appState.editingLinearElement ? 1.5 : 2)
: radius) / appState.zoom.value,
!isPhantomPoint, !isPhantomPoint,
!isOverlappingPoint || isSelected,
); );
}; };
@@ -187,16 +193,10 @@ const renderBindingHighlightForBindableElement = (
elementsMap: ElementsMap, elementsMap: ElementsMap,
zoom: InteractiveCanvasAppState["zoom"], zoom: InteractiveCanvasAppState["zoom"],
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const width = x2 - x1;
const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.fillStyle = "rgba(0,0,0,.05)";
// To ensure the binding highlight doesn't overlap the element itself
const padding = maxBindingGap(element, element.width, element.height, zoom); const padding = maxBindingGap(element, element.width, element.height, zoom);
context.fillStyle = "rgba(0,0,0,.05)";
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "text": case "text":
@@ -210,10 +210,13 @@ const renderBindingHighlightForBindableElement = (
case "diamond": case "diamond":
drawHighlightForDiamondWithRotation(context, padding, element); drawHighlightForDiamondWithRotation(context, padding, element);
break; break;
case "ellipse": case "ellipse": {
context.lineWidth = const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
maxBindingGap(element, element.width, element.height, zoom) - const width = x2 - x1;
FIXED_BINDING_DISTANCE; const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation( strokeEllipseWithRotation(
context, context,
@@ -224,6 +227,7 @@ const renderBindingHighlightForBindableElement = (
element.angle, element.angle,
); );
break; break;
}
} }
}; };
@@ -253,7 +257,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
index, index,
elementsMap, elementsMap,
); );
fillCircle(context, x, y, threshold); fillCircle(context, x, y, threshold, true);
}); });
}; };
@@ -442,15 +446,48 @@ const renderLinearPointHandles = (
const radius = appState.editingLinearElement const radius = appState.editingLinearElement
? POINT_HANDLE_SIZE ? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2; : POINT_HANDLE_SIZE / 2;
const _isElbowArrow = isElbowArrow(element);
const _isLineElement = isLineElement(element);
points.forEach((point, idx) => { points.forEach((point, idx) => {
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) { if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
return; return;
} }
const isSelected = const isOverlappingPoint =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx); idx > 0 &&
(idx !== points.length - 1 || !_isLineElement || !element.polygon) &&
pointsEqual(
point,
idx === points.length - 1 ? points[0] : points[idx - 1],
2 / appState.zoom.value,
);
renderSingleLinearPoint(context, appState, point, radius, isSelected); let isSelected =
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
// when element is a polygon, highlight the last point as well if first
// point is selected since they overlap and the last point tends to be
// rendered on top
if (
_isLineElement &&
element.polygon &&
!isSelected &&
idx === element.points.length - 1 &&
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
) {
isSelected = true;
}
renderSingleLinearPoint(
context,
appState,
point,
radius,
isSelected,
false,
isOverlappingPoint,
);
}); });
// Rendering segment mid points // Rendering segment mid points
@@ -477,6 +514,7 @@ const renderLinearPointHandles = (
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
false, false,
!fixedSegments.includes(idx + 1), !fixedSegments.includes(idx + 1),
false,
); );
} }
}); });
@@ -500,6 +538,7 @@ const renderLinearPointHandles = (
POINT_HANDLE_SIZE / 2, POINT_HANDLE_SIZE / 2,
false, false,
true, true,
false,
); );
} }
}); });
@@ -526,7 +565,7 @@ const renderTransformHandles = (
context.strokeStyle = renderConfig.selectionColor; context.strokeStyle = renderConfig.selectionColor;
} }
if (key === "rotation") { if (key === "rotation") {
fillCircle(context, x + width / 2, y + height / 2, width / 2); fillCircle(context, x + width / 2, y + height / 2, width / 2, true);
// prefer round corners if roundRect API is available // prefer round corners if roundRect API is available
} else if (context.roundRect) { } else if (context.roundRect) {
context.beginPath(); context.beginPath();
-15
View File
@@ -1,5 +1,4 @@
import { isElementInViewport } from "@excalidraw/element"; import { isElementInViewport } from "@excalidraw/element";
import { isImageElement } from "@excalidraw/element";
import { memoize, toBrandedType } from "@excalidraw/common"; import { memoize, toBrandedType } from "@excalidraw/common";
@@ -72,25 +71,14 @@ export class Renderer {
elements, elements,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"]; editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined; newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
for (const element of elements) { for (const element of elements) {
if (isImageElement(element)) {
if (
// => not placed on canvas yet (but in elements array)
pendingImageElementId === element.id
) {
continue;
}
}
if (newElementId === element.id) { if (newElementId === element.id) {
continue; continue;
} }
@@ -119,7 +107,6 @@ export class Renderer {
width, width,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
// cache-invalidation nonce // cache-invalidation nonce
sceneNonce: _sceneNonce, sceneNonce: _sceneNonce,
}: { }: {
@@ -134,7 +121,6 @@ export class Renderer {
/** note: first render of newElement will always bust the cache /** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */ * (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined; newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>; sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => { }) => {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
@@ -143,7 +129,6 @@ export class Renderer {
elements, elements,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
}); });
const visibleElements = getVisibleCanvasElements({ const visibleElements = getVisibleCanvasElements({
@@ -102,14 +102,14 @@ describe("Test <MermaidToExcalidraw/>", () => {
expect(dialog).not.toBeNull(); expect(dialog).not.toBeNull();
const selector = ".ttd-dialog-input"; const selector = ".ttd-dialog-input";
let editor = await getTextEditor(selector, true); let editor = await getTextEditor({ selector, waitForEditor: true });
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull(); expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
expect(editor.textContent).toMatchSnapshot(); expect(editor.textContent).toMatchSnapshot();
updateTextEditor(editor, "flowchart TD1"); updateTextEditor(editor, "flowchart TD1");
editor = await getTextEditor(selector, false); editor = await getTextEditor({ selector, waitForEditor: false });
expect(editor.textContent).toBe("flowchart TD1"); expect(editor.textContent).toBe("flowchart TD1");
expect( expect(
File diff suppressed because it is too large Load Diff
@@ -71,9 +71,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -107,9 +105,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -153,10 +149,9 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
50, 50,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -192,9 +187,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -17,9 +17,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -27,7 +25,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 5, "version": 5,
"versionNonce": 1505387817, "versionNonce": 23633383,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@@ -51,17 +49,15 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1505387817,
},
"seed": 1604849351,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 915032327, "versionNonce": 81784553,
"width": 30, "width": 30,
"x": -10, "x": -10,
"y": 60, "y": 60,
@@ -85,9 +81,7 @@ exports[`move element > rectangle 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -95,7 +89,7 @@ exports[`move element > rectangle 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1116226695, "versionNonce": 1014066025,
"width": 30, "width": 30,
"x": 0, "x": 0,
"y": 40, "y": 40,
@@ -124,9 +118,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -134,7 +126,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 4, "version": 4,
"versionNonce": 1723083209, "versionNonce": 1006504105,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -163,17 +155,15 @@ exports[`move element > rectangles with binding arrow 6`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3, "seed": 1116226695,
},
"seed": 1150084233,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 7, "version": 7,
"versionNonce": 745419401, "versionNonce": 1984422985,
"width": 300, "width": 300,
"x": 201, "x": 201,
"y": 2, "y": 2,
@@ -196,7 +186,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "87.29887", "height": "81.40630",
"id": "id6", "id": "id6",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -210,15 +200,15 @@ exports[`move element > rectangles with binding arrow 7`] = `
0, 0,
], ],
[ [
"86.85786", "81.00000",
"87.29887", "81.40630",
], ],
], ],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": {
"type": 2, "type": 2,
}, },
"seed": 1604849351, "seed": 23633383,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id0", "elementId": "id0",
@@ -231,9 +221,9 @@ exports[`move element > rectangles with binding arrow 7`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 11,
"versionNonce": 1051383431, "versionNonce": 1573789895,
"width": "86.85786", "width": "81.00000",
"x": "107.07107", "x": "110.00000",
"y": "47.07107", "y": 50,
} }
`; `;
@@ -50,7 +50,7 @@ exports[`multi point mode in linear elements > arrow 3`] = `
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 400692809, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
@@ -93,10 +93,9 @@ exports[`multi point mode in linear elements > line 3`] = `
110, 110,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -106,7 +105,7 @@ exports[`multi point mode in linear elements > line 3`] = `
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"versionNonce": 400692809, "versionNonce": 1604849351,
"width": 70, "width": 70,
"x": 30, "x": 30,
"y": 30, "y": 30,
File diff suppressed because it is too large Load Diff
@@ -79,10 +79,9 @@ exports[`select single element on the scene > arrow escape 1`] = `
50, 50,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -116,9 +115,7 @@ exports[`select single element on the scene > diamond 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -150,9 +147,7 @@ exports[`select single element on the scene > ellipse 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -184,9 +179,7 @@ exports[`select single element on the scene > rectangle 1`] = `
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
+10 -10
View File
@@ -110,8 +110,8 @@ describe("contextMenu element", () => {
it("shows context menu for element", () => { it("shows context menu for element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -304,8 +304,8 @@ describe("contextMenu element", () => {
it("selecting 'Copy styles' in context menu copies styles", () => { it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -389,8 +389,8 @@ describe("contextMenu element", () => {
it("selecting 'Delete' in context menu deletes element", () => { it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -405,8 +405,8 @@ describe("contextMenu element", () => {
it("selecting 'Add to library' in context menu adds element to library", async () => { it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -424,8 +424,8 @@ describe("contextMenu element", () => {
it("selecting 'Duplicate' in context menu duplicates element", () => { it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -31,9 +31,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
], ],
], ],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -193,9 +191,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
], ],
"pressures": [], "pressures": [],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"simulatePressure": true, "simulatePressure": true,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@@ -240,10 +236,9 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
100, 100,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -289,10 +284,9 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
100, 100,
], ],
], ],
"polygon": false,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -332,9 +326,7 @@ exports[`restoreElements > should restore text element correctly passing value f
"opacity": 100, "opacity": 100,
"originalText": "text", "originalText": "text",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -376,9 +368,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
"opacity": 100, "opacity": 100,
"originalText": "", "originalText": "",
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -6,7 +6,10 @@ import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { newElementWith } from "@excalidraw/element";
import * as sizeHelpers from "@excalidraw/element"; import * as sizeHelpers from "@excalidraw/element";
import type { LocalPoint } from "@excalidraw/math";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -163,6 +166,109 @@ describe("restoreElements", () => {
}); });
}); });
it("should remove imperceptibly small elements", () => {
const arrowElement = API.createElement({
type: "arrow",
points: [
[0, 0],
[0.02, 0.05],
] as LocalPoint[],
x: 0,
y: 0,
});
const restoredElements = restore.restoreElements([arrowElement], null);
const restoredArrow = restoredElements[0] as
| ExcalidrawArrowElement
| undefined;
expect(restoredArrow).toBeUndefined();
});
it("should keep 'imperceptibly' small freedraw/line elements", () => {
const freedrawElement = API.createElement({
type: "freedraw",
points: [
[0, 0],
[0.0001, 0.0001],
] as LocalPoint[],
x: 0,
y: 0,
});
const lineElement = API.createElement({
type: "line",
points: [
[0, 0],
[0.0001, 0.0001],
] as LocalPoint[],
x: 0,
y: 0,
});
const restoredElements = restore.restoreElements(
[freedrawElement, lineElement],
null,
);
expect(restoredElements).toEqual([
expect.objectContaining({ id: freedrawElement.id }),
expect.objectContaining({ id: lineElement.id }),
]);
});
it("should restore loop linears correctly", () => {
const linearElement = API.createElement({
type: "line",
points: [
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[],
x: 0,
y: 0,
});
const arrowElement = API.createElement({
type: "arrow",
points: [
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[],
x: 500,
y: 500,
});
const restoredElements = restore.restoreElements(
[linearElement, arrowElement],
null,
);
const restoredLinear = restoredElements[0] as
| ExcalidrawLinearElement
| undefined;
const restoredArrow = restoredElements[1] as
| ExcalidrawArrowElement
| undefined;
expect(restoredLinear?.type).toBe("line");
expect(restoredLinear?.points).toEqual([
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[]);
expect(restoredArrow?.type).toBe("arrow");
expect(restoredArrow?.points).toEqual([
[0, 0],
[100, 100],
[100, 200],
[0, 0],
] as LocalPoint[]);
});
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => { it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
const arrowElement = API.createElement({ type: "arrow" }); const arrowElement = API.createElement({ type: "arrow" });
const restoredElements = restore.restoreElements([arrowElement], null); const restoredElements = restore.restoreElements([arrowElement], null);
@@ -1,5 +1,3 @@
import React from "react";
import { KEYS } from "@excalidraw/common"; import { KEYS } from "@excalidraw/common";
import { actionSelectAll } from "../actions"; import { actionSelectAll } from "../actions";
@@ -10,6 +8,8 @@ import { API } from "../tests/helpers/api";
import { Keyboard, Pointer, UI } from "../tests/helpers/ui"; import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
import { render, unmountComponent } from "../tests/test-utils"; import { render, unmountComponent } from "../tests/test-utils";
import { getTextEditor } from "./queries/dom";
unmountComponent(); unmountComponent();
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@@ -245,7 +245,7 @@ describe("element locking", () => {
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore locked text under cursor when clicked with text tool", () => { it("should ignore locked text under cursor when clicked with text tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
text: "ola", text: "ola",
@@ -258,16 +258,14 @@ describe("element locking", () => {
API.setElements([text]); API.setElements([text]);
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
expect(h.state.editingTextElement?.id).toBe(h.elements[1].id); expect(h.state.editingTextElement?.id).toBe(h.elements[1].id);
}); });
it("should ignore text under cursor when double-clicked with selection tool", () => { it("should ignore text under cursor when double-clicked with selection tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
text: "ola", text: "ola",
@@ -280,9 +278,7 @@ describe("element locking", () => {
API.setElements([text]); API.setElements([text]);
UI.clickTool("selection"); UI.clickTool("selection");
mouse.doubleClickAt(text.x + 50, text.y + 50); mouse.doubleClickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(2); expect(h.elements.length).toBe(2);
@@ -328,7 +324,7 @@ describe("element locking", () => {
]); ]);
}); });
it("bound text shouldn't be editable via double-click", () => { it("bound text shouldn't be editable via double-click", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
width: 100, width: 100,
@@ -353,16 +349,14 @@ describe("element locking", () => {
UI.clickTool("selection"); UI.clickTool("selection");
mouse.doubleClickAt(container.width / 2, container.height / 2); mouse.doubleClickAt(container.width / 2, container.height / 2);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
expect(h.state.editingTextElement?.id).toBe(h.elements[2].id); expect(h.state.editingTextElement?.id).toBe(h.elements[2].id);
}); });
it("bound text shouldn't be editable via text tool", () => { it("bound text shouldn't be editable via text tool", async () => {
const container = API.createElement({ const container = API.createElement({
type: "rectangle", type: "rectangle",
width: 100, width: 100,
@@ -387,9 +381,7 @@ describe("element locking", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(container.width / 2, container.height / 2); mouse.clickAt(container.width / 2, container.height / 2);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
expect(h.state.editingTextElement?.id).not.toBe(text.id); expect(h.state.editingTextElement?.id).not.toBe(text.id);
expect(h.elements.length).toBe(3); expect(h.elements.length).toBe(3);
+4 -7
View File
@@ -1,4 +1,3 @@
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common"; import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common";
@@ -37,6 +36,8 @@ import {
waitFor, waitFor,
} from "./test-utils"; } from "./test-utils";
import { getTextEditor } from "./queries/dom";
import type { NormalizedZoomValue } from "../types"; import type { NormalizedZoomValue } from "../types";
const { h } = window; const { h } = window;
@@ -846,9 +847,7 @@ describe("mutliple elements", () => {
}); });
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
let editor = document.querySelector<HTMLTextAreaElement>( let editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "arrow" } }); fireEvent.input(editor, { target: { value: "arrow" } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@@ -860,9 +859,7 @@ describe("mutliple elements", () => {
}); });
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector<HTMLTextAreaElement>( editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "rect\ntext" } }); fireEvent.input(editor, { target: { value: "rect\ntext" } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
+9 -1
View File
@@ -499,13 +499,21 @@ export class API {
value: { value: {
files, files,
getData: (type: string) => { getData: (type: string) => {
if (type === blob.type) { if (type === blob.type || type === "text") {
return text; return text;
} }
return ""; return "";
}, },
types: [blob.type],
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", {
value: 0,
});
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent); await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
}; };
@@ -31,3 +31,30 @@ export const mockMermaidToExcalidraw = (opts: {
}); });
} }
}; };
// Mock for HTMLImageElement (use with `vi.unstubAllGlobals()`)
// as jsdom.resources: "usable" throws an error on image load
export const mockHTMLImageElement = (
naturalWidth: number,
naturalHeight: number,
) => {
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
Object.defineProperty(this, "naturalWidth", {
value: naturalWidth,
});
Object.defineProperty(this, "naturalHeight", {
value: naturalHeight,
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};
+25 -8
View File
@@ -32,10 +32,11 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { createTestHook } from "../../components/App"; import { createTestHook } from "../../components/App";
import { getTextEditor } from "../queries/dom"; import { getTextEditor, TEXT_EDITOR_SELECTOR } from "../queries/dom";
import { act, fireEvent, GlobalTestState, screen } from "../test-utils"; import { act, fireEvent, GlobalTestState, screen } from "../test-utils";
import { API } from "./api"; import { API } from "./api";
@@ -146,6 +147,7 @@ export class Keyboard {
const getElementPointForSelection = ( const getElementPointForSelection = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, angle } = element; const { x, y, width, angle } = element;
const target = pointFrom<GlobalPoint>( const target = pointFrom<GlobalPoint>(
@@ -162,7 +164,7 @@ const getElementPointForSelection = (
(bounds[1] + bounds[3]) / 2, (bounds[1] + bounds[3]) / 2,
); );
} else { } else {
center = elementCenterPoint(element); center = elementCenterPoint(element, elementsMap);
} }
if (isTextElement(element)) { if (isTextElement(element)) {
@@ -299,7 +301,12 @@ export class Pointer {
elements = Array.isArray(elements) ? elements : [elements]; elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => { elements.forEach((element) => {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
}); });
}); });
@@ -308,13 +315,23 @@ export class Pointer {
clickOn(element: ExcalidrawElement) { clickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
doubleClickOn(element: ExcalidrawElement) { doubleClickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.doubleClick(...getElementPointForSelection(element)); this.doubleClick(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
} }
@@ -532,16 +549,15 @@ export class UI {
static async editText< static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer, T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) { >(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor = const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector); document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) { if (!openedEditor) {
mouse.select(element); mouse.select(element);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
} }
const editor = await getTextEditor(textEditorSelector); const editor = await getTextEditor();
if (!editor) { if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom"); throw new Error("Can't find wysiwyg text editor in the dom");
} }
@@ -598,6 +614,7 @@ export class UI {
const mutations = cropElement( const mutations = cropElement(
element, element,
h.scene.getNonDeletedElementsMap(),
handle, handle,
naturalWidth, naturalWidth,
naturalHeight, naturalHeight,
+244 -1
View File
@@ -19,6 +19,7 @@ import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@@ -35,6 +36,7 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FileId,
FixedPointBinding, FixedPointBinding,
FractionalIndex, FractionalIndex,
SceneElementsMap, SceneElementsMap,
@@ -49,12 +51,16 @@ import {
} from "../actions"; } from "../actions";
import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import * as StaticScene from "../renderer/staticScene";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene"; import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { mockHTMLImageElement } from "./helpers/mocks";
import { import {
GlobalTestState, GlobalTestState,
act, act,
@@ -63,6 +69,7 @@ import {
togglePopover, togglePopover,
getCloneByOrigId, getCloneByOrigId,
checkpointHistory, checkpointHistory,
unmountComponent,
} from "./test-utils"; } from "./test-utils";
import type { AppState } from "../types"; import type { AppState } from "../types";
@@ -106,7 +113,22 @@ const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
describe("history", () => { describe("history", () => {
beforeEach(() => { beforeEach(() => {
unmountComponent();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
}); });
afterEach(() => { afterEach(() => {
@@ -559,6 +581,227 @@ describe("history", () => {
]); ]);
}); });
it("should create new history entry on image drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const deerImageDimensions = {
width: 318,
height: 335,
};
mockHTMLImageElement(
deerImageDimensions.width,
deerImageDimensions.height,
);
await API.drop(await API.loadFile("./fixtures/deer.png"));
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...deerImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...deerImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop(
new Blob([link], {
type: MIME_TYPES.text,
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should create new history entry on image paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const smileyImageDimensions = {
width: 56,
height: 77,
};
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
document.dispatchEvent(
createPasteEvent({
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...smileyImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...smileyImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
document.dispatchEvent(
createPasteEvent({
types: {
"text/plain": link,
},
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should support appstate name or viewBackgroundColor change", async () => { it("should support appstate name or viewBackgroundColor change", async () => {
await render( await render(
<Excalidraw <Excalidraw
+1
View File
@@ -70,6 +70,7 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => {
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ?? ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ??
[], [],
elements: h.elements, elements: h.elements,
elementsMap: h.scene.getNonDeletedElementsMap(),
elementsSegments, elementsSegments,
intersectedElements: new Set(), intersectedElements: new Set(),
enclosedElements: new Set(), enclosedElements: new Set(),
+2 -2
View File
@@ -124,8 +124,8 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]); expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]); expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]); expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });
+23 -5
View File
@@ -1,13 +1,31 @@
import { waitFor } from "@testing-library/dom"; import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (selector: string, waitForEditor = true) => { import {
const query = () => document.querySelector(selector) as HTMLTextAreaElement; stripIgnoredNodesFromErrorMessage,
if (waitForEditor) { trimErrorStack,
await waitFor(() => expect(query()).not.toBe(null)); } from "../test-utils";
export const TEXT_EDITOR_SELECTOR =
".excalidraw-textEditorContainer > textarea";
export const getTextEditor = async ({
selector = TEXT_EDITOR_SELECTOR,
waitForEditor = true,
}: { selector?: string; waitForEditor?: boolean } = {}) => {
const error = trimErrorStack(new Error());
try {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));
return query();
}
return query(); return query();
} catch (err: any) {
stripIgnoredNodesFromErrorMessage(err);
err.stack = error.stack;
throw err;
} }
return query();
}; };
export const updateTextEditor = ( export const updateTextEditor = (
+1 -1
View File
@@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80); expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50); expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(116.7, 1); expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(0); expect(arrow.height).toBeCloseTo(0);
}); });
File diff suppressed because one or more lines are too long
+33 -14
View File
@@ -421,11 +421,7 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
.join(", ")}]\n`, .join(", ")}]\n`,
)}`; )}`;
const error = new Error(errStr); throw trimErrorStack(new Error(errStr), 1);
const stack = err.stack.split("\n");
stack.splice(1, 1);
error.stack = stack.join("\n");
throw error;
} }
expect(mappedActualElements).toEqual( expect(mappedActualElements).toEqual(
@@ -435,12 +431,17 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
expect(h.state.selectedElementIds).toEqual(selectedElementIds); expect(h.state.selectedElementIds).toEqual(selectedElementIds);
}; };
const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) => const stripProps = (
deltas: Record<string, { deleted: any; inserted: any }>,
props: string[],
) =>
Object.entries(deltas).reduce((acc, curr) => { Object.entries(deltas).reduce((acc, curr) => {
const { inserted, deleted, ...rest } = curr[1]; const { inserted, deleted, ...rest } = curr[1];
delete inserted.seed; for (const prop of props) {
delete deleted.seed; delete inserted[prop];
delete deleted[prop];
}
acc[curr[0]] = { acc[curr[0]] = {
inserted, inserted,
@@ -457,9 +458,9 @@ export const checkpointHistory = (history: History, name: string) => {
...x, ...x,
elements: { elements: {
...x.elements, ...x.elements,
added: stripSeed(x.elements.added), added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripSeed(x.elements.removed), removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripSeed(x.elements.updated), updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
}, },
})), })),
).toMatchSnapshot(`[${name}] undo stack`); ).toMatchSnapshot(`[${name}] undo stack`);
@@ -469,10 +470,28 @@ export const checkpointHistory = (history: History, name: string) => {
...x, ...x,
elements: { elements: {
...x.elements, ...x.elements,
added: stripSeed(x.elements.added), added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripSeed(x.elements.removed), removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripSeed(x.elements.updated), updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
}, },
})), })),
).toMatchSnapshot(`[${name}] redo stack`); ).toMatchSnapshot(`[${name}] redo stack`);
}; };
/**
* removes one or more leading stack trace lines (leading to files) from the
* error stack trace
*/
export const trimErrorStack = (error: Error, range = 1) => {
const stack = error.stack?.split("\n");
if (stack) {
stack.splice(1, range);
error.stack = stack.join("\n");
}
return error;
};
export const stripIgnoredNodesFromErrorMessage = (error: Error) => {
error.message = error.message.replace(/\s+Ignored nodes:[\s\S]+/, "");
return error;
};
+3 -4
View File
@@ -24,7 +24,6 @@ import type {
ChartType, ChartType,
FontFamilyValues, FontFamilyValues,
FileId, FileId,
ExcalidrawImageElement,
Theme, Theme,
StrokeRoundness, StrokeRoundness,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
@@ -191,7 +190,6 @@ type _CommonCanvasAppState = {
offsetLeft: AppState["offsetLeft"]; offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"]; offsetTop: AppState["offsetTop"];
theme: AppState["theme"]; theme: AppState["theme"];
pendingImageElementId: AppState["pendingImageElementId"];
}; };
export type StaticCanvasAppState = Readonly< export type StaticCanvasAppState = Readonly<
@@ -416,8 +414,6 @@ export interface AppState {
shown: true; shown: true;
data: Spreadsheet; data: Spreadsheet;
}; };
/** imageElement waiting to be placed on canvas */
pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor"; showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null; selectedLinearElement: LinearElementEditor | null;
snapLines: readonly SnapLine[]; snapLines: readonly SnapLine[];
@@ -813,6 +809,9 @@ export interface ExcalidrawImperativeAPI {
getSceneElementsIncludingDeleted: InstanceType< getSceneElementsIncludingDeleted: InstanceType<
typeof App typeof App
>["getSceneElementsIncludingDeleted"]; >["getSceneElementsIncludingDeleted"];
getSceneElementsMapIncludingDeleted: InstanceType<
typeof App
>["getSceneElementsMapIncludingDeleted"];
history: { history: {
clear: InstanceType<typeof App>["resetHistory"]; clear: InstanceType<typeof App>["resetHistory"];
}; };

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