merge: and fix sides change for diamonds
This commit is contained in:
commit
73d54e3446
7
.github/workflows/publish-docker.yml
vendored
7
.github/workflows/publish-docker.yml
vendored
@ -17,9 +17,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: excalidraw/excalidraw:latest
|
tags: excalidraw/excalidraw:latest
|
||||||
|
platforms: linux/amd64, linux/arm64, linux/arm/v7
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
FROM node:18 AS build
|
FROM --platform=${BUILDPLATFORM} node:18 AS build
|
||||||
|
|
||||||
WORKDIR /opt/node_app
|
WORKDIR /opt/node_app
|
||||||
|
|
||||||
@ -6,13 +6,14 @@ COPY . .
|
|||||||
|
|
||||||
# do not ignore optional dependencies:
|
# do not ignore optional dependencies:
|
||||||
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
|
||||||
RUN yarn --network-timeout 600000
|
RUN --mount=type=cache,target=/root/.cache/yarn \
|
||||||
|
npm_config_target_arch=${TARGETARCH} yarn --network-timeout 600000
|
||||||
|
|
||||||
ARG NODE_ENV=production
|
ARG NODE_ENV=production
|
||||||
|
|
||||||
RUN yarn build:app:docker
|
RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker
|
||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine
|
||||||
|
|
||||||
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
```ts
|
```ts
|
||||||
(
|
(
|
||||||
tool: (
|
tool: (
|
||||||
| (
|
| { type: ToolType }
|
||||||
| { type: Exclude<ToolType, "image"> }
|
|
||||||
| {
|
|
||||||
type: Extract<ToolType, "image">;
|
|
||||||
insertOnCanvasDirectly?: boolean;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
| { type: "custom"; customType: string }
|
| { type: "custom"; customType: string }
|
||||||
) & { locked?: boolean },
|
) & { locked?: boolean },
|
||||||
) => {};
|
) => {};
|
||||||
@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
|
|||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` |
|
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
|
||||||
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
|
||||||
|
|
||||||
## setCursor
|
## setCursor
|
||||||
|
|||||||
@ -18,10 +18,10 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { isCurve } from "@excalidraw/math/curve";
|
import { isCurve } from "@excalidraw/math/curve";
|
||||||
|
|
||||||
import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
|
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
|
|
||||||
|
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||||
|
|
||||||
import { STORAGE_KEYS } from "../app_constants";
|
import { STORAGE_KEYS } from "../app_constants";
|
||||||
|
|
||||||
const renderLine = (
|
const renderLine = (
|
||||||
|
|||||||
@ -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 }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
import { average } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
FontString,
|
FontString,
|
||||||
ExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -101,7 +100,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 +710,8 @@ export const arrayToObject = <T>(
|
|||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
groupBy?: (value: T) => string | number,
|
groupBy?: (value: T) => string | number,
|
||||||
) =>
|
) =>
|
||||||
array.reduce((acc, value) => {
|
array.reduce((acc, value, idx) => {
|
||||||
acc[groupBy ? groupBy(value) : String(value)] = value;
|
acc[groupBy ? groupBy(value) : idx] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as { [key: string]: T });
|
}, {} as { [key: string]: T });
|
||||||
|
|
||||||
@ -1238,20 +1236,6 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||||||
export const castArray = <T>(value: T | T[]): T[] =>
|
export const castArray = <T>(value: T | T[]): T[] =>
|
||||||
Array.isArray(value) ? value : [value];
|
Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
export const elementCenterPoint = (
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
xOffset: number = 0,
|
|
||||||
yOffset: number = 0,
|
|
||||||
) => {
|
|
||||||
const { x, y, width, height } = element;
|
|
||||||
|
|
||||||
const centerXPoint = x + width / 2 + xOffset;
|
|
||||||
|
|
||||||
const centerYPoint = y + height / 2 + yOffset;
|
|
||||||
|
|
||||||
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** hack for Array.isArray type guard not working with readonly value[] */
|
/** hack for Array.isArray type guard not working with readonly value[] */
|
||||||
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
export const isReadonlyArray = (value?: any): value is readonly any[] => {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
import { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
|
|
||||||
import { COLOR_PALETTE } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AppState,
|
|
||||||
EmbedsValidationStatus,
|
|
||||||
} from "@excalidraw/excalidraw/types";
|
|
||||||
import type {
|
|
||||||
ElementShape,
|
|
||||||
ElementShapes,
|
|
||||||
} from "@excalidraw/excalidraw/scene/types";
|
|
||||||
|
|
||||||
import { _generateElementShape } from "./Shape";
|
|
||||||
|
|
||||||
import { elementWithCanvasCache } from "./renderElement";
|
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
|
|
||||||
|
|
||||||
import type { Drawable } from "roughjs/bin/core";
|
|
||||||
|
|
||||||
export class ShapeCache {
|
|
||||||
private static rg = new RoughGenerator();
|
|
||||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves shape from cache if available. Use this only if shape
|
|
||||||
* is optional and you have a fallback in case it's not cached.
|
|
||||||
*/
|
|
||||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
|
||||||
return ShapeCache.cache.get(
|
|
||||||
element,
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]] | undefined
|
|
||||||
: ElementShape | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
public static set = <T extends ExcalidrawElement>(
|
|
||||||
element: T,
|
|
||||||
shape: T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable,
|
|
||||||
) => ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
public static delete = (element: ExcalidrawElement) =>
|
|
||||||
ShapeCache.cache.delete(element);
|
|
||||||
|
|
||||||
public static destroy = () => {
|
|
||||||
ShapeCache.cache = new WeakMap();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates & caches shape for element if not already cached, otherwise
|
|
||||||
* returns cached shape.
|
|
||||||
*/
|
|
||||||
public static generateElementShape = <
|
|
||||||
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
|
||||||
>(
|
|
||||||
element: T,
|
|
||||||
renderConfig: {
|
|
||||||
isExporting: boolean;
|
|
||||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
|
||||||
embedsValidationStatus: EmbedsValidationStatus;
|
|
||||||
} | null,
|
|
||||||
) => {
|
|
||||||
// when exporting, always regenerated to guarantee the latest shape
|
|
||||||
const cachedShape = renderConfig?.isExporting
|
|
||||||
? undefined
|
|
||||||
: ShapeCache.get(element);
|
|
||||||
|
|
||||||
// `null` indicates no rc shape applicable for this element type,
|
|
||||||
// but it's considered a valid cache value (= do not regenerate)
|
|
||||||
if (cachedShape !== undefined) {
|
|
||||||
return cachedShape;
|
|
||||||
}
|
|
||||||
|
|
||||||
elementWithCanvasCache.delete(element);
|
|
||||||
|
|
||||||
const shape = _generateElementShape(
|
|
||||||
element,
|
|
||||||
ShapeCache.rg,
|
|
||||||
renderConfig || {
|
|
||||||
isExporting: false,
|
|
||||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
|
||||||
embedsValidationStatus: null,
|
|
||||||
},
|
|
||||||
) as T["type"] extends keyof ElementShapes
|
|
||||||
? ElementShapes[T["type"]]
|
|
||||||
: Drawable | null;
|
|
||||||
|
|
||||||
ShapeCache.cache.set(element, shape);
|
|
||||||
|
|
||||||
return shape;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
elementCenterPoint,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -27,8 +26,6 @@ import {
|
|||||||
PRECISION,
|
PRECISION,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@ -36,12 +33,12 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
doBoundsIntersect,
|
||||||
getCenterForBounds,
|
getCenterForBounds,
|
||||||
getElementBounds,
|
getElementBounds,
|
||||||
doBoundsIntersect,
|
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
import { intersectElementWithLineSegment } from "./collision";
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
headingForPointFromElement,
|
headingForPointFromElement,
|
||||||
headingIsHorizontal,
|
headingIsHorizontal,
|
||||||
@ -64,7 +61,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
|
import { aabbForElement, elementCenterPoint } from "./bounds";
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -116,7 +113,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,
|
||||||
@ -138,6 +134,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(
|
||||||
@ -148,6 +145,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
scene,
|
scene,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
bindOrUnbindLinearElementEdge(
|
bindOrUnbindLinearElementEdge(
|
||||||
linearElement,
|
linearElement,
|
||||||
@ -157,6 +155,7 @@ export const bindOrUnbindLinearElement = (
|
|||||||
boundToElementIds,
|
boundToElementIds,
|
||||||
unboundFromElementIds,
|
unboundFromElementIds,
|
||||||
scene,
|
scene,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
const onlyUnbound = Array.from(unboundFromElementIds).filter(
|
||||||
@ -183,6 +182,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") {
|
||||||
@ -223,12 +223,12 @@ const bindOrUnbindLinearElementEdge = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
edge: "start" | "end",
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap,
|
||||||
zoom?: AppState["zoom"],
|
zoom?: AppState["zoom"],
|
||||||
): NonDeleted<ExcalidrawElement> | null => {
|
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||||
|
(["start", "end"] as const).map((edge) => {
|
||||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||||
const elementId =
|
const elementId =
|
||||||
edge === "start"
|
edge === "start"
|
||||||
@ -245,21 +245,7 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
});
|
||||||
|
|
||||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap,
|
|
||||||
zoom?: AppState["zoom"],
|
|
||||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
|
||||||
["start", "end"].map((edge) =>
|
|
||||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
|
||||||
linearElement,
|
|
||||||
edge as "start" | "end",
|
|
||||||
elementsMap,
|
|
||||||
zoom,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getBindingStrategyForDraggingArrowEndpoints = (
|
const getBindingStrategyForDraggingArrowEndpoints = (
|
||||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -275,7 +261,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,
|
||||||
@ -286,7 +272,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||||||
: "keep";
|
: "keep";
|
||||||
const end = endDragged
|
const end = endDragged
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -318,7 +304,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
);
|
);
|
||||||
const start = startIsClose
|
const start = startIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -329,7 +315,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||||||
: null;
|
: null;
|
||||||
const end = endIsClose
|
const end = endIsClose
|
||||||
? isBindingEnabled
|
? isBindingEnabled
|
||||||
? getElligibleElementForBindingElement(
|
? getEligibleElementForBindingElement(
|
||||||
selectedElement,
|
selectedElement,
|
||||||
"end",
|
"end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -405,6 +391,48 @@ export const getSuggestedBindingsForArrows = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const maybeSuggestBindingsForLinearElementAtCoords = (
|
||||||
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
|
/** scene coords */
|
||||||
|
pointerCoords: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}[],
|
||||||
|
scene: Scene,
|
||||||
|
zoom: AppState["zoom"],
|
||||||
|
// During line creation the start binding hasn't been written yet
|
||||||
|
// into `linearElement`
|
||||||
|
oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
|
||||||
|
): ExcalidrawBindableElement[] =>
|
||||||
|
Array.from(
|
||||||
|
pointerCoords.reduce(
|
||||||
|
(acc: Set<NonDeleted<ExcalidrawBindableElement>>, coords) => {
|
||||||
|
const hoveredBindableElement = getHoveredElementForBinding(
|
||||||
|
coords,
|
||||||
|
scene.getNonDeletedElements(),
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
zoom,
|
||||||
|
isElbowArrow(linearElement),
|
||||||
|
isElbowArrow(linearElement),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hoveredBindableElement != null &&
|
||||||
|
!isLinearElementSimpleAndAlreadyBound(
|
||||||
|
linearElement,
|
||||||
|
oppositeBindingBoundElement?.id,
|
||||||
|
hoveredBindableElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
acc.add(hoveredBindableElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
new Set() as Set<NonDeleted<ExcalidrawBindableElement>>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const maybeBindLinearElement = (
|
export const maybeBindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -448,22 +476,13 @@ export const maybeBindLinearElement = (
|
|||||||
const normalizePointBinding = (
|
const normalizePointBinding = (
|
||||||
binding: { focus: number; gap: number },
|
binding: { focus: number; gap: number },
|
||||||
hoveredElement: ExcalidrawBindableElement,
|
hoveredElement: ExcalidrawBindableElement,
|
||||||
) => {
|
) => ({
|
||||||
let gap = binding.gap;
|
|
||||||
const maxGap = maxBindingGap(
|
|
||||||
hoveredElement,
|
|
||||||
hoveredElement.width,
|
|
||||||
hoveredElement.height,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (gap > maxGap) {
|
|
||||||
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...binding,
|
...binding,
|
||||||
gap,
|
gap: Math.min(
|
||||||
};
|
binding.gap,
|
||||||
};
|
maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const bindLinearElement = (
|
export const bindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -495,6 +514,7 @@ export const bindLinearElement = (
|
|||||||
linearElement,
|
linearElement,
|
||||||
hoveredElement,
|
hoveredElement,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -542,7 +562,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
|
|||||||
|
|
||||||
const isLinearElementSimple = (
|
const isLinearElementSimple = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
): boolean => linearElement.points.length < 3;
|
): boolean => linearElement.points.length < 3 && !isElbowArrow(linearElement);
|
||||||
|
|
||||||
const unbindLinearElement = (
|
const unbindLinearElement = (
|
||||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||||
@ -710,8 +730,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)),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -881,6 +906,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));
|
||||||
@ -889,11 +915,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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -903,9 +934,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,
|
||||||
@ -920,12 +952,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>(
|
||||||
@ -933,7 +966,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);
|
||||||
@ -952,26 +985,31 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
const isHorizontal = headingIsHorizontal(
|
const isHorizontal = headingIsHorizontal(
|
||||||
headingForPointFromElement(bindableElement, aabb, globalP),
|
headingForPointFromElement(bindableElement, aabb, globalP),
|
||||||
);
|
);
|
||||||
|
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
|
||||||
const otherPoint = pointFrom<GlobalPoint>(
|
const otherPoint = pointFrom<GlobalPoint>(
|
||||||
isHorizontal ? center[0] : edgePoint[0],
|
isHorizontal ? center[0] : snapPoint[0],
|
||||||
!isHorizontal ? center[1] : edgePoint[1],
|
!isHorizontal ? center[1] : snapPoint[1],
|
||||||
);
|
);
|
||||||
intersection = intersectElementWithLineSegment(
|
const intersector = lineSegment(
|
||||||
bindableElement,
|
|
||||||
lineSegment(
|
|
||||||
otherPoint,
|
otherPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
vectorScale(
|
vectorScale(
|
||||||
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
|
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
|
||||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||||
),
|
),
|
||||||
otherPoint,
|
otherPoint,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
)[0];
|
intersection = intersectElementWithLineSegment(
|
||||||
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
|
intersector,
|
||||||
|
FIXED_BINDING_DISTANCE,
|
||||||
|
).sort(pointDistanceSq)[0];
|
||||||
} else {
|
} else {
|
||||||
intersection = intersectElementWithLineSegment(
|
intersection = intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
lineSegment(
|
lineSegment(
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
@ -998,31 +1036,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) {
|
||||||
@ -1115,35 +1137,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(
|
||||||
@ -1153,8 +1174,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(
|
||||||
@ -1164,8 +1185,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(
|
||||||
@ -1174,7 +1195,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,
|
||||||
@ -1191,27 +1212,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);
|
||||||
}
|
}
|
||||||
@ -1246,8 +1268,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,
|
||||||
@ -1273,6 +1296,7 @@ const updateBoundPoint = (
|
|||||||
);
|
);
|
||||||
const focusPointAbsolute = determineFocusPoint(
|
const focusPointAbsolute = determineFocusPoint(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
binding.focus,
|
binding.focus,
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
);
|
);
|
||||||
@ -1291,7 +1315,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) +
|
||||||
@ -1299,6 +1323,7 @@ const updateBoundPoint = (
|
|||||||
const intersections = [
|
const intersections = [
|
||||||
...intersectElementWithLineSegment(
|
...intersectElementWithLineSegment(
|
||||||
bindableElement,
|
bindableElement,
|
||||||
|
elementsMap,
|
||||||
lineSegment<GlobalPoint>(
|
lineSegment<GlobalPoint>(
|
||||||
adjacentPoint,
|
adjacentPoint,
|
||||||
pointFromVector(
|
pointFromVector(
|
||||||
@ -1349,6 +1374,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,
|
||||||
@ -1360,6 +1386,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,
|
||||||
@ -1403,7 +1430,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,
|
||||||
@ -1555,14 +1582,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 = (
|
||||||
@ -1582,7 +1633,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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1593,12 +1644,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;
|
||||||
@ -1723,12 +1775,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;
|
||||||
@ -2151,6 +2204,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);
|
||||||
|
|
||||||
@ -2159,7 +2213,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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -2183,6 +2237,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],
|
||||||
@ -2193,6 +2248,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],
|
||||||
@ -2378,7 +2434,7 @@ export const getBindingSideMidPoint = (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(bindableElement);
|
const center = elementCenterPoint(bindableElement, elementsMap);
|
||||||
const shapeType = getShapeType(bindableElement);
|
const shapeType = getShapeType(bindableElement);
|
||||||
const side = getShapeSideAdaptive(
|
const side = getShapeSideAdaptive(
|
||||||
normalizeFixedPoint(binding.fixedPoint),
|
normalizeFixedPoint(binding.fixedPoint),
|
||||||
@ -2390,7 +2446,7 @@ export const getBindingSideMidPoint = (
|
|||||||
|
|
||||||
if (bindableElement.type === "diamond") {
|
if (bindableElement.type === "diamond") {
|
||||||
const [sides, corners] = deconstructDiamondElement(bindableElement);
|
const [sides, corners] = deconstructDiamondElement(bindableElement);
|
||||||
const [topRight, bottomRight, bottomLeft, topLeft] = sides;
|
const [bottomRight, bottomLeft, topLeft, topRight] = sides;
|
||||||
|
|
||||||
let x: number;
|
let x: number;
|
||||||
let y: number;
|
let y: number;
|
||||||
|
|||||||
@ -33,7 +33,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { generateRoughOptions } from "./shape";
|
||||||
|
import { ShapeCache } from "./shape";
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||||
import {
|
import {
|
||||||
@ -44,15 +45,13 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shapes";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
import { generateRoughOptions } from "./Shape";
|
|
||||||
|
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
import type {
|
import type {
|
||||||
@ -103,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 &&
|
||||||
@ -116,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, {
|
||||||
@ -554,7 +584,7 @@ const solveQuadratic = (
|
|||||||
return [s1, s2];
|
return [s1, s2];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCubicBezierCurveBound = (
|
export const getCubicBezierCurveBound = (
|
||||||
p0: GlobalPoint,
|
p0: GlobalPoint,
|
||||||
p1: GlobalPoint,
|
p1: GlobalPoint,
|
||||||
p2: GlobalPoint,
|
p2: GlobalPoint,
|
||||||
@ -940,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 = (
|
||||||
@ -1134,6 +1165,71 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
|
|||||||
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
bounds[1] + (bounds[3] - bounds[1]) / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the axis-aligned bounding box for a given element
|
||||||
|
*/
|
||||||
|
export const aabbForElement = (
|
||||||
|
element: Readonly<ExcalidrawElement>,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
offset?: [number, number, number, number],
|
||||||
|
) => {
|
||||||
|
const bbox = {
|
||||||
|
minX: element.x,
|
||||||
|
minY: element.y,
|
||||||
|
maxX: element.x + element.width,
|
||||||
|
maxY: element.y + element.height,
|
||||||
|
midX: element.x + element.width / 2,
|
||||||
|
midY: element.y + element.height / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
const [topLeftX, topLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [topRightX, topRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.minY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomRightX, bottomRightY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.maxX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
||||||
|
pointFrom(bbox.minX, bbox.maxY),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bounds = [
|
||||||
|
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
||||||
|
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (offset) {
|
||||||
|
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
||||||
|
return [
|
||||||
|
bounds[0] - leftOffset,
|
||||||
|
bounds[1] - topOffset,
|
||||||
|
bounds[2] + rightOffset,
|
||||||
|
bounds[3] + downOffset,
|
||||||
|
] as Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||||
|
p: P,
|
||||||
|
bounds: Bounds,
|
||||||
|
): boolean =>
|
||||||
|
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||||
|
|
||||||
export const doBoundsIntersect = (
|
export const doBoundsIntersect = (
|
||||||
bounds1: Bounds | null,
|
bounds1: Bounds | null,
|
||||||
bounds2: Bounds | null,
|
bounds2: Bounds | null,
|
||||||
@ -1147,3 +1243,14 @@ export const doBoundsIntersect = (
|
|||||||
|
|
||||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const elementCenterPoint = (
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
xOffset: number = 0,
|
||||||
|
yOffset: number = 0,
|
||||||
|
) => {
|
||||||
|
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||||
|
|
||||||
|
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,52 +1,68 @@
|
|||||||
import { isTransparent, elementCenterPoint } from "@excalidraw/common";
|
import { isTransparent } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
curveIntersectLineSegment,
|
curveIntersectLineSegment,
|
||||||
isPointWithinBounds,
|
isPointWithinBounds,
|
||||||
line,
|
|
||||||
lineSegment,
|
lineSegment,
|
||||||
lineSegmentIntersectionPoints,
|
lineSegmentIntersectionPoints,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
|
pointFromVector,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
pointsEqual,
|
pointsEqual,
|
||||||
|
vectorFromPoint,
|
||||||
|
vectorNormalize,
|
||||||
|
vectorScale,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ellipse,
|
ellipse,
|
||||||
ellipseLineIntersectionPoints,
|
ellipseSegmentInterceptPoints,
|
||||||
} from "@excalidraw/math/ellipse";
|
} from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
|
||||||
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Curve,
|
||||||
GlobalPoint,
|
GlobalPoint,
|
||||||
LineSegment,
|
LineSegment,
|
||||||
LocalPoint,
|
|
||||||
Polygon,
|
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getBoundTextShape, isPathALoop } from "./shapes";
|
import { isPathALoop } from "./utils";
|
||||||
import { getElementBounds } from "./bounds";
|
import {
|
||||||
|
type Bounds,
|
||||||
|
doBoundsIntersect,
|
||||||
|
elementCenterPoint,
|
||||||
|
getCenterForBounds,
|
||||||
|
getCubicBezierCurveBound,
|
||||||
|
getElementBounds,
|
||||||
|
} from "./bounds";
|
||||||
import {
|
import {
|
||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
|
isFreeDrawElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { getBoundTextElement } from "./textElement";
|
||||||
|
|
||||||
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
|
|
||||||
|
import { distanceToElement } from "./distance";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
ExcalidrawRectangleElement,
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -72,45 +88,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
|
|||||||
return isDraggableFromInside || isImageElement(element);
|
return isDraggableFromInside || isImageElement(element);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
export type HitTestArgs = {
|
||||||
x: number;
|
point: GlobalPoint;
|
||||||
y: number;
|
|
||||||
element: ExcalidrawElement;
|
element: ExcalidrawElement;
|
||||||
shape: GeometricShape<Point>;
|
threshold: number;
|
||||||
threshold?: number;
|
elementsMap: ElementsMap;
|
||||||
frameNameBound?: FrameNameBounds | null;
|
frameNameBound?: FrameNameBounds | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
export const hitElementItself = ({
|
||||||
x,
|
point,
|
||||||
y,
|
|
||||||
element,
|
element,
|
||||||
shape,
|
threshold,
|
||||||
threshold = 10,
|
elementsMap,
|
||||||
frameNameBound = null,
|
frameNameBound = null,
|
||||||
}: HitTestArgs<Point>) => {
|
}: HitTestArgs) => {
|
||||||
let hit = shouldTestInside(element)
|
// Hit test against a frame's name
|
||||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
const hitFrameName = frameNameBound
|
||||||
// we would need `onShape` as well to include the "borders"
|
? isPointWithinBounds(
|
||||||
isPointInShape(pointFrom(x, y), shape) ||
|
pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
|
||||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
point,
|
||||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
pointFrom(
|
||||||
|
frameNameBound.x + frameNameBound.width + threshold,
|
||||||
|
frameNameBound.y + frameNameBound.height + threshold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
// hit test against a frame's name
|
// Hit test against the extended, rotated bounding box of the element first
|
||||||
if (!hit && frameNameBound) {
|
const bounds = getElementBounds(element, elementsMap, true);
|
||||||
hit = isPointInShape(pointFrom(x, y), {
|
const hitBounds = isPointWithinBounds(
|
||||||
type: "polygon",
|
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
pointRotateRads(
|
||||||
.data as Polygon<Point>,
|
point,
|
||||||
});
|
getCenterForBounds(bounds),
|
||||||
|
-element.angle as Radians,
|
||||||
|
),
|
||||||
|
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||||
|
);
|
||||||
|
|
||||||
|
// PERF: Bail out early if the point is not even in the
|
||||||
|
// rotated bounding box or not hitting the frame name (saves 99%)
|
||||||
|
if (!hitBounds && !hitFrameName) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hit;
|
// Do the precise (and relatively costly) hit test
|
||||||
|
const hitElement = shouldTestInside(element)
|
||||||
|
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||||
|
// we would need `onShape` as well to include the "borders"
|
||||||
|
isPointInElement(point, element, elementsMap) ||
|
||||||
|
isPointOnElementOutline(point, element, elementsMap, threshold)
|
||||||
|
: isPointOnElementOutline(point, element, elementsMap, threshold);
|
||||||
|
|
||||||
|
return hitElement || hitFrameName;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBox = (
|
export const hitElementBoundingBox = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
tolerance = 0,
|
tolerance = 0,
|
||||||
@ -120,37 +155,42 @@ export const hitElementBoundingBox = (
|
|||||||
y1 -= tolerance;
|
y1 -= tolerance;
|
||||||
x2 += tolerance;
|
x2 += tolerance;
|
||||||
y2 += tolerance;
|
y2 += tolerance;
|
||||||
return isPointWithinBounds(
|
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||||
pointFrom(x1, y1),
|
|
||||||
pointFrom(x, y),
|
|
||||||
pointFrom(x2, y2),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hitElementBoundingBoxOnly = <
|
export const hitElementBoundingBoxOnly = (
|
||||||
Point extends GlobalPoint | LocalPoint,
|
hitArgs: HitTestArgs,
|
||||||
>(
|
|
||||||
hitArgs: HitTestArgs<Point>,
|
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) =>
|
||||||
return (
|
|
||||||
!hitElementItself(hitArgs) &&
|
!hitElementItself(hitArgs) &&
|
||||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||||
!hitElementBoundText(
|
!hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
|
||||||
hitArgs.x,
|
hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
|
||||||
hitArgs.y,
|
|
||||||
getBoundTextShape(hitArgs.element, elementsMap),
|
|
||||||
) &&
|
|
||||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
export const hitElementBoundText = (
|
||||||
x: number,
|
point: GlobalPoint,
|
||||||
y: number,
|
element: ExcalidrawElement,
|
||||||
textShape: GeometricShape<Point> | null,
|
elementsMap: ElementsMap,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
|
||||||
|
|
||||||
|
if (!boundTextElementCandidate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const boundTextElement = isLinearElement(element)
|
||||||
|
? {
|
||||||
|
...boundTextElementCandidate,
|
||||||
|
// arrow's bound text accurate position is not stored in the element's property
|
||||||
|
// but rather calculated and returned from the following static method
|
||||||
|
...LinearElementEditor.getBoundTextElementPosition(
|
||||||
|
element,
|
||||||
|
boundTextElementCandidate,
|
||||||
|
elementsMap,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: boundTextElementCandidate;
|
||||||
|
|
||||||
|
return isPointInElement(point, boundTextElement, elementsMap);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -163,9 +203,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
*/
|
*/
|
||||||
export const intersectElementWithLineSegment = (
|
export const intersectElementWithLineSegment = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
line: LineSegment<GlobalPoint>,
|
line: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
|
// First check if the line intersects the element's axis-aligned bounding box
|
||||||
|
// as it is much faster than checking intersection against the element's shape
|
||||||
|
const intersectorBounds = [
|
||||||
|
Math.min(line[0][0] - offset, line[1][0] - offset),
|
||||||
|
Math.min(line[0][1] - offset, line[1][1] - offset),
|
||||||
|
Math.max(line[0][0] + offset, line[1][0] + offset),
|
||||||
|
Math.max(line[0][1] + offset, line[1][1] + offset),
|
||||||
|
] as Bounds;
|
||||||
|
const elementBounds = getElementBounds(element, elementsMap);
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the actual intersection test against the element's shape
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
@ -173,67 +230,196 @@ export const intersectElementWithLineSegment = (
|
|||||||
case "iframe":
|
case "iframe":
|
||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
|
case "selection":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
return intersectRectanguloidWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return intersectDiamondWithLineSegment(element, line, offset);
|
return intersectDiamondWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return intersectEllipseWithLineSegment(element, line, offset);
|
return intersectEllipseWithLineSegment(
|
||||||
default:
|
element,
|
||||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
elementsMap,
|
||||||
|
line,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
case "line":
|
||||||
|
case "freedraw":
|
||||||
|
case "arrow":
|
||||||
|
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const curveIntersections = (
|
||||||
|
curves: Curve<GlobalPoint>[],
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
intersections: GlobalPoint[],
|
||||||
|
center: GlobalPoint,
|
||||||
|
angle: Radians,
|
||||||
|
onlyFirst = false,
|
||||||
|
) => {
|
||||||
|
for (const c of curves) {
|
||||||
|
// Optimize by doing a cheap bounding box check first
|
||||||
|
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||||
|
const b2 = [
|
||||||
|
Math.min(segment[0][0], segment[1][0]),
|
||||||
|
Math.min(segment[0][1], segment[1][1]),
|
||||||
|
Math.max(segment[0][0], segment[1][0]),
|
||||||
|
Math.max(segment[0][1], segment[1][1]),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(b1, b2)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = curveIntersectLineSegment(c, segment);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
for (const j of hits) {
|
||||||
|
intersections.push(pointRotateRads(j, center, angle));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineIntersections = (
|
||||||
|
lines: LineSegment<GlobalPoint>[],
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
intersections: GlobalPoint[],
|
||||||
|
center: GlobalPoint,
|
||||||
|
angle: Radians,
|
||||||
|
onlyFirst = false,
|
||||||
|
) => {
|
||||||
|
for (const l of lines) {
|
||||||
|
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||||
|
if (intersection) {
|
||||||
|
intersections.push(pointRotateRads(intersection, center, angle));
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections;
|
||||||
|
};
|
||||||
|
|
||||||
|
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
|
onlyFirst = false,
|
||||||
|
): GlobalPoint[] => {
|
||||||
|
// NOTE: This is the only one which return the decomposed elements
|
||||||
|
// rotated! This is due to taking advantage of roughjs definitions.
|
||||||
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
|
for (const l of lines) {
|
||||||
|
const intersection = lineSegmentIntersectionPoints(l, segment);
|
||||||
|
if (intersection) {
|
||||||
|
intersections.push(intersection);
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of curves) {
|
||||||
|
// Optimize by doing a cheap bounding box check first
|
||||||
|
const b1 = getCubicBezierCurveBound(c[0], c[1], c[2], c[3]);
|
||||||
|
const b2 = [
|
||||||
|
Math.min(segment[0][0], segment[1][0]),
|
||||||
|
Math.min(segment[0][1], segment[1][1]),
|
||||||
|
Math.max(segment[0][0], segment[1][0]),
|
||||||
|
Math.max(segment[0][1], segment[1][1]),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
if (!doBoundsIntersect(b1, b2)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = curveIntersectLineSegment(c, segment);
|
||||||
|
|
||||||
|
if (hits.length > 0) {
|
||||||
|
intersections.push(...hits);
|
||||||
|
|
||||||
|
if (onlyFirst) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intersections;
|
||||||
|
};
|
||||||
|
|
||||||
const intersectRectanguloidWithLineSegment = (
|
const intersectRectanguloidWithLineSegment = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
l: LineSegment<GlobalPoint>,
|
elementsMap: ElementsMap,
|
||||||
|
segment: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||||
l[0],
|
segment[0],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||||
l[1],
|
segment[1],
|
||||||
center,
|
center,
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
// Get the element's building components we can test against
|
// Get the element's building components we can test against
|
||||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||||
|
|
||||||
return (
|
const intersections: GlobalPoint[] = [];
|
||||||
// Test intersection against the sides, keep only the valid
|
|
||||||
// intersection points and rotate them back to scene space
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((x) => x != null)
|
|
||||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
|
|
||||||
// Test intersection against the corners which are cubic bezier curves,
|
|
||||||
// keep only the valid intersection points and rotate them back to scene
|
|
||||||
// space
|
|
||||||
.concat(
|
|
||||||
corners
|
|
||||||
.flatMap((t) =>
|
|
||||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((i) => i != null)
|
|
||||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -245,43 +431,45 @@ const intersectRectanguloidWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectDiamondWithLineSegment = (
|
const intersectDiamondWithLineSegment = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
|
onlyFirst = false,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
const rotatedIntersector = lineSegment(rotatedA, rotatedB);
|
||||||
|
|
||||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
const [sides, corners] = deconstructDiamondElement(element, offset);
|
||||||
|
const intersections: GlobalPoint[] = [];
|
||||||
|
|
||||||
return (
|
lineIntersections(
|
||||||
sides
|
sides,
|
||||||
.map((s) =>
|
rotatedIntersector,
|
||||||
lineSegmentIntersectionPoints(
|
intersections,
|
||||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
center,
|
||||||
s,
|
element.angle,
|
||||||
),
|
onlyFirst,
|
||||||
)
|
|
||||||
.filter((p): p is GlobalPoint => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
|
|
||||||
.concat(
|
|
||||||
curves
|
|
||||||
.flatMap((p) =>
|
|
||||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
|
||||||
)
|
|
||||||
.filter((p) => p != null)
|
|
||||||
// Rotate back intersection points
|
|
||||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
|
||||||
)
|
|
||||||
// Remove duplicates
|
|
||||||
.filter(
|
|
||||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onlyFirst && intersections.length > 0) {
|
||||||
|
return intersections;
|
||||||
|
}
|
||||||
|
|
||||||
|
curveIntersections(
|
||||||
|
corners,
|
||||||
|
rotatedIntersector,
|
||||||
|
intersections,
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
onlyFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
return intersections;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -293,16 +481,76 @@ const intersectDiamondWithLineSegment = (
|
|||||||
*/
|
*/
|
||||||
const intersectEllipseWithLineSegment = (
|
const intersectEllipseWithLineSegment = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
l: LineSegment<GlobalPoint>,
|
l: LineSegment<GlobalPoint>,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): GlobalPoint[] => {
|
): GlobalPoint[] => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||||
|
|
||||||
return ellipseLineIntersectionPoints(
|
return ellipseSegmentInterceptPoints(
|
||||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||||
line(rotatedA, rotatedB),
|
lineSegment(rotatedA, rotatedB),
|
||||||
).map((p) => pointRotateRads(p, center, element.angle));
|
).map((p) => pointRotateRads(p, center, element.angle));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given point is considered on the given shape's border
|
||||||
|
*
|
||||||
|
* @param point
|
||||||
|
* @param element
|
||||||
|
* @param tolerance
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const isPointOnElementOutline = (
|
||||||
|
point: GlobalPoint,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
tolerance = 1,
|
||||||
|
) => distanceToElement(element, elementsMap, point) <= tolerance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given point is considered inside the element's border
|
||||||
|
*
|
||||||
|
* @param point
|
||||||
|
* @param element
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const isPointInElement = (
|
||||||
|
point: GlobalPoint,
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||||
|
!isPathALoop(element.points)
|
||||||
|
) {
|
||||||
|
// There isn't any "inside" for a non-looping path
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||||
|
|
||||||
|
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||||
|
const otherPoint = pointFromVector(
|
||||||
|
vectorScale(
|
||||||
|
vectorNormalize(vectorFromPoint(point, center, 0.1)),
|
||||||
|
Math.max(element.width, element.height) * 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
);
|
||||||
|
const intersector = lineSegment(point, otherPoint);
|
||||||
|
const intersections = intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
intersector,
|
||||||
|
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
|
||||||
|
|
||||||
|
return intersections.length % 2 === 1;
|
||||||
|
};
|
||||||
|
|||||||
@ -14,9 +14,8 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Point } from "points-on-curve";
|
import { type Point } from "points-on-curve";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
elementCenterPoint,
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getResizedElementAbsoluteCoords,
|
getResizedElementAbsoluteCoords,
|
||||||
} from "./bounds";
|
} from "./bounds";
|
||||||
@ -34,6 +33,7 @@ export const MINIMAL_CROP_SIZE = 10;
|
|||||||
|
|
||||||
export const cropElement = (
|
export const cropElement = (
|
||||||
element: ExcalidrawImageElement,
|
element: ExcalidrawImageElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
transformHandle: TransformHandleType,
|
transformHandle: TransformHandleType,
|
||||||
naturalWidth: number,
|
naturalWidth: number,
|
||||||
naturalHeight: number,
|
naturalHeight: number,
|
||||||
@ -63,7 +63,7 @@ export const cropElement = (
|
|||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
const rotatedPointer = pointRotateRads(
|
||||||
pointFrom(pointerX, pointerY),
|
pointFrom(pointerX, pointerY),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
-element.angle as Radians,
|
-element.angle as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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 (!croppingElementId) {
|
||||||
|
// previously there was a croppingElementId (assuming visible), now there is none
|
||||||
|
visibleDifferenceFlag.value = true;
|
||||||
|
} else {
|
||||||
|
const element = nextElements.get(croppingElementId);
|
||||||
|
|
||||||
if (element && !element.isDeleted) {
|
if (element && !element.isDeleted) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
nextAppState[key] = null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,27 +6,33 @@ import {
|
|||||||
|
|
||||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
|
import { elementCenterPoint } from "./bounds";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawBindableElement,
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawEllipseElement,
|
ExcalidrawEllipseElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const distanceToBindableElement = (
|
export const distanceToElement = (
|
||||||
element: ExcalidrawBindableElement,
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
|
case "selection":
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
@ -34,11 +40,15 @@ export const distanceToBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
return distanceToRectanguloidElement(element, p);
|
return distanceToRectanguloidElement(element, elementsMap, p);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return distanceToDiamondElement(element, p);
|
return distanceToDiamondElement(element, elementsMap, p);
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
return distanceToEllipseElement(element, p);
|
return distanceToEllipseElement(element, elementsMap, p);
|
||||||
|
case "line":
|
||||||
|
case "arrow":
|
||||||
|
case "freedraw":
|
||||||
|
return distanceToLinearOrFreeDraElement(element, p);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,9 +62,10 @@ export const distanceToBindableElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToRectanguloidElement = (
|
const distanceToRectanguloidElement = (
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
) => {
|
) => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||||
// instead. It's all the same distance-wise.
|
// instead. It's all the same distance-wise.
|
||||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||||
@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToDiamondElement = (
|
const distanceToDiamondElement = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
|
||||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||||
// points. It's all the same distance-wise.
|
// points. It's all the same distance-wise.
|
||||||
@ -108,12 +120,24 @@ const distanceToDiamondElement = (
|
|||||||
*/
|
*/
|
||||||
const distanceToEllipseElement = (
|
const distanceToEllipseElement = (
|
||||||
element: ExcalidrawEllipseElement,
|
element: ExcalidrawEllipseElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
p: GlobalPoint,
|
p: GlobalPoint,
|
||||||
): number => {
|
): number => {
|
||||||
const center = elementCenterPoint(element);
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
return ellipseDistanceFromPoint(
|
return ellipseDistanceFromPoint(
|
||||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||||
pointRotateRads(p, center, -element.angle as Radians),
|
pointRotateRads(p, center, -element.angle as Radians),
|
||||||
ellipse(center, element.width / 2, element.height / 2),
|
ellipse(center, element.width / 2, element.height / 2),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const distanceToLinearOrFreeDraElement = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
p: GlobalPoint,
|
||||||
|
) => {
|
||||||
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
return Math.min(
|
||||||
|
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||||
|
...curves.map((a) => curvePointDistance(a, p)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
getSizeFromPoints,
|
getSizeFromPoints,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
|
arrayToMap,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
@ -29,10 +30,9 @@ import {
|
|||||||
FIXED_BINDING_DISTANCE,
|
FIXED_BINDING_DISTANCE,
|
||||||
getHeadingForElbowArrowSnap,
|
getHeadingForElbowArrowSnap,
|
||||||
getGlobalFixedPointForBindableElement,
|
getGlobalFixedPointForBindableElement,
|
||||||
snapToMid,
|
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import { distanceToBindableElement } from "./distance";
|
import { distanceToElement } from "./distance";
|
||||||
import {
|
import {
|
||||||
compareHeading,
|
compareHeading,
|
||||||
flipHeading,
|
flipHeading,
|
||||||
@ -52,7 +52,7 @@ import {
|
|||||||
type NonDeletedSceneElementsMap,
|
type NonDeletedSceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { aabbForElement, pointInsideBounds } from "./shapes";
|
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||||
|
|
||||||
import type { Bounds } from "./bounds";
|
import type { Bounds } from "./bounds";
|
||||||
import type { Heading } from "./heading";
|
import type { Heading } from "./heading";
|
||||||
@ -898,50 +898,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 +1229,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 +1243,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 +1251,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 +1275,7 @@ const getElbowArrowData = (
|
|||||||
const startElementBounds = hoveredStartElement
|
const startElementBounds = hoveredStartElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredStartElement,
|
hoveredStartElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
startHeading,
|
startHeading,
|
||||||
arrow.startArrowhead
|
arrow.startArrowhead
|
||||||
@ -1327,6 +1288,7 @@ const getElbowArrowData = (
|
|||||||
const endElementBounds = hoveredEndElement
|
const endElementBounds = hoveredEndElement
|
||||||
? aabbForElement(
|
? aabbForElement(
|
||||||
hoveredEndElement,
|
hoveredEndElement,
|
||||||
|
elementsMap,
|
||||||
offsetFromHeading(
|
offsetFromHeading(
|
||||||
endHeading,
|
endHeading,
|
||||||
arrow.endArrowhead
|
arrow.endArrowhead
|
||||||
@ -1342,6 +1304,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 +1314,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 +1361,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,35 +2193,28 @@ const getGlobalPoint = (
|
|||||||
fixedPointRatio: [number, number] | undefined | null,
|
fixedPointRatio: [number, number] | undefined | null,
|
||||||
initialPoint: GlobalPoint,
|
initialPoint: GlobalPoint,
|
||||||
element?: ExcalidrawBindableElement | null,
|
element?: ExcalidrawBindableElement | null,
|
||||||
|
elementsMap?: ElementsMap,
|
||||||
isDragging?: boolean,
|
isDragging?: boolean,
|
||||||
): GlobalPoint => {
|
): GlobalPoint => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
if (element) {
|
if (element && elementsMap) {
|
||||||
const snapPoint = bindPointToSnapToElementOutline(
|
return bindPointToSnapToElementOutline(
|
||||||
arrow,
|
arrow,
|
||||||
element,
|
element,
|
||||||
startOrEnd,
|
startOrEnd,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
return snapToMid(element, snapPoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
|
return getGlobalFixedPointForBindableElement(
|
||||||
fixedPointRatio || [0, 0],
|
fixedPointRatio || [0, 0],
|
||||||
element,
|
element,
|
||||||
|
elementsMap ?? arrayToMap([element]),
|
||||||
);
|
);
|
||||||
|
|
||||||
// NOTE: Resize scales the binding position point too, so we need to update it
|
|
||||||
return Math.abs(
|
|
||||||
distanceToBindableElement(element, fixedGlobalPoint) -
|
|
||||||
FIXED_BINDING_DISTANCE,
|
|
||||||
) > 0.01
|
|
||||||
? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
|
|
||||||
: fixedGlobalPoint;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return initialPoint;
|
return initialPoint;
|
||||||
@ -2268,6 +2225,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 +2234,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 +2243,7 @@ const getBindPointHeading = (
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
origPoint,
|
origPoint,
|
||||||
|
elementsMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
const getHoveredElement = (
|
const getHoveredElement = (
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import {
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { newArrowElement, newElement } from "./newElement";
|
import { newArrowElement, newElement } from "./newElement";
|
||||||
import { aabbForElement } from "./shapes";
|
import { aabbForElement } from "./bounds";
|
||||||
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
|
||||||
import {
|
import {
|
||||||
isBindableElement,
|
isBindableElement,
|
||||||
@ -95,10 +95,11 @@ const getNodeRelatives = (
|
|||||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||||
) as Readonly<LocalPoint>;
|
) as Readonly<LocalPoint>;
|
||||||
|
|
||||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
const heading = headingForPointFromElement(
|
||||||
edgePoint[0] + el.x,
|
node,
|
||||||
edgePoint[1] + el.y,
|
aabbForElement(node, elementsMap),
|
||||||
] as Readonly<GlobalPoint>);
|
[edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
|
||||||
|
);
|
||||||
|
|
||||||
acc.push({
|
acc.push({
|
||||||
relative,
|
relative,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
|
|||||||
|
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement, newElementWith } from "./mutateElement";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { hasBoundTextElement } from "./typeChecks";
|
import { hasBoundTextElement } from "./typeChecks";
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import type {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class InvalidFractionalIndexError extends Error {
|
export class InvalidFractionalIndexError extends Error {
|
||||||
@ -161,9 +162,15 @@ export const syncMovedIndices = (
|
|||||||
|
|
||||||
// try generatating indices, throws on invalid movedElements
|
// try generatating indices, throws on invalid movedElements
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
const elementsCandidates = elements.map((x) =>
|
const elementsCandidates = elements.map((x) => {
|
||||||
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x,
|
const elementUpdates = elementsUpdates.get(x);
|
||||||
);
|
|
||||||
|
if (elementUpdates) {
|
||||||
|
return { ...x, index: elementUpdates.index };
|
||||||
|
}
|
||||||
|
|
||||||
|
return x;
|
||||||
|
});
|
||||||
|
|
||||||
// ensure next indices are valid before mutation, throws on invalid ones
|
// ensure next indices are valid before mutation, throws on invalid ones
|
||||||
validateFractionalIndices(
|
validateFractionalIndices(
|
||||||
@ -177,8 +184,8 @@ export const syncMovedIndices = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// split mutation so we don't end up in an incosistent state
|
// split mutation so we don't end up in an incosistent state
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// fallback to default sync
|
// fallback to default sync
|
||||||
@ -189,7 +196,7 @@ export const syncMovedIndices = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
|
* Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
|
||||||
*
|
*
|
||||||
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
*/
|
*/
|
||||||
@ -200,13 +207,32 @@ export const syncInvalidIndices = (
|
|||||||
const indicesGroups = getInvalidIndicesGroups(elements);
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
const elementsUpdates = generateIndices(elements, indicesGroups);
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
for (const [element, update] of elementsUpdates) {
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
mutateElement(element, elementsMap, update);
|
mutateElement(element, elementsMap, { index });
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements as OrderedExcalidrawElement[];
|
return elements as OrderedExcalidrawElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
|
||||||
|
*
|
||||||
|
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
|
||||||
|
*/
|
||||||
|
export const syncInvalidIndicesImmutable = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
): SceneElementsMap | undefined => {
|
||||||
|
const syncedElements = arrayToMap(elements);
|
||||||
|
const indicesGroups = getInvalidIndicesGroups(elements);
|
||||||
|
const elementsUpdates = generateIndices(elements, indicesGroups);
|
||||||
|
|
||||||
|
for (const [element, { index }] of elementsUpdates) {
|
||||||
|
syncedElements.set(element.id, newElementWith(element, { index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncedElements as SceneElementsMap;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get contiguous groups of indices of passed moved elements.
|
* Get contiguous groups of indices of passed moved elements.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -102,9 +102,7 @@ export * from "./resizeElements";
|
|||||||
export * from "./resizeTest";
|
export * from "./resizeTest";
|
||||||
export * from "./Scene";
|
export * from "./Scene";
|
||||||
export * from "./selection";
|
export * from "./selection";
|
||||||
export * from "./Shape";
|
export * from "./shape";
|
||||||
export * from "./ShapeCache";
|
|
||||||
export * from "./shapes";
|
|
||||||
export * from "./showSelectedShapeActions";
|
export * from "./showSelectedShapeActions";
|
||||||
export * from "./sizeHelpers";
|
export * from "./sizeHelpers";
|
||||||
export * from "./sortElements";
|
export * from "./sortElements";
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
pointDistance,
|
pointDistance,
|
||||||
vectorFromPoint,
|
vectorFromPoint,
|
||||||
|
curveLength,
|
||||||
|
curvePointAtLength,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||||
@ -18,9 +20,14 @@ import {
|
|||||||
getGridPoint,
|
getGridPoint,
|
||||||
invariant,
|
invariant,
|
||||||
tupleToCoors,
|
tupleToCoors,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import type { Store } from "@excalidraw/element";
|
import {
|
||||||
|
deconstructLinearOrFreeDrawElement,
|
||||||
|
isPathALoop,
|
||||||
|
type Store,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { Radians } from "@excalidraw/math";
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -39,6 +46,7 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
getHoveredElementForBinding,
|
getHoveredElementForBinding,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
|
maybeSuggestBindingsForLinearElementAtCoords,
|
||||||
} from "./binding";
|
} from "./binding";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@ -55,16 +63,7 @@ import {
|
|||||||
isFixedPointBinding,
|
isFixedPointBinding,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache, toggleLinePolygonState } from "./shape";
|
||||||
|
|
||||||
import {
|
|
||||||
isPathALoop,
|
|
||||||
getBezierCurveLength,
|
|
||||||
getControlPointsForBezierCurve,
|
|
||||||
mapIntervalToBezierT,
|
|
||||||
getBezierXY,
|
|
||||||
toggleLinePolygonState,
|
|
||||||
} from "./shapes";
|
|
||||||
|
|
||||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||||
|
|
||||||
@ -278,18 +277,13 @@ export class LinearElementEditor {
|
|||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
scenePointerX: number,
|
scenePointerX: number,
|
||||||
scenePointerY: number,
|
scenePointerY: number,
|
||||||
maybeSuggestBinding: (
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
pointSceneCoords: { x: number; y: number }[],
|
|
||||||
) => void,
|
|
||||||
linearElementEditor: LinearElementEditor,
|
linearElementEditor: LinearElementEditor,
|
||||||
scene: Scene,
|
): Pick<AppState, keyof AppState> | null {
|
||||||
): LinearElementEditor | null {
|
|
||||||
if (!linearElementEditor) {
|
if (!linearElementEditor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { elementId } = linearElementEditor;
|
const { elementId } = linearElementEditor;
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||||
let customLineAngle = linearElementEditor.customLineAngle;
|
let customLineAngle = linearElementEditor.customLineAngle;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@ -350,7 +344,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@ -378,7 +372,7 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
scene,
|
app.scene,
|
||||||
new Map(
|
new Map(
|
||||||
selectedPointsIndices.map((pointIndex) => {
|
selectedPointsIndices.map((pointIndex) => {
|
||||||
const newPointPosition: LocalPoint =
|
const newPointPosition: LocalPoint =
|
||||||
@ -410,15 +404,22 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
handleBindTextResize(element, scene, false);
|
handleBindTextResize(element, app.scene, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// suggest bindings for first and last point if selected
|
// suggest bindings for first and last point if selected
|
||||||
|
let suggestedBindings: ExcalidrawBindableElement[] = [];
|
||||||
if (isBindingElement(element, false)) {
|
if (isBindingElement(element, false)) {
|
||||||
|
const firstSelectedIndex = selectedPointsIndices[0] === 0;
|
||||||
|
const lastSelectedIndex =
|
||||||
|
selectedPointsIndices[selectedPointsIndices.length - 1] ===
|
||||||
|
element.points.length - 1;
|
||||||
const coords: { x: number; y: number }[] = [];
|
const coords: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
const firstSelectedIndex = selectedPointsIndices[0];
|
if (!firstSelectedIndex !== !lastSelectedIndex) {
|
||||||
if (firstSelectedIndex === 0) {
|
coords.push({ x: scenePointerX, y: scenePointerY });
|
||||||
|
} else {
|
||||||
|
if (firstSelectedIndex) {
|
||||||
coords.push(
|
coords.push(
|
||||||
tupleToCoors(
|
tupleToCoors(
|
||||||
LinearElementEditor.getPointGlobalCoordinates(
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
@ -430,26 +431,32 @@ export class LinearElementEditor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSelectedIndex =
|
if (lastSelectedIndex) {
|
||||||
selectedPointsIndices[selectedPointsIndices.length - 1];
|
|
||||||
if (lastSelectedIndex === element.points.length - 1) {
|
|
||||||
coords.push(
|
coords.push(
|
||||||
tupleToCoors(
|
tupleToCoors(
|
||||||
LinearElementEditor.getPointGlobalCoordinates(
|
LinearElementEditor.getPointGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
element.points[lastSelectedIndex],
|
element.points[
|
||||||
|
selectedPointsIndices[selectedPointsIndices.length - 1]
|
||||||
|
],
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (coords.length) {
|
if (coords.length) {
|
||||||
maybeSuggestBinding(element, coords);
|
suggestedBindings = maybeSuggestBindingsForLinearElementAtCoords(
|
||||||
|
element,
|
||||||
|
coords,
|
||||||
|
app.scene,
|
||||||
|
app.state.zoom,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const newLinearElementEditor = {
|
||||||
...linearElementEditor,
|
...linearElementEditor,
|
||||||
selectedPointsIndices,
|
selectedPointsIndices,
|
||||||
segmentMidPointHoveredCoords:
|
segmentMidPointHoveredCoords:
|
||||||
@ -469,6 +476,15 @@ export class LinearElementEditor {
|
|||||||
isDragging: true,
|
isDragging: true,
|
||||||
customLineAngle,
|
customLineAngle,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...app.state,
|
||||||
|
editingLinearElement: app.state.editingLinearElement
|
||||||
|
? newLinearElementEditor
|
||||||
|
: null,
|
||||||
|
selectedLinearElement: newLinearElementEditor,
|
||||||
|
suggestedBindings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -482,6 +498,7 @@ export class LinearElementEditor {
|
|||||||
): LinearElementEditor {
|
): LinearElementEditor {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const elements = scene.getNonDeletedElements();
|
const elements = scene.getNonDeletedElements();
|
||||||
|
const pointerCoords = viewportCoordsToSceneCoords(event, appState);
|
||||||
|
|
||||||
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
|
||||||
editingLinearElement;
|
editingLinearElement;
|
||||||
@ -537,13 +554,15 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
const bindingElement = isBindingEnabled(appState)
|
const bindingElement = isBindingEnabled(appState)
|
||||||
? getHoveredElementForBinding(
|
? getHoveredElementForBinding(
|
||||||
tupleToCoors(
|
(selectedPointsIndices?.length ?? 0) > 1
|
||||||
|
? tupleToCoors(
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
selectedPoint!,
|
selectedPoint!,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
: pointerCoords,
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
@ -629,10 +648,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
element,
|
element,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
midpoints.push(segmentMidPoint);
|
midpoints.push(segmentMidPoint);
|
||||||
index++;
|
index++;
|
||||||
@ -734,7 +750,18 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
let distance = pointDistance(startPoint, endPoint);
|
let distance = pointDistance(startPoint, endPoint);
|
||||||
if (element.points.length > 2 && element.roundness) {
|
if (element.points.length > 2 && element.roundness) {
|
||||||
distance = getBezierCurveLength(element, endPoint);
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
lines.length === 0 && curves.length > 0,
|
||||||
|
"Only linears built out of curves are supported",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
lines.length + curves.length >= index,
|
||||||
|
"Invalid segment index while calculating mid point",
|
||||||
|
);
|
||||||
|
|
||||||
|
distance = curveLength<GlobalPoint>(curves[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
|
||||||
@ -742,39 +769,42 @@ export class LinearElementEditor {
|
|||||||
|
|
||||||
static getSegmentMidPoint(
|
static getSegmentMidPoint(
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
element: NonDeleted<ExcalidrawLinearElement>,
|
||||||
startPoint: GlobalPoint,
|
index: number,
|
||||||
endPoint: GlobalPoint,
|
|
||||||
endPointIndex: number,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GlobalPoint {
|
): GlobalPoint {
|
||||||
let segmentMidPoint = pointCenter(startPoint, endPoint);
|
if (isElbowArrow(element)) {
|
||||||
if (element.points.length > 2 && element.roundness) {
|
invariant(
|
||||||
const controlPoints = getControlPointsForBezierCurve(
|
element.points.length >= index,
|
||||||
element,
|
"Invalid segment index while calculating elbow arrow mid point",
|
||||||
element.points[endPointIndex],
|
|
||||||
);
|
|
||||||
if (controlPoints) {
|
|
||||||
const t = mapIntervalToBezierT(
|
|
||||||
element,
|
|
||||||
element.points[endPointIndex],
|
|
||||||
0.5,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
|
const p = pointCenter(element.points[index - 1], element.points[index]);
|
||||||
element,
|
|
||||||
getBezierXY(
|
return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
),
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segmentMidPoint;
|
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
(lines.length === 0 && curves.length > 0) ||
|
||||||
|
(lines.length > 0 && curves.length === 0),
|
||||||
|
"Only linears built out of either segments or curves are supported",
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
lines.length + curves.length >= index,
|
||||||
|
"Invalid segment index while calculating mid point",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lines.length) {
|
||||||
|
const segment = lines[index - 1];
|
||||||
|
return pointCenter(segment[0], segment[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curves.length) {
|
||||||
|
const segment = curves[index - 1];
|
||||||
|
return curvePointAtLength(segment, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
invariant(false, "Invalid segment type while calculating mid point");
|
||||||
}
|
}
|
||||||
|
|
||||||
static getSegmentMidPointIndex(
|
static getSegmentMidPointIndex(
|
||||||
@ -1670,10 +1700,7 @@ export class LinearElementEditor {
|
|||||||
const index = element.points.length / 2 - 1;
|
const index = element.points.length / 2 - 1;
|
||||||
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
element,
|
element,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
|
|||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ import type {
|
|||||||
|
|
||||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||||
Partial<TElement>,
|
Partial<TElement>,
|
||||||
"id" | "version" | "versionNonce" | "updated"
|
"id" | "updated"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||||||
ShapeCache.delete(element);
|
ShapeCache.delete(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.version++;
|
element.version = updates.version ?? element.version + 1;
|
||||||
element.versionNonce = randomInteger();
|
element.versionNonce = updates.versionNonce ?? randomInteger();
|
||||||
element.updated = getUpdatedTimestamp();
|
element.updated = getUpdatedTimestamp();
|
||||||
|
|
||||||
return element;
|
return element;
|
||||||
@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
...updates,
|
...updates,
|
||||||
|
version: updates.version ?? element.version + 1,
|
||||||
|
versionNonce: updates.versionNonce ?? randomInteger(),
|
||||||
updated: getUpdatedTimestamp(),
|
updated: getUpdatedTimestamp(),
|
||||||
version: element.version + 1,
|
|
||||||
versionNonce: randomInteger(),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -54,9 +54,9 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getContainingFrame } from "./frame";
|
import { getContainingFrame } from "./frame";
|
||||||
import { getCornerRadius } from "./shapes";
|
import { getCornerRadius } from "./utils";
|
||||||
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
import { ShapeCache } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import {
|
|||||||
pointCenter,
|
pointCenter,
|
||||||
normalizeRadians,
|
normalizeRadians,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
type Radians,
|
type Radians,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
@ -104,18 +103,6 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
updateBoundElements(element, scene);
|
updateBoundElements(element, scene);
|
||||||
}
|
}
|
||||||
} else if (isTextElement(element) && transformHandleType) {
|
|
||||||
resizeSingleTextElement(
|
|
||||||
originalElements,
|
|
||||||
element,
|
|
||||||
scene,
|
|
||||||
transformHandleType,
|
|
||||||
shouldResizeFromCenter,
|
|
||||||
pointerX,
|
|
||||||
pointerY,
|
|
||||||
);
|
|
||||||
updateBoundElements(element, scene);
|
|
||||||
return true;
|
|
||||||
} else if (transformHandleType) {
|
} else if (transformHandleType) {
|
||||||
const elementId = selectedElements[0].id;
|
const elementId = selectedElements[0].id;
|
||||||
const latestElement = elementsMap.get(elementId);
|
const latestElement = elementsMap.get(elementId);
|
||||||
@ -150,6 +137,9 @@ export const transformElements = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
updateBoundElements(element, scene);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} else if (selectedElements.length > 1) {
|
} else if (selectedElements.length > 1) {
|
||||||
if (transformHandleType === "rotation") {
|
if (transformHandleType === "rotation") {
|
||||||
@ -282,151 +272,50 @@ export const measureFontSizeFromWidth = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resizeSingleTextElement = (
|
export const resizeSingleTextElement = (
|
||||||
originalElements: PointerDownState["originalElements"],
|
origElement: NonDeleted<ExcalidrawTextElement>,
|
||||||
element: NonDeleted<ExcalidrawTextElement>,
|
element: NonDeleted<ExcalidrawTextElement>,
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
transformHandleType: TransformHandleDirection,
|
transformHandleType: TransformHandleDirection,
|
||||||
shouldResizeFromCenter: boolean,
|
shouldResizeFromCenter: boolean,
|
||||||
pointerX: number,
|
nextWidth: number,
|
||||||
pointerY: number,
|
nextHeight: number,
|
||||||
) => {
|
) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
// rotation pointer with reverse angle
|
|
||||||
const [rotatedX, rotatedY] = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
-element.angle as Radians,
|
|
||||||
);
|
|
||||||
let scaleX = 0;
|
|
||||||
let scaleY = 0;
|
|
||||||
|
|
||||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
const metricsWidth = element.width * (nextHeight / element.height);
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("w")) {
|
|
||||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("n")) {
|
|
||||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("s")) {
|
|
||||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scale = Math.max(scaleX, scaleY);
|
const metrics = measureFontSizeFromWidth(element, elementsMap, metricsWidth);
|
||||||
|
|
||||||
if (scale > 0) {
|
|
||||||
const nextWidth = element.width * scale;
|
|
||||||
const nextHeight = element.height * scale;
|
|
||||||
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
|
|
||||||
if (metrics === null) {
|
if (metrics === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTopLeft = [x1, y1];
|
if (transformHandleType.includes("n") || transformHandleType.includes("s")) {
|
||||||
const startBottomRight = [x2, y2];
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
const startCenter = [cx, cy];
|
|
||||||
|
|
||||||
let newTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
const newOrigin = getResizedOrigin(
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
previousOrigin,
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
origElement.width,
|
||||||
startBottomRight[0] - Math.abs(nextWidth),
|
origElement.height,
|
||||||
startBottomRight[1] - Math.abs(nextHeight),
|
metricsWidth,
|
||||||
|
nextHeight,
|
||||||
|
origElement.angle,
|
||||||
|
transformHandleType,
|
||||||
|
false,
|
||||||
|
shouldResizeFromCenter,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (transformHandleType === "ne") {
|
|
||||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
bottomLeft[0],
|
|
||||||
bottomLeft[1] - Math.abs(nextHeight),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (transformHandleType === "sw") {
|
|
||||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
|
||||||
newTopLeft = pointFrom<GlobalPoint>(
|
|
||||||
topRight[0] - Math.abs(nextWidth),
|
|
||||||
topRight[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (["s", "n"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[0] = startCenter[0] - nextWidth / 2;
|
|
||||||
}
|
|
||||||
if (["e", "w"].includes(transformHandleType)) {
|
|
||||||
newTopLeft[1] = startCenter[1] - nextHeight / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldResizeFromCenter) {
|
|
||||||
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
|
|
||||||
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angle = element.angle;
|
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
newTopLeft,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
const newCenter = pointFrom<GlobalPoint>(
|
|
||||||
newTopLeft[0] + Math.abs(nextWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(nextHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(
|
|
||||||
newCenter,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
|
||||||
const [nextX, nextY] = newTopLeft;
|
|
||||||
|
|
||||||
scene.mutateElement(element, {
|
scene.mutateElement(element, {
|
||||||
fontSize: metrics.size,
|
fontSize: metrics.size,
|
||||||
width: nextWidth,
|
width: metricsWidth,
|
||||||
height: nextHeight,
|
height: nextHeight,
|
||||||
x: nextX,
|
x: newOrigin.x,
|
||||||
y: nextY,
|
y: newOrigin.y,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transformHandleType === "e" || transformHandleType === "w") {
|
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
|
||||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
|
||||||
stateAtResizeStart.width,
|
|
||||||
stateAtResizeStart.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startTopLeft = pointFrom<GlobalPoint>(x1, y1);
|
|
||||||
const startBottomRight = pointFrom<GlobalPoint>(x2, y2);
|
|
||||||
const startCenter = pointCenter(startTopLeft, startBottomRight);
|
|
||||||
|
|
||||||
const rotatedPointer = pointRotateRads(
|
|
||||||
pointFrom(pointerX, pointerY),
|
|
||||||
startCenter,
|
|
||||||
-stateAtResizeStart.angle as Radians,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
|
||||||
element,
|
|
||||||
element.width,
|
|
||||||
element.height,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const boundsCurrentWidth = esx2 - esx1;
|
|
||||||
|
|
||||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
|
||||||
const minWidth = getMinTextElementWidth(
|
const minWidth = getMinTextElementWidth(
|
||||||
getFontString({
|
getFontString({
|
||||||
fontSize: element.fontSize,
|
fontSize: element.fontSize,
|
||||||
@ -435,17 +324,7 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
const newWidth = Math.max(minWidth, nextWidth);
|
||||||
|
|
||||||
if (transformHandleType.includes("e")) {
|
|
||||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
if (transformHandleType.includes("w")) {
|
|
||||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWidth =
|
|
||||||
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
|
||||||
|
|
||||||
const text = wrapText(
|
const text = wrapText(
|
||||||
element.originalText,
|
element.originalText,
|
||||||
@ -458,49 +337,27 @@ const resizeSingleTextElement = (
|
|||||||
element.lineHeight,
|
element.lineHeight,
|
||||||
);
|
);
|
||||||
|
|
||||||
const eleNewHeight = metrics.height;
|
const newHeight = metrics.height;
|
||||||
|
|
||||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
const previousOrigin = pointFrom<GlobalPoint>(origElement.x, origElement.y);
|
||||||
getResizedElementAbsoluteCoords(
|
|
||||||
stateAtResizeStart,
|
const newOrigin = getResizedOrigin(
|
||||||
|
previousOrigin,
|
||||||
|
origElement.width,
|
||||||
|
origElement.height,
|
||||||
newWidth,
|
newWidth,
|
||||||
eleNewHeight,
|
newHeight,
|
||||||
true,
|
element.angle,
|
||||||
);
|
transformHandleType,
|
||||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
false,
|
||||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
shouldResizeFromCenter,
|
||||||
|
|
||||||
let newTopLeft = [...startTopLeft] as [number, number];
|
|
||||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
|
||||||
newTopLeft = [
|
|
||||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
|
||||||
startTopLeft[1],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// adjust topLeft to new rotation point
|
|
||||||
const angle = stateAtResizeStart.angle;
|
|
||||||
const rotatedTopLeft = pointRotateRads(
|
|
||||||
pointFromPair(newTopLeft),
|
|
||||||
startCenter,
|
|
||||||
angle,
|
|
||||||
);
|
|
||||||
const newCenter = pointFrom(
|
|
||||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
|
||||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
|
||||||
);
|
|
||||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
|
||||||
newTopLeft = pointRotateRads(
|
|
||||||
rotatedTopLeft,
|
|
||||||
rotatedNewCenter,
|
|
||||||
-angle as Radians,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||||
width: Math.abs(newWidth),
|
width: Math.abs(newWidth),
|
||||||
height: Math.abs(metrics.height),
|
height: Math.abs(metrics.height),
|
||||||
x: newTopLeft[0],
|
x: newOrigin.x,
|
||||||
y: newTopLeft[1],
|
y: newOrigin.y,
|
||||||
text,
|
text,
|
||||||
autoResize: false,
|
autoResize: false,
|
||||||
};
|
};
|
||||||
@ -821,6 +678,18 @@ export const resizeSingleElement = (
|
|||||||
shouldInformMutation?: boolean;
|
shouldInformMutation?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
|
if (isTextElement(latestElement) && isTextElement(origElement)) {
|
||||||
|
return resizeSingleTextElement(
|
||||||
|
origElement,
|
||||||
|
latestElement,
|
||||||
|
scene,
|
||||||
|
handleDirection,
|
||||||
|
shouldResizeFromCenter,
|
||||||
|
nextWidth,
|
||||||
|
nextHeight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let boundTextFont: { fontSize?: number } = {};
|
let boundTextFont: { fontSize?: number } = {};
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||||
@ -1518,11 +1387,7 @@ export const resizeMultipleElements = (
|
|||||||
} of elementsAndUpdates) {
|
} of elementsAndUpdates) {
|
||||||
const { width, height, angle } = update;
|
const { width, height, angle } = update;
|
||||||
|
|
||||||
scene.mutateElement(element, update, {
|
scene.mutateElement(element, update);
|
||||||
informMutation: true,
|
|
||||||
// needed for the fixed binding point udpate to take effect
|
|
||||||
isDragging: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateBoundElements(element, scene, {
|
updateBoundElements(element, scene, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
|
|||||||
@ -1,26 +1,65 @@
|
|||||||
import { simplify } from "points-on-curve";
|
import { simplify } from "points-on-curve";
|
||||||
|
|
||||||
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math";
|
import {
|
||||||
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
|
type GeometricShape,
|
||||||
|
getClosedCurveShape,
|
||||||
|
getCurveShape,
|
||||||
|
getEllipseShape,
|
||||||
|
getFreedrawShape,
|
||||||
|
getPolygonShape,
|
||||||
|
} from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
|
import {
|
||||||
|
pointFrom,
|
||||||
|
pointDistance,
|
||||||
|
type LocalPoint,
|
||||||
|
pointRotateRads,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
import {
|
||||||
|
ROUGHNESS,
|
||||||
|
isTransparent,
|
||||||
|
assertNever,
|
||||||
|
COLOR_PALETTE,
|
||||||
|
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { RoughGenerator } from "roughjs/bin/generator";
|
||||||
|
|
||||||
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
|
import type {
|
||||||
import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
|
AppState,
|
||||||
|
EmbedsValidationStatus,
|
||||||
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
import type {
|
||||||
|
ElementShape,
|
||||||
|
ElementShapes,
|
||||||
|
} from "@excalidraw/excalidraw/scene/types";
|
||||||
|
|
||||||
|
import { elementWithCanvasCache } from "./renderElement";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canBecomePolygon,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isEmbeddableElement,
|
isEmbeddableElement,
|
||||||
isIframeElement,
|
isIframeElement,
|
||||||
isIframeLikeElement,
|
isIframeLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import { getCornerRadius, isPathALoop } from "./shapes";
|
import { getCornerRadius, isPathALoop } from "./utils";
|
||||||
import { headingForPointIsHorizontal } from "./heading";
|
import { headingForPointIsHorizontal } from "./heading";
|
||||||
|
|
||||||
import { canChangeRoundness } from "./comparisons";
|
import { canChangeRoundness } from "./comparisons";
|
||||||
import { generateFreeDrawShape } from "./renderElement";
|
import { generateFreeDrawShape } from "./renderElement";
|
||||||
import { getArrowheadPoints, getDiamondPoints } from "./bounds";
|
import {
|
||||||
|
getArrowheadPoints,
|
||||||
|
getCenterForBounds,
|
||||||
|
getDiamondPoints,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
} from "./bounds";
|
||||||
|
import { shouldTestInside } from "./collision";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
@ -28,12 +67,89 @@ import type {
|
|||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
Arrowhead,
|
Arrowhead,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ElementsMap,
|
||||||
|
ExcalidrawLineElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import type { Drawable, Options } from "roughjs/bin/core";
|
import type { Drawable, Options } from "roughjs/bin/core";
|
||||||
import type { RoughGenerator } from "roughjs/bin/generator";
|
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
|
|
||||||
|
export class ShapeCache {
|
||||||
|
private static rg = new RoughGenerator();
|
||||||
|
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves shape from cache if available. Use this only if shape
|
||||||
|
* is optional and you have a fallback in case it's not cached.
|
||||||
|
*/
|
||||||
|
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||||
|
return ShapeCache.cache.get(
|
||||||
|
element,
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]] | undefined
|
||||||
|
: ElementShape | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
public static set = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
shape: T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable,
|
||||||
|
) => ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
public static delete = (element: ExcalidrawElement) =>
|
||||||
|
ShapeCache.cache.delete(element);
|
||||||
|
|
||||||
|
public static destroy = () => {
|
||||||
|
ShapeCache.cache = new WeakMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates & caches shape for element if not already cached, otherwise
|
||||||
|
* returns cached shape.
|
||||||
|
*/
|
||||||
|
public static generateElementShape = <
|
||||||
|
T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
|
>(
|
||||||
|
element: T,
|
||||||
|
renderConfig: {
|
||||||
|
isExporting: boolean;
|
||||||
|
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||||
|
embedsValidationStatus: EmbedsValidationStatus;
|
||||||
|
} | null,
|
||||||
|
) => {
|
||||||
|
// when exporting, always regenerated to guarantee the latest shape
|
||||||
|
const cachedShape = renderConfig?.isExporting
|
||||||
|
? undefined
|
||||||
|
: ShapeCache.get(element);
|
||||||
|
|
||||||
|
// `null` indicates no rc shape applicable for this element type,
|
||||||
|
// but it's considered a valid cache value (= do not regenerate)
|
||||||
|
if (cachedShape !== undefined) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
|
const shape = generateElementShape(
|
||||||
|
element,
|
||||||
|
ShapeCache.rg,
|
||||||
|
renderConfig || {
|
||||||
|
isExporting: false,
|
||||||
|
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||||
|
embedsValidationStatus: null,
|
||||||
|
},
|
||||||
|
) as T["type"] extends keyof ElementShapes
|
||||||
|
? ElementShapes[T["type"]]
|
||||||
|
: Drawable | null;
|
||||||
|
|
||||||
|
ShapeCache.cache.set(element, shape);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
|
||||||
|
|
||||||
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
|
||||||
@ -303,6 +419,182 @@ const getArrowheadShapes = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const generateLinearCollisionShape = (
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
) => {
|
||||||
|
const generator = new RoughGenerator();
|
||||||
|
const options: Options = {
|
||||||
|
seed: element.seed,
|
||||||
|
disableMultiStroke: true,
|
||||||
|
disableMultiStrokeFill: true,
|
||||||
|
roughness: 0,
|
||||||
|
preserveVertices: true,
|
||||||
|
};
|
||||||
|
const center = getCenterForBounds(
|
||||||
|
// Need a non-rotated center point
|
||||||
|
element.points.reduce(
|
||||||
|
(acc, point) => {
|
||||||
|
return [
|
||||||
|
Math.min(element.x + point[0], acc[0]),
|
||||||
|
Math.min(element.y + point[1], acc[1]),
|
||||||
|
Math.max(element.x + point[0], acc[2]),
|
||||||
|
Math.max(element.y + point[1], acc[3]),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[Infinity, Infinity, -Infinity, -Infinity],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case "line":
|
||||||
|
case "arrow": {
|
||||||
|
// points array can be empty in the beginning, so it is important to add
|
||||||
|
// initial position to it
|
||||||
|
const points = element.points.length
|
||||||
|
? element.points
|
||||||
|
: [pointFrom<LocalPoint>(0, 0)];
|
||||||
|
|
||||||
|
if (isElbowArrow(element)) {
|
||||||
|
return generator.path(generateElbowArrowShape(points, 16), options)
|
||||||
|
.sets[0].ops;
|
||||||
|
} else if (!element.roundness) {
|
||||||
|
return points.map((point, idx) => {
|
||||||
|
const p = pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: idx === 0 ? "move" : "lineTo",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(points as unknown as RoughPoint[], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case "freedraw": {
|
||||||
|
if (element.points.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const simplifiedPoints = simplify(
|
||||||
|
element.points as Mutable<LocalPoint[]>,
|
||||||
|
0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
return generator
|
||||||
|
.curve(simplifiedPoints as [number, number][], options)
|
||||||
|
.sets[0].ops.slice(0, element.points.length)
|
||||||
|
.map((op, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
const p = pointRotateRads<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "move",
|
||||||
|
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
op: "bcurveTo",
|
||||||
|
data: [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((p) =>
|
||||||
|
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||||||
|
)
|
||||||
|
.flat(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the roughjs shape for given element.
|
* Generates the roughjs shape for given element.
|
||||||
*
|
*
|
||||||
@ -310,7 +602,7 @@ const getArrowheadShapes = (
|
|||||||
*
|
*
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
export const _generateElementShape = (
|
const generateElementShape = (
|
||||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
generator: RoughGenerator,
|
generator: RoughGenerator,
|
||||||
{
|
{
|
||||||
@ -611,3 +903,103 @@ const generateElbowArrowShape = (
|
|||||||
|
|
||||||
return d.join(" ");
|
return d.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get the pure geometric shape of an excalidraw elementw
|
||||||
|
* which is then used for hit detection
|
||||||
|
*/
|
||||||
|
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||||
|
element: ExcalidrawElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
|
): GeometricShape<Point> => {
|
||||||
|
switch (element.type) {
|
||||||
|
case "rectangle":
|
||||||
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
|
case "magicframe":
|
||||||
|
case "embeddable":
|
||||||
|
case "image":
|
||||||
|
case "iframe":
|
||||||
|
case "text":
|
||||||
|
case "selection":
|
||||||
|
return getPolygonShape(element);
|
||||||
|
case "arrow":
|
||||||
|
case "line": {
|
||||||
|
const roughShape =
|
||||||
|
ShapeCache.get(element)?.[0] ??
|
||||||
|
ShapeCache.generateElementShape(element, null)[0];
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
|
||||||
|
return shouldTestInside(element)
|
||||||
|
? getClosedCurveShape<Point>(
|
||||||
|
element,
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
)
|
||||||
|
: getCurveShape<Point>(
|
||||||
|
roughShape,
|
||||||
|
pointFrom<Point>(element.x, element.y),
|
||||||
|
element.angle,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ellipse":
|
||||||
|
return getEllipseShape(element);
|
||||||
|
|
||||||
|
case "freedraw": {
|
||||||
|
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||||
|
return getFreedrawShape(
|
||||||
|
element,
|
||||||
|
pointFrom(cx, cy),
|
||||||
|
shouldTestInside(element),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleLinePolygonState = (
|
||||||
|
element: ExcalidrawLineElement,
|
||||||
|
nextPolygonState: boolean,
|
||||||
|
): {
|
||||||
|
polygon: ExcalidrawLineElement["polygon"];
|
||||||
|
points: ExcalidrawLineElement["points"];
|
||||||
|
} | null => {
|
||||||
|
const updatedPoints = [...element.points];
|
||||||
|
|
||||||
|
if (nextPolygonState) {
|
||||||
|
if (!canBecomePolygon(element.points)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstPoint = updatedPoints[0];
|
||||||
|
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||||
|
|
||||||
|
const distance = Math.hypot(
|
||||||
|
firstPoint[0] - lastPoint[0],
|
||||||
|
firstPoint[1] - lastPoint[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||||
|
updatedPoints.length < 4
|
||||||
|
) {
|
||||||
|
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||||
|
} else {
|
||||||
|
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||||
|
firstPoint[0],
|
||||||
|
firstPoint[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||||
|
const ret = {
|
||||||
|
polygon: nextPolygonState,
|
||||||
|
points: updatedPoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
@ -1,446 +0,0 @@
|
|||||||
import {
|
|
||||||
DEFAULT_ADAPTIVE_RADIUS,
|
|
||||||
DEFAULT_PROPORTIONAL_RADIUS,
|
|
||||||
LINE_CONFIRM_THRESHOLD,
|
|
||||||
ROUNDNESS,
|
|
||||||
invariant,
|
|
||||||
elementCenterPoint,
|
|
||||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
|
||||||
} from "@excalidraw/common";
|
|
||||||
import {
|
|
||||||
isPoint,
|
|
||||||
pointFrom,
|
|
||||||
pointDistance,
|
|
||||||
pointFromPair,
|
|
||||||
pointRotateRads,
|
|
||||||
pointsEqual,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
import {
|
|
||||||
getClosedCurveShape,
|
|
||||||
getCurvePathOps,
|
|
||||||
getCurveShape,
|
|
||||||
getEllipseShape,
|
|
||||||
getFreedrawShape,
|
|
||||||
getPolygonShape,
|
|
||||||
type GeometricShape,
|
|
||||||
} from "@excalidraw/utils/shape";
|
|
||||||
|
|
||||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
|
||||||
|
|
||||||
import { shouldTestInside } from "./collision";
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
import { ShapeCache } from "./ShapeCache";
|
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
|
||||||
|
|
||||||
import { canBecomePolygon } from "./typeChecks";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ElementsMap,
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
ExcalidrawLineElement,
|
|
||||||
NonDeleted,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get the pure geometric shape of an excalidraw elementw
|
|
||||||
* which is then used for hit detection
|
|
||||||
*/
|
|
||||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> => {
|
|
||||||
switch (element.type) {
|
|
||||||
case "rectangle":
|
|
||||||
case "diamond":
|
|
||||||
case "frame":
|
|
||||||
case "magicframe":
|
|
||||||
case "embeddable":
|
|
||||||
case "image":
|
|
||||||
case "iframe":
|
|
||||||
case "text":
|
|
||||||
case "selection":
|
|
||||||
return getPolygonShape(element);
|
|
||||||
case "arrow":
|
|
||||||
case "line": {
|
|
||||||
const roughShape =
|
|
||||||
ShapeCache.get(element)?.[0] ??
|
|
||||||
ShapeCache.generateElementShape(element, null)[0];
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
|
|
||||||
return shouldTestInside(element)
|
|
||||||
? getClosedCurveShape<Point>(
|
|
||||||
element,
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
)
|
|
||||||
: getCurveShape<Point>(
|
|
||||||
roughShape,
|
|
||||||
pointFrom<Point>(element.x, element.y),
|
|
||||||
element.angle,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
case "ellipse":
|
|
||||||
return getEllipseShape(element);
|
|
||||||
|
|
||||||
case "freedraw": {
|
|
||||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
|
||||||
return getFreedrawShape(
|
|
||||||
element,
|
|
||||||
pointFrom(cx, cy),
|
|
||||||
shouldTestInside(element),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
element: ExcalidrawElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
): GeometricShape<Point> | null => {
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
|
||||||
|
|
||||||
if (boundTextElement) {
|
|
||||||
if (element.type === "arrow") {
|
|
||||||
return getElementShape(
|
|
||||||
{
|
|
||||||
...boundTextElement,
|
|
||||||
// arrow's bound text accurate position is not stored in the element's property
|
|
||||||
// but rather calculated and returned from the following static method
|
|
||||||
...LinearElementEditor.getBoundTextElementPosition(
|
|
||||||
element,
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return getElementShape(boundTextElement, elementsMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getControlPointsForBezierCurve = <
|
|
||||||
P extends GlobalPoint | LocalPoint,
|
|
||||||
>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const shape = ShapeCache.generateElementShape(element, null);
|
|
||||||
if (!shape) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ops = getCurvePathOps(shape[0]);
|
|
||||||
let currentP = pointFrom<P>(0, 0);
|
|
||||||
let index = 0;
|
|
||||||
let minDistance = Infinity;
|
|
||||||
let controlPoints: P[] | null = null;
|
|
||||||
|
|
||||||
while (index < ops.length) {
|
|
||||||
const { op, data } = ops[index];
|
|
||||||
if (op === "move") {
|
|
||||||
invariant(
|
|
||||||
isPoint(data),
|
|
||||||
"The returned ops is not compatible with a point",
|
|
||||||
);
|
|
||||||
currentP = pointFromPair(data);
|
|
||||||
}
|
|
||||||
if (op === "bcurveTo") {
|
|
||||||
const p0 = currentP;
|
|
||||||
const p1 = pointFrom<P>(data[0], data[1]);
|
|
||||||
const p2 = pointFrom<P>(data[2], data[3]);
|
|
||||||
const p3 = pointFrom<P>(data[4], data[5]);
|
|
||||||
const distance = pointDistance(p3, endPoint);
|
|
||||||
if (distance < minDistance) {
|
|
||||||
minDistance = distance;
|
|
||||||
controlPoints = [p0, p1, p2, p3];
|
|
||||||
}
|
|
||||||
currentP = p3;
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return controlPoints;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p0: P,
|
|
||||||
p1: P,
|
|
||||||
p2: P,
|
|
||||||
p3: P,
|
|
||||||
t: number,
|
|
||||||
): P => {
|
|
||||||
const equation = (t: number, idx: number) =>
|
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
const tx = equation(t, 0);
|
|
||||||
const ty = equation(t, 1);
|
|
||||||
return pointFrom(tx, ty);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
|
|
||||||
if (!controlPoints) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const pointsOnCurve: P[] = [];
|
|
||||||
let t = 1;
|
|
||||||
// Take 20 points on curve for better accuracy
|
|
||||||
while (t > 0) {
|
|
||||||
const p = getBezierXY(
|
|
||||||
controlPoints[0],
|
|
||||||
controlPoints[1],
|
|
||||||
controlPoints[2],
|
|
||||||
controlPoints[3],
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
pointsOnCurve.push(pointFrom(p[0], p[1]));
|
|
||||||
t -= 0.05;
|
|
||||||
}
|
|
||||||
if (pointsOnCurve.length) {
|
|
||||||
if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
|
|
||||||
pointsOnCurve.push(pointFrom(endPoint[0], endPoint[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pointsOnCurve;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths: number[] = [];
|
|
||||||
arcLengths[0] = 0;
|
|
||||||
const points = getPointsInBezierCurve(element, endPoint);
|
|
||||||
let index = 0;
|
|
||||||
let distance = 0;
|
|
||||||
while (index < points.length - 1) {
|
|
||||||
const segmentDistance = pointDistance(points[index], points[index + 1]);
|
|
||||||
distance += segmentDistance;
|
|
||||||
arcLengths.push(distance);
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return arcLengths;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
return arcLengths.at(-1) as number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
|
|
||||||
export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
element: NonDeleted<ExcalidrawLinearElement>,
|
|
||||||
endPoint: P,
|
|
||||||
interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
|
|
||||||
) => {
|
|
||||||
const arcLengths = getBezierCurveArcLengths(element, endPoint);
|
|
||||||
const pointsCount = arcLengths.length - 1;
|
|
||||||
const curveLength = arcLengths.at(-1) as number;
|
|
||||||
const targetLength = interval * curveLength;
|
|
||||||
let low = 0;
|
|
||||||
let high = pointsCount;
|
|
||||||
let index = 0;
|
|
||||||
// Doing a binary search to find the largest length that is less than the target length
|
|
||||||
while (low < high) {
|
|
||||||
index = Math.floor(low + (high - low) / 2);
|
|
||||||
if (arcLengths[index] < targetLength) {
|
|
||||||
low = index + 1;
|
|
||||||
} else {
|
|
||||||
high = index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (arcLengths[index] > targetLength) {
|
|
||||||
index--;
|
|
||||||
}
|
|
||||||
if (arcLengths[index] === targetLength) {
|
|
||||||
return index / pointsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
1 -
|
|
||||||
(index +
|
|
||||||
(targetLength - arcLengths[index]) /
|
|
||||||
(arcLengths[index + 1] - arcLengths[index])) /
|
|
||||||
pointsCount
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the axis-aligned bounding box for a given element
|
|
||||||
*/
|
|
||||||
export const aabbForElement = (
|
|
||||||
element: Readonly<ExcalidrawElement>,
|
|
||||||
offset?: [number, number, number, number],
|
|
||||||
) => {
|
|
||||||
const bbox = {
|
|
||||||
minX: element.x,
|
|
||||||
minY: element.y,
|
|
||||||
maxX: element.x + element.width,
|
|
||||||
maxY: element.y + element.height,
|
|
||||||
midX: element.x + element.width / 2,
|
|
||||||
midY: element.y + element.height / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
const [topLeftX, topLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [topRightX, topRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.minY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomRightX, bottomRightY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.maxX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
const [bottomLeftX, bottomLeftY] = pointRotateRads(
|
|
||||||
pointFrom(bbox.minX, bbox.maxY),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bounds = [
|
|
||||||
Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
|
|
||||||
Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
if (offset) {
|
|
||||||
const [topOffset, rightOffset, downOffset, leftOffset] = offset;
|
|
||||||
return [
|
|
||||||
bounds[0] - leftOffset,
|
|
||||||
bounds[1] - topOffset,
|
|
||||||
bounds[2] + rightOffset,
|
|
||||||
bounds[3] + downOffset,
|
|
||||||
] as Bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
return bounds;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
|
||||||
p: P,
|
|
||||||
bounds: Bounds,
|
|
||||||
): boolean =>
|
|
||||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
|
||||||
|
|
||||||
export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[1]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[2], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(a[0], a[3]), b) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[1]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[2], b[3]), a) ||
|
|
||||||
pointInsideBounds(pointFrom(b[0], b[3]), a);
|
|
||||||
|
|
||||||
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
|
||||||
if (
|
|
||||||
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
|
||||||
element.roundness?.type === ROUNDNESS.LEGACY
|
|
||||||
) {
|
|
||||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
|
||||||
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
|
||||||
|
|
||||||
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
|
|
||||||
if (x <= CUTOFF_SIZE) {
|
|
||||||
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fixedRadiusSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Checks if the first and last point are close enough
|
|
||||||
// to be considered a loop
|
|
||||||
export const isPathALoop = (
|
|
||||||
points: ExcalidrawLinearElement["points"],
|
|
||||||
/** supply if you want the loop detection to account for current zoom */
|
|
||||||
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
|
||||||
): boolean => {
|
|
||||||
if (points.length >= 3) {
|
|
||||||
const [first, last] = [points[0], points[points.length - 1]];
|
|
||||||
const distance = pointDistance(first, last);
|
|
||||||
|
|
||||||
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
|
||||||
// really close we make the threshold smaller, and vice versa.
|
|
||||||
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
@ -19,9 +19,19 @@ import { newElementWith } from "./mutateElement";
|
|||||||
|
|
||||||
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
import { ElementsDelta, AppStateDelta, Delta } from "./delta";
|
||||||
|
|
||||||
import { hashElementsVersion, hashString } from "./index";
|
import {
|
||||||
|
syncInvalidIndicesImmutable,
|
||||||
|
hashElementsVersion,
|
||||||
|
hashString,
|
||||||
|
isInitializedImageElement,
|
||||||
|
isImageElement,
|
||||||
|
} from "./index";
|
||||||
|
|
||||||
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types";
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
OrderedExcalidrawElement,
|
||||||
|
SceneElementsMap,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const CaptureUpdateAction = {
|
export const CaptureUpdateAction = {
|
||||||
/**
|
/**
|
||||||
@ -105,7 +115,7 @@ export class Store {
|
|||||||
params:
|
params:
|
||||||
| {
|
| {
|
||||||
action: CaptureUpdateActionType;
|
action: CaptureUpdateActionType;
|
||||||
elements: SceneElementsMap | undefined;
|
elements: readonly ExcalidrawElement[] | undefined;
|
||||||
appState: AppState | ObservedAppState | undefined;
|
appState: AppState | ObservedAppState | undefined;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -129,13 +139,21 @@ export class Store {
|
|||||||
} else {
|
} else {
|
||||||
// immediately create an immutable change of the scheduled updates,
|
// immediately create an immutable change of the scheduled updates,
|
||||||
// compared to the current state, so that they won't mutate later on during batching
|
// compared to the current state, so that they won't mutate later on during batching
|
||||||
|
// also, we have to compare against the current state,
|
||||||
|
// as comparing against the snapshot might include yet uncomitted changes (i.e. async freedraw / text / image, etc.)
|
||||||
const currentSnapshot = StoreSnapshot.create(
|
const currentSnapshot = StoreSnapshot.create(
|
||||||
this.app.scene.getElementsMapIncludingDeleted(),
|
this.app.scene.getElementsMapIncludingDeleted(),
|
||||||
this.app.state,
|
this.app.state,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduledSnapshot = currentSnapshot.maybeClone(
|
const scheduledSnapshot = currentSnapshot.maybeClone(
|
||||||
action,
|
action,
|
||||||
params.elements,
|
// let's sync invalid indices first, so that we could detect this change
|
||||||
|
// also have the synced elements immutable, so that we don't mutate elements,
|
||||||
|
// that are already in the scene, otherwise we wouldn't see any change
|
||||||
|
params.elements
|
||||||
|
? syncInvalidIndicesImmutable(params.elements)
|
||||||
|
: undefined,
|
||||||
params.appState,
|
params.appState,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -213,16 +231,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 +514,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 +551,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 +559,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 +570,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 +583,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 +714,10 @@ export class StoreSnapshot {
|
|||||||
nextElements.set(id, changedElement);
|
nextElements.set(id, changedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAppState = Object.assign(
|
const nextAppState = getObservedAppState({
|
||||||
{},
|
...this.appState,
|
||||||
this.appState,
|
...change.appState,
|
||||||
change.appState,
|
});
|
||||||
) as ObservedAppState;
|
|
||||||
|
|
||||||
return StoreSnapshot.create(nextElements, nextAppState, {
|
return StoreSnapshot.create(nextElements, nextAppState, {
|
||||||
// by default we assume that change is different from what we have in the snapshot
|
// by default we assume that change is different from what we have in the snapshot
|
||||||
@ -847,7 +873,7 @@ export class StoreSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if there any changed elements.
|
* Detect if there are any changed elements.
|
||||||
*/
|
*/
|
||||||
private detectChangedElements(
|
private detectChangedElements(
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
@ -882,6 +908,14 @@ export class StoreSnapshot {
|
|||||||
!prevElement || // element was added
|
!prevElement || // element was added
|
||||||
prevElement.version < nextElement.version // element was updated
|
prevElement.version < nextElement.version // element was updated
|
||||||
) {
|
) {
|
||||||
|
if (
|
||||||
|
isImageElement(nextElement) &&
|
||||||
|
!isInitializedImageElement(nextElement)
|
||||||
|
) {
|
||||||
|
// ignore any updates on uninitialized image elements
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
changedElements.set(nextElement.id, nextElement);
|
changedElements.set(nextElement.id, nextElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -944,18 +978,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, {
|
||||||
|
|||||||
@ -326,10 +326,7 @@ export const getContainerCenter = (
|
|||||||
if (!midSegmentMidpoint) {
|
if (!midSegmentMidpoint) {
|
||||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||||
container,
|
container,
|
||||||
points[index],
|
|
||||||
points[index + 1],
|
|
||||||
index + 1,
|
index + 1,
|
||||||
elementsMap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||||
|
|||||||
@ -129,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 => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,259 +1,346 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_ADAPTIVE_RADIUS,
|
||||||
|
DEFAULT_PROPORTIONAL_RADIUS,
|
||||||
|
LINE_CONFIRM_THRESHOLD,
|
||||||
|
ROUNDNESS,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
curve,
|
curve,
|
||||||
|
curveCatmullRomCubicApproxPoints,
|
||||||
|
curveOffsetPoints,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
|
pointDistance,
|
||||||
pointFrom,
|
pointFrom,
|
||||||
pointFromVector,
|
pointFromArray,
|
||||||
rectangle,
|
rectangle,
|
||||||
vectorFromPoint,
|
|
||||||
vectorNormalize,
|
|
||||||
vectorScale,
|
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { elementCenterPoint } from "@excalidraw/common";
|
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import { getCornerRadius } from "./shapes";
|
|
||||||
|
|
||||||
import { getDiamondPoints } from "./bounds";
|
import { getDiamondPoints } from "./bounds";
|
||||||
|
|
||||||
|
import { generateLinearCollisionShape } from "./shape";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFreeDrawElement,
|
||||||
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
|
||||||
|
|
||||||
|
const ElementShapesCache = new WeakMap<
|
||||||
|
ExcalidrawElement,
|
||||||
|
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
offset: number,
|
||||||
|
): ElementShape | undefined => {
|
||||||
|
const record = ElementShapesCache.get(element);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version, shapes } = record;
|
||||||
|
|
||||||
|
if (version !== element.version) {
|
||||||
|
ElementShapesCache.delete(element);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shapes.get(offset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||||
|
element: T,
|
||||||
|
shape: ElementShape,
|
||||||
|
offset: number,
|
||||||
|
) => {
|
||||||
|
const record = ElementShapesCache.get(element);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
ElementShapesCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
shapes: new Map([[offset, shape]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version, shapes } = record;
|
||||||
|
|
||||||
|
if (version !== element.version) {
|
||||||
|
ElementShapesCache.set(element, {
|
||||||
|
version: element.version,
|
||||||
|
shapes: new Map([[offset, shape]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shapes.set(offset, shape);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the **rotated** components of freedraw, line or arrow elements.
|
||||||
|
*
|
||||||
|
* @param element The linear element to deconstruct
|
||||||
|
* @returns The rotated in components.
|
||||||
|
*/
|
||||||
|
export function deconstructLinearOrFreeDrawElement(
|
||||||
|
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||||
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
|
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||||
|
|
||||||
|
if (cachedShape) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops = generateLinearCollisionShape(element) as {
|
||||||
|
op: string;
|
||||||
|
data: number[];
|
||||||
|
}[];
|
||||||
|
const lines = [];
|
||||||
|
const curves = [];
|
||||||
|
|
||||||
|
for (let idx = 0; idx < ops.length; idx += 1) {
|
||||||
|
const op = ops[idx];
|
||||||
|
const prevPoint =
|
||||||
|
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
|
||||||
|
switch (op.op) {
|
||||||
|
case "move":
|
||||||
|
continue;
|
||||||
|
case "lineTo":
|
||||||
|
if (!prevPoint) {
|
||||||
|
throw new Error("prevPoint is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + prevPoint[0],
|
||||||
|
element.y + prevPoint[1],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
case "bcurveTo":
|
||||||
|
if (!prevPoint) {
|
||||||
|
throw new Error("prevPoint is undefined");
|
||||||
|
}
|
||||||
|
|
||||||
|
curves.push(
|
||||||
|
curve<GlobalPoint>(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + prevPoint[0],
|
||||||
|
element.y + prevPoint[1],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[0],
|
||||||
|
element.y + op.data[1],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[2],
|
||||||
|
element.y + op.data[3],
|
||||||
|
),
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
element.x + op.data[4],
|
||||||
|
element.y + op.data[5],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
default: {
|
||||||
|
console.error("Unknown op type", op.op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shape = [lines, curves] as ElementShape;
|
||||||
|
setElementShapesCacheEntry(element, shape, 0);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the building components of a rectanguloid element in the form of
|
* Get the building components of a rectanguloid element in the form of
|
||||||
* line segments and curves.
|
* line segments and curves **unrotated**.
|
||||||
*
|
*
|
||||||
* @param element Target rectanguloid element
|
* @param element Target rectanguloid element
|
||||||
* @param offset Optional offset to expand the rectanguloid shape
|
* @param offset Optional offset to expand the rectanguloid shape
|
||||||
* @returns Tuple of line segments (0) and curves (1)
|
* @returns Tuple of **unrotated** line segments (0) and curves (1)
|
||||||
*/
|
*/
|
||||||
export function deconstructRectanguloidElement(
|
export function deconstructRectanguloidElement(
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const roundness = getCornerRadius(
|
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||||
|
|
||||||
|
if (cachedShape) {
|
||||||
|
return cachedShape;
|
||||||
|
}
|
||||||
|
|
||||||
|
let radius = getCornerRadius(
|
||||||
Math.min(element.width, element.height),
|
Math.min(element.width, element.height),
|
||||||
element,
|
element,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (roundness <= 0) {
|
if (radius === 0) {
|
||||||
const r = rectangle(
|
radius = 0.01;
|
||||||
pointFrom(element.x - offset, element.y - offset),
|
|
||||||
pointFrom(
|
|
||||||
element.x + element.width + offset,
|
|
||||||
element.y + element.height + offset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
|
||||||
);
|
|
||||||
const right = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
|
||||||
);
|
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
|
||||||
);
|
|
||||||
const left = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
|
||||||
);
|
|
||||||
const sides = [top, right, bottom, left];
|
|
||||||
|
|
||||||
return [sides, []];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
|
||||||
|
|
||||||
const r = rectangle(
|
const r = rectangle(
|
||||||
pointFrom(element.x, element.y),
|
pointFrom(element.x, element.y),
|
||||||
pointFrom(element.x + element.width, element.y + element.height),
|
pointFrom(element.x + element.width, element.y + element.height),
|
||||||
);
|
);
|
||||||
|
|
||||||
const top = lineSegment<GlobalPoint>(
|
const top = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
|
||||||
);
|
);
|
||||||
const right = lineSegment<GlobalPoint>(
|
const right = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
|
||||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
|
||||||
);
|
);
|
||||||
const bottom = lineSegment<GlobalPoint>(
|
const bottom = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
|
||||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
|
||||||
);
|
);
|
||||||
const left = lineSegment<GlobalPoint>(
|
const left = lineSegment<GlobalPoint>(
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
|
||||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
|
||||||
);
|
);
|
||||||
|
|
||||||
const offsets = [
|
const baseCorners = [
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // TOP LEFT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), //TOP RIGHT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // BOTTOM RIGHT
|
|
||||||
vectorScale(
|
|
||||||
vectorNormalize(
|
|
||||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
|
||||||
),
|
|
||||||
offset,
|
|
||||||
), // BOTTOM LEFT
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[0], left[1]),
|
left[1],
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||||
),
|
),
|
||||||
),
|
top[0],
|
||||||
pointFromVector(offsets[0], top[0]),
|
|
||||||
), // TOP LEFT
|
), // TOP LEFT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[1], top[1]),
|
top[1],
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||||
),
|
),
|
||||||
),
|
right[0],
|
||||||
pointFromVector(offsets[1], right[0]),
|
|
||||||
), // TOP RIGHT
|
), // TOP RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[2], right[1]),
|
right[1],
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||||
),
|
),
|
||||||
),
|
bottom[1],
|
||||||
pointFromVector(offsets[2], bottom[1]),
|
|
||||||
), // BOTTOM RIGHT
|
), // BOTTOM RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(offsets[3], bottom[0]),
|
bottom[0],
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||||
),
|
),
|
||||||
),
|
left[0],
|
||||||
pointFromVector(offsets[3], left[0]),
|
|
||||||
), // BOTTOM LEFT
|
), // BOTTOM LEFT
|
||||||
];
|
];
|
||||||
|
|
||||||
const sides = [
|
const corners =
|
||||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
offset > 0
|
||||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
? baseCorners.map(
|
||||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
(corner) =>
|
||||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
curveCatmullRomCubicApproxPoints(
|
||||||
|
curveOffsetPoints(corner, offset),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
[baseCorners[0]],
|
||||||
|
[baseCorners[1]],
|
||||||
|
[baseCorners[2]],
|
||||||
|
[baseCorners[3]],
|
||||||
];
|
];
|
||||||
|
|
||||||
return [sides, corners];
|
const sides = [
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[0][corners[0].length - 1][3],
|
||||||
|
corners[1][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[1][corners[1].length - 1][3],
|
||||||
|
corners[2][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[2][corners[2].length - 1][3],
|
||||||
|
corners[3][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[3][corners[3].length - 1][3],
|
||||||
|
corners[0][0][0],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const shape = [sides, corners.flat()] as ElementShape;
|
||||||
|
|
||||||
|
setElementShapesCacheEntry(element, shape, offset);
|
||||||
|
|
||||||
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the building components of a diamond element in the form of
|
* Get the **unrotated** building components of a diamond element
|
||||||
* line segments and curves as a tuple, in this order.
|
* in the form of line segments and curves as a tuple, in this order.
|
||||||
*
|
*
|
||||||
* @param element The element to deconstruct
|
* @param element The element to deconstruct
|
||||||
* @param offset An optional offset
|
* @param offset An optional offset
|
||||||
* @returns Tuple of line segments (0) and curves (1)
|
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||||
*/
|
*/
|
||||||
export function deconstructDiamondElement(
|
export function deconstructDiamondElement(
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||||
getDiamondPoints(element);
|
|
||||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
|
||||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
|
||||||
|
|
||||||
if (element.roundness?.type == null) {
|
if (cachedShape) {
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
return cachedShape;
|
||||||
pointFrom(element.x + topX, element.y + topY - offset),
|
|
||||||
pointFrom(element.x + rightX + offset, element.y + rightY),
|
|
||||||
pointFrom(element.x + bottomX, element.y + bottomY + offset),
|
|
||||||
pointFrom(element.x + leftX - offset, element.y + leftY),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create the line segment parts of the diamond
|
|
||||||
// NOTE: Horizontal and vertical seems to be flipped here
|
|
||||||
const topRight = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
|
|
||||||
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
|
|
||||||
);
|
|
||||||
const bottomRight = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
|
|
||||||
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
|
|
||||||
);
|
|
||||||
const bottomLeft = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
|
|
||||||
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
|
|
||||||
);
|
|
||||||
const topLeft = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
|
|
||||||
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const center = elementCenterPoint(element);
|
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||||
|
getDiamondPoints(element);
|
||||||
|
const verticalRadius = element.roundness
|
||||||
|
? getCornerRadius(Math.abs(topX - leftX), element)
|
||||||
|
: (topX - leftX) * 0.01;
|
||||||
|
const horizontalRadius = element.roundness
|
||||||
|
? getCornerRadius(Math.abs(rightY - topY), element)
|
||||||
|
: (rightY - topY) * 0.01;
|
||||||
|
|
||||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||||
pointFrom(element.x + topX, element.y + topY),
|
pointFrom(element.x + topX, element.y + topY),
|
||||||
@ -262,94 +349,135 @@ export function deconstructDiamondElement(
|
|||||||
pointFrom(element.x + leftX, element.y + leftY),
|
pointFrom(element.x + leftX, element.y + leftY),
|
||||||
];
|
];
|
||||||
|
|
||||||
const offsets = [
|
const baseCorners = [
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
|
||||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners = [
|
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[0] - verticalRadius,
|
right[0] - verticalRadius,
|
||||||
right[1] - horizontalRadius,
|
right[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
right,
|
||||||
pointFromVector(offsets[0], right),
|
right,
|
||||||
pointFromVector(offsets[0], right),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[0],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
right[0] - verticalRadius,
|
right[0] - verticalRadius,
|
||||||
right[1] + horizontalRadius,
|
right[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // RIGHT
|
), // RIGHT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[0] + verticalRadius,
|
bottom[0] + verticalRadius,
|
||||||
bottom[1] - horizontalRadius,
|
bottom[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
bottom,
|
||||||
pointFromVector(offsets[1], bottom),
|
bottom,
|
||||||
pointFromVector(offsets[1], bottom),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[1],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
bottom[0] - verticalRadius,
|
bottom[0] - verticalRadius,
|
||||||
bottom[1] - horizontalRadius,
|
bottom[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // BOTTOM
|
), // BOTTOM
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[0] + verticalRadius,
|
left[0] + verticalRadius,
|
||||||
left[1] + horizontalRadius,
|
left[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
left,
|
||||||
pointFromVector(offsets[2], left),
|
left,
|
||||||
pointFromVector(offsets[2], left),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[2],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
left[0] + verticalRadius,
|
left[0] + verticalRadius,
|
||||||
left[1] - horizontalRadius,
|
left[1] - horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // LEFT
|
), // LEFT
|
||||||
curve(
|
curve(
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[0] - verticalRadius,
|
top[0] - verticalRadius,
|
||||||
top[1] + horizontalRadius,
|
top[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
top,
|
||||||
pointFromVector(offsets[3], top),
|
top,
|
||||||
pointFromVector(offsets[3], top),
|
|
||||||
pointFromVector(
|
|
||||||
offsets[3],
|
|
||||||
pointFrom<GlobalPoint>(
|
pointFrom<GlobalPoint>(
|
||||||
top[0] + verticalRadius,
|
top[0] + verticalRadius,
|
||||||
top[1] + horizontalRadius,
|
top[1] + horizontalRadius,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
), // TOP
|
), // TOP
|
||||||
];
|
];
|
||||||
|
|
||||||
const sides = [
|
const corners =
|
||||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
offset > 0
|
||||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
? baseCorners.map(
|
||||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
(corner) =>
|
||||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
curveCatmullRomCubicApproxPoints(
|
||||||
|
curveOffsetPoints(corner, offset),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: [
|
||||||
|
[baseCorners[0]],
|
||||||
|
[baseCorners[1]],
|
||||||
|
[baseCorners[2]],
|
||||||
|
[baseCorners[3]],
|
||||||
];
|
];
|
||||||
|
|
||||||
return [sides, corners];
|
const sides = [
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[0][corners[0].length - 1][3],
|
||||||
|
corners[1][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[1][corners[1].length - 1][3],
|
||||||
|
corners[2][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[2][corners[2].length - 1][3],
|
||||||
|
corners[3][0][0],
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
corners[3][corners[3].length - 1][3],
|
||||||
|
corners[0][0][0],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const shape = [sides, corners.flat()] as ElementShape;
|
||||||
|
|
||||||
|
setElementShapesCacheEntry(element, shape, offset);
|
||||||
|
|
||||||
|
return shape;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks if the first and last point are close enough
|
||||||
|
// to be considered a loop
|
||||||
|
export const isPathALoop = (
|
||||||
|
points: ExcalidrawLinearElement["points"],
|
||||||
|
/** supply if you want the loop detection to account for current zoom */
|
||||||
|
zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
|
||||||
|
): boolean => {
|
||||||
|
if (points.length >= 3) {
|
||||||
|
const [first, last] = [points[0], points[points.length - 1]];
|
||||||
|
const distance = pointDistance(first, last);
|
||||||
|
|
||||||
|
// Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
|
||||||
|
// really close we make the threshold smaller, and vice versa.
|
||||||
|
return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||||
|
if (
|
||||||
|
element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
|
||||||
|
element.roundness?.type === ROUNDNESS.LEGACY
|
||||||
|
) {
|
||||||
|
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
|
||||||
|
const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
|
||||||
|
|
||||||
|
const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
|
||||||
|
|
||||||
|
if (x <= CUTOFF_SIZE) {
|
||||||
|
return x * DEFAULT_PROPORTIONAL_RADIUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedRadiusSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|||||||
@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
|||||||
class="excalidraw-wysiwyg"
|
class="excalidraw-wysiwyg"
|
||||||
data-type="wysiwyg"
|
data-type="wysiwyg"
|
||||||
dir="auto"
|
dir="auto"
|
||||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
|
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
wrap="off"
|
wrap="off"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -202,6 +204,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -215,6 +218,7 @@ describe("aligning", () => {
|
|||||||
// Add the created group to the current selection
|
// Add the created group to the current selection
|
||||||
mouse.restorePosition(0, 0);
|
mouse.restorePosition(0, 0);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -316,6 +320,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already selected because it was the last element created
|
// The second rectangle is already selected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -330,7 +335,7 @@ describe("aligning", () => {
|
|||||||
mouse.down();
|
mouse.down();
|
||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
|
|
||||||
mouse.restorePosition(200, 200);
|
mouse.restorePosition(210, 200);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
@ -341,6 +346,7 @@ describe("aligning", () => {
|
|||||||
// The second group is already selected because it was the last group created
|
// The second group is already selected because it was the last group created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -454,6 +460,7 @@ describe("aligning", () => {
|
|||||||
// The second rectangle is already reselected because it was the last element created
|
// The second rectangle is already reselected because it was the last element created
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -466,7 +473,7 @@ describe("aligning", () => {
|
|||||||
mouse.up(100, 100);
|
mouse.up(100, 100);
|
||||||
|
|
||||||
// Add group to current selection
|
// Add group to current selection
|
||||||
mouse.restorePosition(0, 0);
|
mouse.restorePosition(10, 0);
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
@ -482,6 +489,7 @@ describe("aligning", () => {
|
|||||||
// Select the nested group, the rectangle is already selected
|
// Select the nested group, the rectangle is already selected
|
||||||
mouse.reset();
|
mouse.reset();
|
||||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||||
|
mouse.moveTo(10, 0);
|
||||||
mouse.click();
|
mouse.click();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
packages/element/tests/collision.test.tsx
Normal file
38
packages/element/tests/collision.test.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||||
|
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||||
|
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
|
import "@excalidraw/utils/test-utils";
|
||||||
|
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||||
|
|
||||||
|
import { hitElementItself } from "../src/collision";
|
||||||
|
|
||||||
|
describe("check rotated elements can be hit:", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arrow", () => {
|
||||||
|
UI.createElement("arrow", {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 124,
|
||||||
|
height: 302,
|
||||||
|
angle: 1.8700426423973724,
|
||||||
|
points: [
|
||||||
|
[0, 0],
|
||||||
|
[120, -198],
|
||||||
|
[-4, -302],
|
||||||
|
] as LocalPoint[],
|
||||||
|
});
|
||||||
|
//const p = [120, -211];
|
||||||
|
//const p = [0, 13];
|
||||||
|
const hit = hitElementItself({
|
||||||
|
point: pointFrom<GlobalPoint>(88, -68),
|
||||||
|
element: window.h.elements[0],
|
||||||
|
threshold: 10,
|
||||||
|
elementsMap: window.h.scene.getNonDeletedElementsMap(),
|
||||||
|
});
|
||||||
|
expect(hit).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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");
|
||||||
@ -346,12 +423,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"55.96978",
|
"54.27552",
|
||||||
"47.44233",
|
"46.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"76.08587",
|
"76.95494",
|
||||||
"43.29417",
|
"44.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -411,12 +488,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"105.96978",
|
"104.27552",
|
||||||
"67.44233",
|
"66.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"126.08587",
|
"126.95494",
|
||||||
"63.29417",
|
"64.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -727,12 +804,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"31.88408",
|
"29.28349",
|
||||||
"23.13276",
|
"20.91105",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"77.74793",
|
"78.86048",
|
||||||
"44.57841",
|
"46.12277",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -816,12 +893,12 @@ describe("Test Linear Elements", () => {
|
|||||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
"55.96978",
|
"54.27552",
|
||||||
"47.44233",
|
"46.16120",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"76.08587",
|
"76.95494",
|
||||||
"43.29417",
|
"44.56052",
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
@ -983,19 +1060,17 @@ describe("Test Linear Elements", () => {
|
|||||||
);
|
);
|
||||||
expect(position).toMatchInlineSnapshot(`
|
expect(position).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
"x": "85.82202",
|
"x": "86.17305",
|
||||||
"y": "75.63461",
|
"y": "76.11251",
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should match styles for text editor", () => {
|
it("should match styles for text editor", async () => {
|
||||||
createTwoPointerLinearElement("arrow");
|
createTwoPointerLinearElement("arrow");
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = document.querySelector(
|
const editor = await getTextEditor();
|
||||||
".excalidraw-textEditorContainer > textarea",
|
|
||||||
) as HTMLTextAreaElement;
|
|
||||||
expect(editor).toMatchSnapshot();
|
expect(editor).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1012,9 +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(
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,7 +14,12 @@ import {
|
|||||||
isLineElement,
|
isLineElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
import {
|
||||||
|
KEYS,
|
||||||
|
arrayToMap,
|
||||||
|
tupleToCoors,
|
||||||
|
updateActiveTool,
|
||||||
|
} from "@excalidraw/common";
|
||||||
import { isPathALoop } from "@excalidraw/element";
|
import { isPathALoop } from "@excalidraw/element";
|
||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
@ -43,12 +48,16 @@ export const actionFinalize = register({
|
|||||||
trackEvent: false,
|
trackEvent: false,
|
||||||
perform: (elements, appState, data, app) => {
|
perform: (elements, appState, data, app) => {
|
||||||
const { interactiveCanvas, focusContainer, scene } = app;
|
const { interactiveCanvas, focusContainer, scene } = app;
|
||||||
|
const { event, sceneCoords } =
|
||||||
|
(data as {
|
||||||
|
event?: PointerEvent;
|
||||||
|
sceneCoords?: { x: number; y: number };
|
||||||
|
}) ?? {};
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
if (data?.event && appState.selectedLinearElement) {
|
if (event && appState.selectedLinearElement) {
|
||||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||||
data.event,
|
event,
|
||||||
appState.selectedLinearElement,
|
appState.selectedLinearElement,
|
||||||
appState,
|
appState,
|
||||||
app.scene,
|
app.scene,
|
||||||
@ -122,18 +131,6 @@ 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();
|
||||||
}
|
}
|
||||||
@ -216,12 +213,17 @@ export const actionFinalize = register({
|
|||||||
element.points.length > 1 &&
|
element.points.length > 1 &&
|
||||||
isBindingEnabled(appState)
|
isBindingEnabled(appState)
|
||||||
) {
|
) {
|
||||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
const coords =
|
||||||
|
sceneCoords ??
|
||||||
|
tupleToCoors(
|
||||||
|
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
element,
|
element,
|
||||||
-1,
|
-1,
|
||||||
arrayToMap(elements),
|
arrayToMap(elements),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
maybeBindLinearElement(element, appState, { x, y }, scene);
|
|
||||||
|
maybeBindLinearElement(element, appState, coords, scene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -280,7 +282,6 @@ export const actionFinalize = register({
|
|||||||
element && isLinearElement(element)
|
element && isLinearElement(element)
|
||||||
? new LinearElementEditor(element, arrayToMap(newElements))
|
? new LinearElementEditor(element, 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,
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import {
|
|||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import {
|
||||||
|
toggleLinePolygonState,
|
||||||
|
CaptureUpdateAction,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@ -22,8 +25,6 @@ import { ButtonIcon } from "../components/ButtonIcon";
|
|||||||
|
|
||||||
import { newElementWith } from "../../element/src/mutateElement";
|
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({
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
tupleToCoors,
|
|
||||||
getLineHeight,
|
getLineHeight,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
reduceToCommonValue,
|
reduceToCommonValue,
|
||||||
@ -28,9 +27,7 @@ import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
bindLinearElement,
|
bindLinearElement,
|
||||||
bindPointToSnapToElementOutline,
|
|
||||||
calculateFixedPointForElbowArrowBinding,
|
calculateFixedPointForElbowArrowBinding,
|
||||||
getHoveredElementForBinding,
|
|
||||||
updateBoundElements,
|
updateBoundElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
@ -55,9 +52,11 @@ import {
|
|||||||
|
|
||||||
import { hasStrokeColor } from "@excalidraw/element";
|
import { hasStrokeColor } from "@excalidraw/element";
|
||||||
|
|
||||||
import { updateElbowArrowPoints } from "@excalidraw/element";
|
import {
|
||||||
|
updateElbowArrowPoints,
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
CaptureUpdateAction,
|
||||||
|
toggleLinePolygonState,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -138,8 +137,6 @@ 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";
|
||||||
@ -1661,64 +1658,17 @@ export const actionChangeArrowType = register({
|
|||||||
-1,
|
-1,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const startHoveredElement =
|
const startElement =
|
||||||
!newElement.startBinding &&
|
newElement.startBinding &&
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(startGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const endHoveredElement =
|
|
||||||
!newElement.endBinding &&
|
|
||||||
getHoveredElementForBinding(
|
|
||||||
tupleToCoors(endGlobalPoint),
|
|
||||||
elements,
|
|
||||||
elementsMap,
|
|
||||||
appState.zoom,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const startElement = startHoveredElement
|
|
||||||
? startHoveredElement
|
|
||||||
: newElement.startBinding &&
|
|
||||||
(elementsMap.get(
|
(elementsMap.get(
|
||||||
newElement.startBinding.elementId,
|
newElement.startBinding.elementId,
|
||||||
) as ExcalidrawBindableElement);
|
) as ExcalidrawBindableElement);
|
||||||
const endElement = endHoveredElement
|
const endElement =
|
||||||
? endHoveredElement
|
newElement.endBinding &&
|
||||||
: newElement.endBinding &&
|
|
||||||
(elementsMap.get(
|
(elementsMap.get(
|
||||||
newElement.endBinding.elementId,
|
newElement.endBinding.elementId,
|
||||||
) as ExcalidrawBindableElement);
|
) as ExcalidrawBindableElement);
|
||||||
|
|
||||||
const finalStartPoint = startHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
)
|
|
||||||
: startGlobalPoint;
|
|
||||||
const finalEndPoint = endHoveredElement
|
|
||||||
? bindPointToSnapToElementOutline(
|
|
||||||
newElement,
|
|
||||||
endHoveredElement,
|
|
||||||
"end",
|
|
||||||
)
|
|
||||||
: endGlobalPoint;
|
|
||||||
|
|
||||||
startHoveredElement &&
|
|
||||||
bindLinearElement(
|
|
||||||
newElement,
|
|
||||||
startHoveredElement,
|
|
||||||
"start",
|
|
||||||
app.scene,
|
|
||||||
);
|
|
||||||
endHoveredElement &&
|
|
||||||
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
|
|
||||||
|
|
||||||
const startBinding =
|
const startBinding =
|
||||||
startElement && newElement.startBinding
|
startElement && newElement.startBinding
|
||||||
? {
|
? {
|
||||||
@ -1728,6 +1678,7 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
startElement,
|
startElement,
|
||||||
"start",
|
"start",
|
||||||
|
elementsMap,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@ -1740,6 +1691,7 @@ export const actionChangeArrowType = register({
|
|||||||
newElement,
|
newElement,
|
||||||
endElement,
|
endElement,
|
||||||
"end",
|
"end",
|
||||||
|
elementsMap,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@ -1749,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
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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
@ -503,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 &&
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import {
|
|||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { resizeSingleElement } from "@excalidraw/element";
|
import { resizeSingleElement } from "@excalidraw/element";
|
||||||
import { isImageElement } from "@excalidraw/element";
|
import { isImageElement } from "@excalidraw/element";
|
||||||
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
import { getElementsInResizingFrame } from "@excalidraw/element";
|
||||||
|
import { replaceAllElementsInFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
|
|||||||
import DragInput from "./DragInput";
|
import DragInput from "./DragInput";
|
||||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type {
|
||||||
|
DragFinishedCallbackType,
|
||||||
|
DragInputCallbackType,
|
||||||
|
} from "./DragInput";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
interface DimensionDragInputProps {
|
interface DimensionDragInputProps {
|
||||||
@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
originalAppState,
|
originalAppState,
|
||||||
instantChange,
|
instantChange,
|
||||||
scene,
|
scene,
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const origElement = originalElements[0];
|
const origElement = originalElements[0];
|
||||||
@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User types in a value to stats then presses Enter
|
||||||
if (nextValue !== undefined) {
|
if (nextValue !== undefined) {
|
||||||
const nextWidth = Math.max(
|
const nextWidth = Math.max(
|
||||||
property === "width"
|
property === "width"
|
||||||
@ -184,8 +193,30 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.replaceAllElements(updatedElements);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats slider is dragged
|
||||||
|
{
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
|
||||||
@ -230,6 +261,55 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
shouldMaintainAspectRatio: keepAspectRatio,
|
shouldMaintainAspectRatio: keepAspectRatio,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle highlighting frame element candidates
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight: nextElementsInFrame,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragFinished: DragFinishedCallbackType = ({
|
||||||
|
setAppState,
|
||||||
|
app,
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements?.[0];
|
||||||
|
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.scene.replaceAllElements(updatedElements);
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -269,6 +349,7 @@ const DimensionDragInput = ({
|
|||||||
scene={scene}
|
scene={scene}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
property={property}
|
property={property}
|
||||||
|
dragFinishedCallback={handleDragFinished}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
|||||||
|
|
||||||
import type { Scene } from "@excalidraw/element";
|
import type { Scene } from "@excalidraw/element";
|
||||||
|
|
||||||
import { useApp } from "../App";
|
import { useApp, useExcalidrawSetAppState } from "../App";
|
||||||
import { InlineIcon } from "../InlineIcon";
|
import { InlineIcon } from "../InlineIcon";
|
||||||
|
|
||||||
import { SMALLEST_DELTA } from "./utils";
|
import { SMALLEST_DELTA } from "./utils";
|
||||||
@ -36,6 +36,15 @@ export type DragInputCallbackType<
|
|||||||
property: P;
|
property: P;
|
||||||
originalAppState: AppState;
|
originalAppState: AppState;
|
||||||
setInputValue: (value: number) => void;
|
setInputValue: (value: number) => void;
|
||||||
|
app: ReturnType<typeof useApp>;
|
||||||
|
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
|
||||||
|
app: ReturnType<typeof useApp>;
|
||||||
|
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
|
||||||
|
originalElements: readonly E[] | null;
|
||||||
|
originalAppState: AppState;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
interface StatsDragInputProps<
|
interface StatsDragInputProps<
|
||||||
@ -54,6 +63,7 @@ interface StatsDragInputProps<
|
|||||||
appState: AppState;
|
appState: AppState;
|
||||||
/** how many px you need to drag to get 1 unit change */
|
/** how many px you need to drag to get 1 unit change */
|
||||||
sensitivity?: number;
|
sensitivity?: number;
|
||||||
|
dragFinishedCallback?: DragFinishedCallbackType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatsDragInput = <
|
const StatsDragInput = <
|
||||||
@ -71,8 +81,10 @@ const StatsDragInput = <
|
|||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
sensitivity = 1,
|
sensitivity = 1,
|
||||||
|
dragFinishedCallback,
|
||||||
}: StatsDragInputProps<T, E>) => {
|
}: StatsDragInputProps<T, E>) => {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
const setAppState = useExcalidrawSetAppState();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const labelRef = useRef<HTMLDivElement>(null);
|
const labelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -137,6 +149,8 @@ const StatsDragInput = <
|
|||||||
property,
|
property,
|
||||||
originalAppState: appState,
|
originalAppState: appState,
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
});
|
});
|
||||||
app.syncActionResult({
|
app.syncActionResult({
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@ -263,6 +277,8 @@ const StatsDragInput = <
|
|||||||
scene,
|
scene,
|
||||||
originalAppState,
|
originalAppState,
|
||||||
setInputValue: (value) => setInputValue(String(value)),
|
setInputValue: (value) => setInputValue(String(value)),
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
});
|
});
|
||||||
|
|
||||||
stepChange = 0;
|
stepChange = 0;
|
||||||
@ -287,6 +303,14 @@ const StatsDragInput = <
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify implementors
|
||||||
|
dragFinishedCallback?.({
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
});
|
||||||
|
|
||||||
lastPointer = null;
|
lastPointer = null;
|
||||||
accumulatedChange = 0;
|
accumulatedChange = 0;
|
||||||
stepChange = 0;
|
stepChange = 0;
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
|
||||||
import { updateBoundElements } from "@excalidraw/element";
|
import {
|
||||||
|
getElementsInResizingFrame,
|
||||||
|
isFrameLikeElement,
|
||||||
|
replaceAllElementsInFrame,
|
||||||
|
updateBoundElements,
|
||||||
|
} from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
rescalePointsInElement,
|
rescalePointsInElement,
|
||||||
resizeSingleElement,
|
resizeSingleElement,
|
||||||
@ -25,7 +30,10 @@ import DragInput from "./DragInput";
|
|||||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||||
import { getElementsInAtomicUnit } from "./utils";
|
import { getElementsInAtomicUnit } from "./utils";
|
||||||
|
|
||||||
import type { DragInputCallbackType } from "./DragInput";
|
import type {
|
||||||
|
DragFinishedCallbackType,
|
||||||
|
DragInputCallbackType,
|
||||||
|
} from "./DragInput";
|
||||||
import type { AtomicUnit } from "./utils";
|
import type { AtomicUnit } from "./utils";
|
||||||
import type { AppState } from "../../types";
|
import type { AppState } from "../../types";
|
||||||
|
|
||||||
@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
nextValue,
|
nextValue,
|
||||||
scene,
|
scene,
|
||||||
property,
|
property,
|
||||||
|
setAppState,
|
||||||
|
app,
|
||||||
}) => {
|
}) => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||||
@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
shouldInformMutation: false,
|
shouldInformMutation: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.replaceAllElements(updatedElements);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -250,6 +279,7 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
|
|
||||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||||
|
const elementsToHighlight: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
for (const atomicUnit of atomicUnits) {
|
for (const atomicUnit of atomicUnits) {
|
||||||
const elementsInUnit = getElementsInAtomicUnit(
|
const elementsInUnit = getElementsInAtomicUnit(
|
||||||
@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType<
|
|||||||
shouldInformMutation: false,
|
shouldInformMutation: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle highlighting frame element candidates
|
||||||
|
if (isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
elementsToHighlight.push(...nextElementsInFrame);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight,
|
||||||
|
});
|
||||||
|
|
||||||
scene.triggerUpdate();
|
scene.triggerUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragFinished: DragFinishedCallbackType = ({
|
||||||
|
setAppState,
|
||||||
|
app,
|
||||||
|
originalElements,
|
||||||
|
originalAppState,
|
||||||
|
}) => {
|
||||||
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
const origElement = originalElements?.[0];
|
||||||
|
const latestElement = origElement && elementsMap.get(origElement.id);
|
||||||
|
|
||||||
|
// Handle frame membership update for resized frames
|
||||||
|
if (latestElement && isFrameLikeElement(latestElement)) {
|
||||||
|
const nextElementsInFrame = getElementsInResizingFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
latestElement,
|
||||||
|
originalAppState,
|
||||||
|
app.scene.getNonDeletedElementsMap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedElements = replaceAllElementsInFrame(
|
||||||
|
app.scene.getElementsIncludingDeleted(),
|
||||||
|
nextElementsInFrame,
|
||||||
|
latestElement,
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
|
||||||
|
app.scene.replaceAllElements(updatedElements);
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
elementsToHighlight: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const MultiDimension = ({
|
const MultiDimension = ({
|
||||||
property,
|
property,
|
||||||
elements,
|
elements,
|
||||||
@ -396,6 +476,7 @@ const MultiDimension = ({
|
|||||||
appState={appState}
|
appState={appState}
|
||||||
property={property}
|
property={property}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
|
dragFinishedCallback={handleDragFinished}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
@ -403,11 +401,23 @@ describe("stats for a non-generic element", () => {
|
|||||||
UI.updateInput(input, "36");
|
UI.updateInput(input, "36");
|
||||||
expect(text.fontSize).toBe(36);
|
expect(text.fontSize).toBe(36);
|
||||||
|
|
||||||
// cannot change width or height
|
// can change width or height
|
||||||
const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
|
const width = UI.queryStatsProperty("W")?.querySelector(
|
||||||
expect(width).toBeUndefined();
|
".drag-input",
|
||||||
const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
|
) as HTMLInputElement;
|
||||||
expect(height).toBeUndefined();
|
expect(width).toBeDefined();
|
||||||
|
const height = UI.queryStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(height).toBeDefined();
|
||||||
|
|
||||||
|
const textHeightBeforeWrapping = text.height;
|
||||||
|
const textBeforeWrapping = text.text;
|
||||||
|
const originalTextBeforeWrapping = textBeforeWrapping;
|
||||||
|
UI.updateInput(width, "30");
|
||||||
|
expect(text.height).toBeGreaterThan(textHeightBeforeWrapping);
|
||||||
|
expect(text.text).not.toBe(textBeforeWrapping);
|
||||||
|
expect(text.originalText).toBe(originalTextBeforeWrapping);
|
||||||
|
|
||||||
// min font size is 4
|
// min font size is 4
|
||||||
UI.updateInput(input, "0");
|
UI.updateInput(input, "0");
|
||||||
@ -576,8 +586,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();
|
||||||
@ -630,12 +639,11 @@ describe("stats for multiple elements", () => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
expect(fontSize).toBeDefined();
|
expect(fontSize).toBeDefined();
|
||||||
|
|
||||||
// changing width does not affect text
|
|
||||||
UI.updateInput(width, "200");
|
UI.updateInput(width, "200");
|
||||||
|
|
||||||
expect(rectangle?.width).toBe(200);
|
expect(rectangle?.width).toBe(200);
|
||||||
expect(frame.width).toBe(200);
|
expect(frame.width).toBe(200);
|
||||||
expect(text?.width).not.toBe(200);
|
expect(text?.width).toBe(200);
|
||||||
|
|
||||||
UI.updateInput(angle, "40");
|
UI.updateInput(angle, "40");
|
||||||
|
|
||||||
@ -657,6 +665,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -728,3 +737,196 @@ describe("stats for multiple elements", () => {
|
|||||||
expect(newGroupHeight).toBeCloseTo(500, 4);
|
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("frame resizing behavior", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
renderStaticScene.mockClear();
|
||||||
|
reseed(7);
|
||||||
|
setDateTimeForTests("201933152653");
|
||||||
|
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
|
API.setElements([]);
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
stats = UI.queryStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreOriginalGetBoundingClientRect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add shapes to frame when resizing frame to encompass them", () => {
|
||||||
|
// Create a frame
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a rectangle outside the frame
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 150,
|
||||||
|
y: 50,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle]);
|
||||||
|
|
||||||
|
// Initially, rectangle should not be in the frame
|
||||||
|
expect(rectangle.frameId).toBe(null);
|
||||||
|
|
||||||
|
// Select the frame
|
||||||
|
API.setAppState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// Find the width input and update it to encompass the rectangle
|
||||||
|
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(widthInput).toBeDefined();
|
||||||
|
expect(widthInput.value).toBe("100");
|
||||||
|
|
||||||
|
// Resize frame to width 250, which should encompass the rectangle
|
||||||
|
UI.updateInput(widthInput, "250");
|
||||||
|
|
||||||
|
// After resizing, the rectangle should now be part of the frame
|
||||||
|
expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add multiple shapes when frame encompasses them through height resize", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectangle1 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 50,
|
||||||
|
y: 150,
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rectangle2 = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 100,
|
||||||
|
y: 180,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, rectangle1, rectangle2]);
|
||||||
|
|
||||||
|
// Initially, rectangles should not be in the frame
|
||||||
|
expect(rectangle1.frameId).toBe(null);
|
||||||
|
expect(rectangle2.frameId).toBe(null);
|
||||||
|
|
||||||
|
// Select the frame
|
||||||
|
API.setAppState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// Resize frame height to encompass both rectangles
|
||||||
|
const heightInput = UI.queryStatsProperty("H")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
// Resize frame to height 250, which should encompass both rectangles
|
||||||
|
UI.updateInput(heightInput, "250");
|
||||||
|
|
||||||
|
// After resizing, both rectangles should now be part of the frame
|
||||||
|
expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect shapes that remain outside frame after resize", () => {
|
||||||
|
const frame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const insideRect = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 120,
|
||||||
|
y: 50,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
const outsideRect = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 300,
|
||||||
|
y: 50,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, insideRect, outsideRect]);
|
||||||
|
|
||||||
|
// Initially, both rectangles should not be in the frame
|
||||||
|
expect(insideRect.frameId).toBe(null);
|
||||||
|
expect(outsideRect.frameId).toBe(null);
|
||||||
|
|
||||||
|
// Select the frame
|
||||||
|
API.setAppState({
|
||||||
|
selectedElementIds: {
|
||||||
|
[frame.id]: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
elementStats = stats?.querySelector("#elementStats");
|
||||||
|
|
||||||
|
// Resize frame width to 200, which should only encompass insideRect
|
||||||
|
const widthInput = UI.queryStatsProperty("W")?.querySelector(
|
||||||
|
".drag-input",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
UI.updateInput(widthInput, "200");
|
||||||
|
|
||||||
|
// After resizing, only insideRect should be in the frame
|
||||||
|
expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe(
|
||||||
|
frame.id,
|
||||||
|
);
|
||||||
|
expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getBoundTextElement } from "@excalidraw/element";
|
import { getBoundTextElement } from "@excalidraw/element";
|
||||||
import { isFrameLikeElement, isTextElement } from "@excalidraw/element";
|
import { isFrameLikeElement } from "@excalidraw/element";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
@ -41,12 +41,6 @@ export const isPropertyEditable = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
property: keyof ExcalidrawElement,
|
property: keyof ExcalidrawElement,
|
||||||
) => {
|
) => {
|
||||||
if (property === "height" && isTextElement(element)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (property === "width" && isTextElement(element)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (property === "angle" && isFrameLikeElement(element)) {
|
if (property === "angle" && isFrameLikeElement(element)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
@ -1540,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,
|
||||||
@ -1791,7 +1791,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||||||
"versionNonce": Any<Number>,
|
"versionNonce": Any<Number>,
|
||||||
"verticalAlign": "middle",
|
"verticalAlign": "middle",
|
||||||
"width": 120,
|
"width": 120,
|
||||||
"x": 187.7545,
|
"x": 187.75450000000004,
|
||||||
"y": 44.5,
|
"y": 44.5,
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@ -526,6 +526,7 @@ const adjustElbowArrowPoints = (elements: ExcalidrawElement[]) => {
|
|||||||
element,
|
element,
|
||||||
endBindableElement,
|
endBindableElement,
|
||||||
"end",
|
"end",
|
||||||
|
elementsMap,
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
|
|
||||||
if (newFixedPoint) {
|
if (newFixedPoint) {
|
||||||
@ -568,6 +569,7 @@ const adjustElbowArrowPoints = (elements: ExcalidrawElement[]) => {
|
|||||||
element,
|
element,
|
||||||
startBindableElement,
|
startBindableElement,
|
||||||
"start",
|
"start",
|
||||||
|
elementsMap,
|
||||||
).fixedPoint;
|
).fixedPoint;
|
||||||
if (newFixedPoint) {
|
if (newFixedPoint) {
|
||||||
Object.assign(element.startBinding, {
|
Object.assign(element.startBinding, {
|
||||||
|
|||||||
@ -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(
|
},
|
||||||
|
elementsMap,
|
||||||
pathSegment,
|
pathSegment,
|
||||||
elementSegment,
|
0,
|
||||||
app.getElementHitThreshold(),
|
true,
|
||||||
) !== null,
|
).length > 0)
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const change = StoreChange.create(prevSnapshot, nextSnapshot);
|
||||||
|
const delta = HistoryDelta.applyLatestChanges(
|
||||||
|
historyDelta,
|
||||||
|
prevElements,
|
||||||
|
nextElements,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!delta.isEmpty()) {
|
||||||
// schedule immediate capture, so that it's emitted for the sync purposes
|
// schedule immediate capture, so that it's emitted for the sync purposes
|
||||||
this.store.scheduleMicroAction({
|
this.store.scheduleMicroAction({
|
||||||
action,
|
action,
|
||||||
change: StoreChange.create(prevSnapshot, nextSnapshot),
|
change,
|
||||||
delta: historyEntry,
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -344,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",
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
import { elementCenterPoint, THEME, THEME_FILTER } from "@excalidraw/common";
|
import { THEME, THEME_FILTER } from "@excalidraw/common";
|
||||||
|
|
||||||
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
|
import { FIXED_BINDING_DISTANCE } from "@excalidraw/element";
|
||||||
import { getDiamondPoints } from "@excalidraw/element";
|
import { getDiamondPoints } from "@excalidraw/element";
|
||||||
import { getCornerRadius } from "@excalidraw/element";
|
import { elementCenterPoint, 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 {
|
||||||
|
ElementsMap,
|
||||||
ExcalidrawDiamondElement,
|
ExcalidrawDiamondElement,
|
||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
@ -102,25 +100,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -128,35 +115,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,11 +129,12 @@ function drawCatmullRomCubicApprox(
|
|||||||
export const drawHighlightForRectWithRotation = (
|
export const drawHighlightForRectWithRotation = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
element: ExcalidrawRectanguloidElement,
|
element: ExcalidrawRectanguloidElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
padding: number,
|
padding: number,
|
||||||
) => {
|
) => {
|
||||||
const [x, y] = pointRotateRads(
|
const [x, y] = pointRotateRads(
|
||||||
pointFrom<GlobalPoint>(element.x, element.y),
|
pointFrom<GlobalPoint>(element.x, element.y),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -187,25 +153,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),
|
||||||
@ -230,25 +196,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,10 +288,11 @@ export const drawHighlightForDiamondWithRotation = (
|
|||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
padding: number,
|
padding: number,
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
|
elementsMap: ElementsMap,
|
||||||
) => {
|
) => {
|
||||||
const [x, y] = pointRotateRads(
|
const [x, y] = pointRotateRads(
|
||||||
pointFrom<GlobalPoint>(element.x, element.y),
|
pointFrom<GlobalPoint>(element.x, element.y),
|
||||||
elementCenterPoint(element),
|
elementCenterPoint(element, elementsMap),
|
||||||
element.angle,
|
element.angle,
|
||||||
);
|
);
|
||||||
context.save();
|
context.save();
|
||||||
@ -343,32 +310,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(
|
||||||
|
curve(
|
||||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||||
pointFrom(topX, topY),
|
pointFrom(topX, topY),
|
||||||
pointFrom(topX, topY),
|
pointFrom(topX, topY),
|
||||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||||
|
),
|
||||||
padding,
|
padding,
|
||||||
);
|
);
|
||||||
const rightApprox = offsetCubicBezier(
|
const rightApprox = curveOffsetPoints(
|
||||||
|
curve(
|
||||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||||
pointFrom(rightX, rightY),
|
pointFrom(rightX, rightY),
|
||||||
pointFrom(rightX, rightY),
|
pointFrom(rightX, rightY),
|
||||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||||
|
),
|
||||||
padding,
|
padding,
|
||||||
);
|
);
|
||||||
const bottomApprox = offsetCubicBezier(
|
const bottomApprox = curveOffsetPoints(
|
||||||
|
curve(
|
||||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||||
pointFrom(bottomX, bottomY),
|
pointFrom(bottomX, bottomY),
|
||||||
pointFrom(bottomX, bottomY),
|
pointFrom(bottomX, bottomY),
|
||||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||||
|
),
|
||||||
padding,
|
padding,
|
||||||
);
|
);
|
||||||
const leftApprox = offsetCubicBezier(
|
const leftApprox = curveOffsetPoints(
|
||||||
|
curve(
|
||||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||||
pointFrom(leftX, leftY),
|
pointFrom(leftX, leftY),
|
||||||
pointFrom(leftX, leftY),
|
pointFrom(leftX, leftY),
|
||||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||||
|
),
|
||||||
padding,
|
padding,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -376,13 +351,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -398,32 +373,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(
|
||||||
|
curve(
|
||||||
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
pointFrom(topX + verticalRadius, topY + horizontalRadius),
|
||||||
pointFrom(topX, topY),
|
pointFrom(topX, topY),
|
||||||
pointFrom(topX, topY),
|
pointFrom(topX, topY),
|
||||||
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
pointFrom(topX - verticalRadius, topY + horizontalRadius),
|
||||||
|
),
|
||||||
-FIXED_BINDING_DISTANCE,
|
-FIXED_BINDING_DISTANCE,
|
||||||
);
|
);
|
||||||
const rightApprox = offsetCubicBezier(
|
const rightApprox = curveOffsetPoints(
|
||||||
|
curve(
|
||||||
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
|
||||||
pointFrom(rightX, rightY),
|
pointFrom(rightX, rightY),
|
||||||
pointFrom(rightX, rightY),
|
pointFrom(rightX, rightY),
|
||||||
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
|
||||||
|
),
|
||||||
-FIXED_BINDING_DISTANCE,
|
-FIXED_BINDING_DISTANCE,
|
||||||
);
|
);
|
||||||
const bottomApprox = offsetCubicBezier(
|
const bottomApprox = curveOffsetPoints(
|
||||||
|
curve(
|
||||||
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
|
||||||
pointFrom(bottomX, bottomY),
|
pointFrom(bottomX, bottomY),
|
||||||
pointFrom(bottomX, bottomY),
|
pointFrom(bottomX, bottomY),
|
||||||
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
|
||||||
|
),
|
||||||
-FIXED_BINDING_DISTANCE,
|
-FIXED_BINDING_DISTANCE,
|
||||||
);
|
);
|
||||||
const leftApprox = offsetCubicBezier(
|
const leftApprox = curveOffsetPoints(
|
||||||
|
curve(
|
||||||
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
|
||||||
pointFrom(leftX, leftY),
|
pointFrom(leftX, leftY),
|
||||||
pointFrom(leftX, leftY),
|
pointFrom(leftX, leftY),
|
||||||
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
|
||||||
|
),
|
||||||
-FIXED_BINDING_DISTANCE,
|
-FIXED_BINDING_DISTANCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -431,66 +414,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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -193,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":
|
||||||
@ -211,15 +205,23 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
case "embeddable":
|
case "embeddable":
|
||||||
case "frame":
|
case "frame":
|
||||||
case "magicframe":
|
case "magicframe":
|
||||||
drawHighlightForRectWithRotation(context, element, padding);
|
drawHighlightForRectWithRotation(context, element, elementsMap, padding);
|
||||||
break;
|
break;
|
||||||
case "diamond":
|
case "diamond":
|
||||||
drawHighlightForDiamondWithRotation(context, padding, element);
|
drawHighlightForDiamondWithRotation(
|
||||||
|
context,
|
||||||
|
padding,
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
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,
|
||||||
@ -231,6 +233,7 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderBindingHighlightForSuggestedPointBinding = (
|
const renderBindingHighlightForSuggestedPointBinding = (
|
||||||
|
|||||||
@ -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",
|
||||||
@ -155,9 +151,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
|||||||
],
|
],
|
||||||
"polygon": false,
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": null,
|
||||||
"type": 2,
|
|
||||||
},
|
|
||||||
"seed": 1278240551,
|
"seed": 1278240551,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
@ -193,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": 1051383431,
|
"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": 1996028265,
|
"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,
|
||||||
@ -95,9 +95,7 @@ exports[`multi point mode in linear elements > line 3`] = `
|
|||||||
],
|
],
|
||||||
"polygon": false,
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": null,
|
||||||
"type": 2,
|
|
||||||
},
|
|
||||||
"seed": 1278240551,
|
"seed": 1278240551,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
@ -107,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
@ -81,9 +81,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
|||||||
],
|
],
|
||||||
"polygon": false,
|
"polygon": false,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": {
|
"roundness": null,
|
||||||
"type": 2,
|
|
||||||
},
|
|
||||||
"seed": 1278240551,
|
"seed": 1278240551,
|
||||||
"startArrowhead": null,
|
"startArrowhead": null,
|
||||||
"startBinding": null,
|
"startBinding": null,
|
||||||
@ -117,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",
|
||||||
@ -151,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",
|
||||||
@ -185,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",
|
||||||
|
|||||||
@ -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",
|
||||||
@ -242,9 +238,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
|||||||
],
|
],
|
||||||
"polygon": false,
|
"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,
|
||||||
@ -292,9 +286,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
|||||||
],
|
],
|
||||||
"polygon": false,
|
"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,
|
||||||
@ -334,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",
|
||||||
@ -378,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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,10 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from "./test-utils";
|
} from "./test-utils";
|
||||||
|
|
||||||
|
import { getTextEditor } from "./queries/dom";
|
||||||
|
|
||||||
|
import { mockHTMLImageElement } from "./helpers/mocks";
|
||||||
|
|
||||||
import type { NormalizedZoomValue } from "../types";
|
import type { NormalizedZoomValue } from "../types";
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -741,6 +744,28 @@ describe("freedraw", () => {
|
|||||||
//image
|
//image
|
||||||
//TODO: currently there is no test for pixel colors at flipped positions.
|
//TODO: currently there is no test for pixel colors at flipped positions.
|
||||||
describe("image", () => {
|
describe("image", () => {
|
||||||
|
const smileyImageDimensions = {
|
||||||
|
width: 56,
|
||||||
|
height: 77,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// it's necessary to specify the height in order to calculate natural dimensions of the image
|
||||||
|
h.state.height = 1000;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mockHTMLImageElement(
|
||||||
|
smileyImageDimensions.width,
|
||||||
|
smileyImageDimensions.height,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
h.state.height = 0;
|
||||||
|
});
|
||||||
|
|
||||||
const createImage = async () => {
|
const createImage = async () => {
|
||||||
const sendPasteEvent = (file?: File) => {
|
const sendPasteEvent = (file?: File) => {
|
||||||
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
|
const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
|
||||||
@ -846,9 +871,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 +883,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);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCommonBounds, getElementPointsCoords } from "@excalidraw/element";
|
import {
|
||||||
|
elementCenterPoint,
|
||||||
|
getCommonBounds,
|
||||||
|
getElementPointsCoords,
|
||||||
|
} from "@excalidraw/element";
|
||||||
import { cropElement } from "@excalidraw/element";
|
import { cropElement } from "@excalidraw/element";
|
||||||
import {
|
import {
|
||||||
getTransformHandles,
|
getTransformHandles,
|
||||||
@ -16,7 +20,7 @@ import {
|
|||||||
isTextElement,
|
isTextElement,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
import { KEYS, arrayToMap, elementCenterPoint } from "@excalidraw/common";
|
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||||
|
|
||||||
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
@ -32,10 +36,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 +151,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 +168,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 +305,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 +319,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 +553,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 +618,7 @@ export class UI {
|
|||||||
|
|
||||||
const mutations = cropElement(
|
const mutations = cropElement(
|
||||||
element,
|
element,
|
||||||
|
h.scene.getNonDeletedElementsMap(),
|
||||||
handle,
|
handle,
|
||||||
naturalWidth,
|
naturalWidth,
|
||||||
naturalHeight,
|
naturalHeight,
|
||||||
|
|||||||
@ -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(() => {
|
||||||
@ -221,6 +243,37 @@ describe("history", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should not modify anything on unrelated appstate change", async () => {
|
||||||
|
const rect = API.createElement({ type: "rectangle" });
|
||||||
|
await render(
|
||||||
|
<Excalidraw
|
||||||
|
handleKeyboardGlobally={true}
|
||||||
|
initialData={{
|
||||||
|
elements: [rect],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
API.updateScene({
|
||||||
|
appState: {
|
||||||
|
viewModeEnabled: true,
|
||||||
|
},
|
||||||
|
captureUpdate: CaptureUpdateAction.NEVER,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(h.state.viewModeEnabled).toBe(true);
|
||||||
|
expect(API.getUndoStack().length).toBe(0);
|
||||||
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
|
expect(h.elements).toEqual([
|
||||||
|
expect.objectContaining({ id: rect.id, isDeleted: false }),
|
||||||
|
]);
|
||||||
|
expect(h.store.snapshot.elements.get(rect.id)).toEqual(
|
||||||
|
expect.objectContaining({ id: rect.id, isDeleted: false }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should not clear the redo stack on standalone appstate change", async () => {
|
it("should not clear the redo stack on standalone appstate change", async () => {
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
@ -559,6 +612,252 @@ 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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
|
||||||
|
expect(
|
||||||
|
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
|
||||||
|
).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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// need to check that delta actually contains initialized image element (with fileId & natural dimensions)
|
||||||
|
expect(
|
||||||
|
Object.values(h.history.undoStack[0].elements.removed)[0].deleted,
|
||||||
|
).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
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
stripIgnoredNodesFromErrorMessage,
|
||||||
|
trimErrorStack,
|
||||||
|
} 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;
|
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
|
||||||
if (waitForEditor) {
|
if (waitForEditor) {
|
||||||
await waitFor(() => expect(query()).not.toBe(null));
|
await waitFor(() => expect(query()).not.toBe(null));
|
||||||
return query();
|
return query();
|
||||||
}
|
}
|
||||||
return query();
|
return query();
|
||||||
|
} catch (err: any) {
|
||||||
|
stripIgnoredNodesFromErrorMessage(err);
|
||||||
|
err.stack = error.stack;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateTextEditor = (
|
export const updateTextEditor = (
|
||||||
|
|||||||
@ -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
@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@ -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"];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,8 +38,6 @@ unmountComponent();
|
|||||||
const tab = " ";
|
const tab = " ";
|
||||||
const mouse = new Pointer("mouse");
|
const mouse = new Pointer("mouse");
|
||||||
|
|
||||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
|
||||||
|
|
||||||
describe("textWysiwyg", () => {
|
describe("textWysiwyg", () => {
|
||||||
describe("start text editing", () => {
|
describe("start text editing", () => {
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
@ -201,7 +199,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
mouse.clickAt(text.x + 50, text.y + 50);
|
mouse.clickAt(text.x + 50, text.y + 50);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, false);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
expect(h.state.editingTextElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
@ -223,7 +221,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, false);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
expect(h.state.editingTextElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
@ -293,7 +291,7 @@ describe("textWysiwyg", () => {
|
|||||||
// edit text
|
// edit text
|
||||||
UI.clickTool("selection");
|
UI.clickTool("selection");
|
||||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
const editor = await getTextEditor(textEditorSelector);
|
const editor = await getTextEditor();
|
||||||
expect(editor).not.toBe(null);
|
expect(editor).not.toBe(null);
|
||||||
expect(h.state.editingTextElement?.id).toBe(text.id);
|
expect(h.state.editingTextElement?.id).toBe(text.id);
|
||||||
expect(h.elements.length).toBe(1);
|
expect(h.elements.length).toBe(1);
|
||||||
@ -326,7 +324,7 @@ describe("textWysiwyg", () => {
|
|||||||
// enter text editing mode
|
// enter text editing mode
|
||||||
UI.clickTool("selection");
|
UI.clickTool("selection");
|
||||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||||
const editor = await getTextEditor(textEditorSelector);
|
const editor = await getTextEditor();
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
// restore after unwrapping
|
// restore after unwrapping
|
||||||
UI.resize(text, "e", [40, 0]);
|
UI.resize(text, "e", [40, 0]);
|
||||||
@ -372,7 +370,7 @@ describe("textWysiwyg", () => {
|
|||||||
textElement = UI.createElement("text");
|
textElement = UI.createElement("text");
|
||||||
|
|
||||||
mouse.clickOn(textElement);
|
mouse.clickOn(textElement);
|
||||||
textarea = await getTextEditor(textEditorSelector, true);
|
textarea = await getTextEditor();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@ -560,7 +558,7 @@ describe("textWysiwyg", () => {
|
|||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
mouse.click(0, 0);
|
mouse.click(0, 0);
|
||||||
|
|
||||||
textarea = await getTextEditor(textEditorSelector, true);
|
textarea = await getTextEditor();
|
||||||
updateTextEditor(
|
updateTextEditor(
|
||||||
textarea,
|
textarea,
|
||||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||||
@ -612,7 +610,7 @@ describe("textWysiwyg", () => {
|
|||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
@ -639,7 +637,7 @@ describe("textWysiwyg", () => {
|
|||||||
]);
|
]);
|
||||||
expect(text.angle).toBe(rectangle.angle);
|
expect(text.angle).toBe(rectangle.angle);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
@ -665,7 +663,7 @@ describe("textWysiwyg", () => {
|
|||||||
API.setSelectedElements([diamond]);
|
API.setSelectedElements([diamond]);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
const value = new Array(1000).fill("1").join("\n");
|
const value = new Array(1000).fill("1").join("\n");
|
||||||
|
|
||||||
@ -682,7 +680,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(diamond.height).toBe(70);
|
expect(diamond.height).toBe(70);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should bind text to container when double clicked on center of transparent container", async () => {
|
it("should bind text to container when double clicked inside of the transparent container", async () => {
|
||||||
const rectangle = API.createElement({
|
const rectangle = API.createElement({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
x: 10,
|
x: 10,
|
||||||
@ -699,7 +697,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(null);
|
expect(text.containerId).toBe(null);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor();
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
mouse.doubleClickAt(
|
mouse.doubleClickAt(
|
||||||
@ -713,7 +711,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
|
|
||||||
mouse.down();
|
mouse.down();
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
@ -734,7 +732,7 @@ describe("textWysiwyg", () => {
|
|||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
@ -767,7 +765,7 @@ describe("textWysiwyg", () => {
|
|||||||
{ id: text.id, type: "text" },
|
{ id: text.id, type: "text" },
|
||||||
]);
|
]);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
@ -791,7 +789,7 @@ describe("textWysiwyg", () => {
|
|||||||
freedraw.y + freedraw.height / 2,
|
freedraw.y + freedraw.height / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@ -825,7 +823,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(null);
|
expect(text.containerId).toBe(null);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
@ -839,7 +837,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
mouse.clickAt(20, 30);
|
mouse.clickAt(20, 30);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(
|
updateTextEditor(
|
||||||
editor,
|
editor,
|
||||||
@ -882,13 +880,13 @@ describe("textWysiwyg", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
expect(await getTextEditor(textEditorSelector, false)).toBe(null);
|
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
|
||||||
|
|
||||||
expect(h.state.editingTextElement).toBe(null);
|
expect(h.state.editingTextElement).toBe(null);
|
||||||
|
|
||||||
@ -922,7 +920,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
Keyboard.keyDown(KEYS.ENTER);
|
Keyboard.keyDown(KEYS.ENTER);
|
||||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
@ -942,7 +940,7 @@ describe("textWysiwyg", () => {
|
|||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
|
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
@ -969,7 +967,7 @@ describe("textWysiwyg", () => {
|
|||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
@ -1004,7 +1002,7 @@ describe("textWysiwyg", () => {
|
|||||||
// Bind first text
|
// Bind first text
|
||||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
@ -1024,7 +1022,7 @@ describe("textWysiwyg", () => {
|
|||||||
it("should respect text alignment when resizing", async () => {
|
it("should respect text alignment when resizing", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@ -1040,7 +1038,7 @@ describe("textWysiwyg", () => {
|
|||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
|
|
||||||
editor.select();
|
editor.select();
|
||||||
|
|
||||||
@ -1059,7 +1057,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
|
|
||||||
editor.select();
|
editor.select();
|
||||||
|
|
||||||
@ -1095,7 +1093,7 @@ describe("textWysiwyg", () => {
|
|||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
mouse.down();
|
mouse.down();
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
@ -1109,7 +1107,7 @@ describe("textWysiwyg", () => {
|
|||||||
it("should scale font size correctly when resizing using shift", async () => {
|
it("should scale font size correctly when resizing using shift", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||||
@ -1128,7 +1126,7 @@ describe("textWysiwyg", () => {
|
|||||||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(h.elements.length).toBe(2);
|
expect(h.elements.length).toBe(2);
|
||||||
@ -1159,7 +1157,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
it("undo should work", async () => {
|
it("undo should work", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([
|
expect(rectangle.boundElements).toStrictEqual([
|
||||||
@ -1195,7 +1193,7 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
it("should not allow bound text with only whitespaces", async () => {
|
it("should not allow bound text with only whitespaces", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, " ");
|
updateTextEditor(editor, " ");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
@ -1249,7 +1247,7 @@ describe("textWysiwyg", () => {
|
|||||||
it("should reset the container height cache when resizing", async () => {
|
it("should reset the container height cache when resizing", async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@ -1260,7 +1258,7 @@ describe("textWysiwyg", () => {
|
|||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
|
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
|
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.height).toBeCloseTo(155, 8);
|
expect(rectangle.height).toBeCloseTo(155, 8);
|
||||||
@ -1275,7 +1273,7 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@ -1300,7 +1298,7 @@ describe("textWysiwyg", () => {
|
|||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(
|
expect(
|
||||||
@ -1332,12 +1330,12 @@ describe("textWysiwyg", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello");
|
updateTextEditor(editor, "Hello");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
mouse.select(rectangle);
|
mouse.select(rectangle);
|
||||||
Keyboard.keyPress(KEYS.ENTER);
|
Keyboard.keyPress(KEYS.ENTER);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
editor.select();
|
editor.select();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1448,7 +1446,7 @@ describe("textWysiwyg", () => {
|
|||||||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||||
UI.clickTool("text");
|
UI.clickTool("text");
|
||||||
mouse.clickAt(20, 30);
|
mouse.clickAt(20, 30);
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(
|
updateTextEditor(
|
||||||
editor,
|
editor,
|
||||||
@ -1500,9 +1498,7 @@ describe("textWysiwyg", () => {
|
|||||||
locked: false,
|
locked: false,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
roughness: 1,
|
roughness: 1,
|
||||||
roundness: {
|
roundness: null,
|
||||||
type: 3,
|
|
||||||
},
|
|
||||||
strokeColor: "#1e1e1e",
|
strokeColor: "#1e1e1e",
|
||||||
strokeStyle: "solid",
|
strokeStyle: "solid",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
@ -1534,7 +1530,7 @@ describe("textWysiwyg", () => {
|
|||||||
// Bind first text
|
// Bind first text
|
||||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.containerId).toBe(rectangle.id);
|
expect(text.containerId).toBe(rectangle.id);
|
||||||
let editor = await getTextEditor(textEditorSelector, true);
|
let editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Hello!");
|
updateTextEditor(editor, "Hello!");
|
||||||
expect(
|
expect(
|
||||||
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
||||||
@ -1557,7 +1553,7 @@ describe("textWysiwyg", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
);
|
);
|
||||||
editor = await getTextEditor(textEditorSelector, true);
|
editor = await getTextEditor();
|
||||||
updateTextEditor(editor, "Excalidraw");
|
updateTextEditor(editor, "Excalidraw");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
|
|
||||||
@ -1632,7 +1628,7 @@ describe("textWysiwyg", () => {
|
|||||||
arrow.y + arrow.height / 2,
|
arrow.y + arrow.height / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
@ -1657,7 +1653,7 @@ describe("textWysiwyg", () => {
|
|||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = await getTextEditor(textEditorSelector, true);
|
const editor = await getTextEditor();
|
||||||
|
|
||||||
updateTextEditor(editor, "Hello World!");
|
updateTextEditor(editor, "Hello World!");
|
||||||
|
|
||||||
|
|||||||
57
packages/math/src/constants.ts
Normal file
57
packages/math/src/constants.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
export const PRECISION = 10e-5;
|
||||||
|
|
||||||
|
// Legendre-Gauss abscissae (x values) and weights for n=24
|
||||||
|
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
|
||||||
|
export const LegendreGaussN24TValues = [
|
||||||
|
-0.0640568928626056260850430826247450385909,
|
||||||
|
0.0640568928626056260850430826247450385909,
|
||||||
|
-0.1911188674736163091586398207570696318404,
|
||||||
|
0.1911188674736163091586398207570696318404,
|
||||||
|
-0.3150426796961633743867932913198102407864,
|
||||||
|
0.3150426796961633743867932913198102407864,
|
||||||
|
-0.4337935076260451384870842319133497124524,
|
||||||
|
0.4337935076260451384870842319133497124524,
|
||||||
|
-0.5454214713888395356583756172183723700107,
|
||||||
|
0.5454214713888395356583756172183723700107,
|
||||||
|
-0.6480936519369755692524957869107476266696,
|
||||||
|
0.6480936519369755692524957869107476266696,
|
||||||
|
-0.7401241915785543642438281030999784255232,
|
||||||
|
0.7401241915785543642438281030999784255232,
|
||||||
|
-0.8200019859739029219539498726697452080761,
|
||||||
|
0.8200019859739029219539498726697452080761,
|
||||||
|
-0.8864155270044010342131543419821967550873,
|
||||||
|
0.8864155270044010342131543419821967550873,
|
||||||
|
-0.9382745520027327585236490017087214496548,
|
||||||
|
0.9382745520027327585236490017087214496548,
|
||||||
|
-0.9747285559713094981983919930081690617411,
|
||||||
|
0.9747285559713094981983919930081690617411,
|
||||||
|
-0.9951872199970213601799974097007368118745,
|
||||||
|
0.9951872199970213601799974097007368118745,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LegendreGaussN24CValues = [
|
||||||
|
0.1279381953467521569740561652246953718517,
|
||||||
|
0.1279381953467521569740561652246953718517,
|
||||||
|
0.1258374563468282961213753825111836887264,
|
||||||
|
0.1258374563468282961213753825111836887264,
|
||||||
|
0.121670472927803391204463153476262425607,
|
||||||
|
0.121670472927803391204463153476262425607,
|
||||||
|
0.1155056680537256013533444839067835598622,
|
||||||
|
0.1155056680537256013533444839067835598622,
|
||||||
|
0.1074442701159656347825773424466062227946,
|
||||||
|
0.1074442701159656347825773424466062227946,
|
||||||
|
0.0976186521041138882698806644642471544279,
|
||||||
|
0.0976186521041138882698806644642471544279,
|
||||||
|
0.086190161531953275917185202983742667185,
|
||||||
|
0.086190161531953275917185202983742667185,
|
||||||
|
0.0733464814110803057340336152531165181193,
|
||||||
|
0.0733464814110803057340336152531165181193,
|
||||||
|
0.0592985849154367807463677585001085845412,
|
||||||
|
0.0592985849154367807463677585001085845412,
|
||||||
|
0.0442774388174198061686027482113382288593,
|
||||||
|
0.0442774388174198061686027482113382288593,
|
||||||
|
0.0285313886289336631813078159518782864491,
|
||||||
|
0.0285313886289336631813078159518782864491,
|
||||||
|
0.0123412297999871995468056670700372915759,
|
||||||
|
0.0123412297999871995468056670700372915759,
|
||||||
|
];
|
||||||
@ -1,8 +1,6 @@
|
|||||||
import type { Bounds } from "@excalidraw/element";
|
import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
|
||||||
|
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
|
||||||
import { isPoint, pointDistance, pointFrom } from "./point";
|
import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants";
|
||||||
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
|
|
||||||
import { vector } from "./vector";
|
|
||||||
|
|
||||||
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
||||||
|
|
||||||
@ -104,20 +102,6 @@ export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
|||||||
export function curveIntersectLineSegment<
|
export function curveIntersectLineSegment<
|
||||||
Point extends GlobalPoint | LocalPoint,
|
Point extends GlobalPoint | LocalPoint,
|
||||||
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
|
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
|
||||||
// Optimize by doing a cheap bounding box check first
|
|
||||||
const bounds = curveBounds(c);
|
|
||||||
if (
|
|
||||||
rectangleIntersectLineSegment(
|
|
||||||
rectangle(
|
|
||||||
pointFrom(bounds[0], bounds[1]),
|
|
||||||
pointFrom(bounds[2], bounds[3]),
|
|
||||||
),
|
|
||||||
l,
|
|
||||||
).length === 0
|
|
||||||
) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = (s: number) =>
|
const line = (s: number) =>
|
||||||
pointFrom<Point>(
|
pointFrom<Point>(
|
||||||
l[0][0] + s * (l[1][0] - l[0][0]),
|
l[0][0] + s * (l[1][0] - l[0][0]),
|
||||||
@ -295,11 +279,227 @@ export function curveTangent<Point extends GlobalPoint | LocalPoint>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function curveBounds<Point extends GlobalPoint | LocalPoint>(
|
export function curveCatmullRomQuadraticApproxPoints(
|
||||||
c: Curve<Point>,
|
points: GlobalPoint[],
|
||||||
): Bounds {
|
tension = 0.5,
|
||||||
const [P0, P1, P2, P3] = c;
|
) {
|
||||||
const x = [P0[0], P1[0], P2[0], P3[0]];
|
if (points.length < 2) {
|
||||||
const y = [P0[1], P1[1], P2[1], P3[1]];
|
return;
|
||||||
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
|
}
|
||||||
|
|
||||||
|
const pointSets: [GlobalPoint, GlobalPoint][] = [];
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
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];
|
||||||
|
const cpX = p1[0] + ((p2[0] - p0[0]) * tension) / 2;
|
||||||
|
const cpY = p1[1] + ((p2[1] - p0[1]) * tension) / 2;
|
||||||
|
|
||||||
|
pointSets.push([
|
||||||
|
pointFrom<GlobalPoint>(cpX, cpY),
|
||||||
|
pointFrom<GlobalPoint>(p2[0], p2[1]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pointSets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function curveCatmullRomCubicApproxPoints<
|
||||||
|
Point extends GlobalPoint | LocalPoint,
|
||||||
|
>(points: Point[], tension = 0.5) {
|
||||||
|
if (points.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointSets: Curve<Point>[] = [];
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
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];
|
||||||
|
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
|
||||||
|
const tangent1 = [(p2[0] - p0[0]) * tension, (p2[1] - p0[1]) * tension];
|
||||||
|
const tangent2 = [(p3[0] - p1[0]) * tension, (p3[1] - p1[1]) * tension];
|
||||||
|
const cp1x = p1[0] + tangent1[0] / 3;
|
||||||
|
const cp1y = p1[1] + tangent1[1] / 3;
|
||||||
|
const cp2x = p2[0] - tangent2[0] / 3;
|
||||||
|
const cp2y = p2[1] - tangent2[1] / 3;
|
||||||
|
|
||||||
|
pointSets.push(
|
||||||
|
curve(
|
||||||
|
pointFrom(p1[0], p1[1]),
|
||||||
|
pointFrom(cp1x, cp1y),
|
||||||
|
pointFrom(cp2x, cp2y),
|
||||||
|
pointFrom(p2[0], p2[1]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pointSets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function curveOffsetPoints(
|
||||||
|
[p0, p1, p2, p3]: Curve<GlobalPoint>,
|
||||||
|
offset: number,
|
||||||
|
steps = 50,
|
||||||
|
) {
|
||||||
|
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, offset), point));
|
||||||
|
}
|
||||||
|
|
||||||
|
return offsetPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function offsetPointsForQuadraticBezier(
|
||||||
|
p0: GlobalPoint,
|
||||||
|
p1: GlobalPoint,
|
||||||
|
p2: GlobalPoint,
|
||||||
|
offsetDist: number,
|
||||||
|
steps = 50,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation based on Legendre-Gauss quadrature for more accurate arc
|
||||||
|
* length calculation.
|
||||||
|
*
|
||||||
|
* Reference: https://pomax.github.io/bezierinfo/#arclength
|
||||||
|
*
|
||||||
|
* @param c The curve to calculate the length of
|
||||||
|
* @returns The approximated length of the curve
|
||||||
|
*/
|
||||||
|
export function curveLength<P extends GlobalPoint | LocalPoint>(
|
||||||
|
c: Curve<P>,
|
||||||
|
): number {
|
||||||
|
const z2 = 0.5;
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const t = z2 * LegendreGaussN24TValues[i] + z2;
|
||||||
|
const derivativeVector = curveTangent(c, t);
|
||||||
|
const magnitude = Math.sqrt(
|
||||||
|
derivativeVector[0] * derivativeVector[0] +
|
||||||
|
derivativeVector[1] * derivativeVector[1],
|
||||||
|
);
|
||||||
|
sum += LegendreGaussN24CValues[i] * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return z2 * sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the curve length from t=0 to t=parameter using the same
|
||||||
|
* Legendre-Gauss quadrature method used in curveLength
|
||||||
|
*
|
||||||
|
* @param c The curve to calculate the partial length for
|
||||||
|
* @param t The parameter value (0 to 1) to calculate length up to
|
||||||
|
* @returns The length of the curve from beginning to parameter t
|
||||||
|
*/
|
||||||
|
export function curveLengthAtParameter<P extends GlobalPoint | LocalPoint>(
|
||||||
|
c: Curve<P>,
|
||||||
|
t: number,
|
||||||
|
): number {
|
||||||
|
if (t <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (t >= 1) {
|
||||||
|
return curveLength(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale and shift the integration interval from [0,t] to [-1,1]
|
||||||
|
// which is what the Legendre-Gauss quadrature expects
|
||||||
|
const z1 = t / 2;
|
||||||
|
const z2 = t / 2;
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const parameter = z1 * LegendreGaussN24TValues[i] + z2;
|
||||||
|
const derivativeVector = curveTangent(c, parameter);
|
||||||
|
const magnitude = Math.sqrt(
|
||||||
|
derivativeVector[0] * derivativeVector[0] +
|
||||||
|
derivativeVector[1] * derivativeVector[1],
|
||||||
|
);
|
||||||
|
sum += LegendreGaussN24CValues[i] * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return z1 * sum; // Scale the result back to the original interval
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the point at a specific percentage of a curve's total length
|
||||||
|
* using binary search for improved efficiency and accuracy.
|
||||||
|
*
|
||||||
|
* @param c The curve to calculate point on
|
||||||
|
* @param percent A value between 0 and 1 representing the percentage of the curve's length
|
||||||
|
* @returns The point at the specified percentage of curve length
|
||||||
|
*/
|
||||||
|
export function curvePointAtLength<P extends GlobalPoint | LocalPoint>(
|
||||||
|
c: Curve<P>,
|
||||||
|
percent: number,
|
||||||
|
): P {
|
||||||
|
if (percent <= 0) {
|
||||||
|
return bezierEquation(c, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (percent >= 1) {
|
||||||
|
return bezierEquation(c, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalLength = curveLength(c);
|
||||||
|
const targetLength = totalLength * percent;
|
||||||
|
|
||||||
|
// Binary search to find parameter t where length at t equals target length
|
||||||
|
let tMin = 0;
|
||||||
|
let tMax = 1;
|
||||||
|
let t = percent; // Start with a reasonable guess (t = percent)
|
||||||
|
let currentLength = 0;
|
||||||
|
|
||||||
|
// Tolerance for length comparison and iteration limit to avoid infinite loops
|
||||||
|
const tolerance = totalLength * 0.0001;
|
||||||
|
const maxIterations = 20;
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||||
|
currentLength = curveLengthAtParameter(c, t);
|
||||||
|
const error = Math.abs(currentLength - targetLength);
|
||||||
|
|
||||||
|
if (error < tolerance) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLength < targetLength) {
|
||||||
|
tMin = t;
|
||||||
|
} else {
|
||||||
|
tMax = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
t = (tMin + tMax) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bezierEquation(c, t);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export * from "./angle";
|
export * from "./angle";
|
||||||
export * from "./curve";
|
export * from "./curve";
|
||||||
|
export * from "./ellipse";
|
||||||
export * from "./line";
|
export * from "./line";
|
||||||
export * from "./point";
|
export * from "./point";
|
||||||
export * from "./polygon";
|
export * from "./polygon";
|
||||||
|
|||||||
@ -10,6 +10,12 @@ export function rectangle<P extends GlobalPoint | LocalPoint>(
|
|||||||
return [topLeft, bottomRight] as Rectangle<P>;
|
return [topLeft, bottomRight] as Rectangle<P>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rectangleFromNumberSequence<
|
||||||
|
Point extends LocalPoint | GlobalPoint,
|
||||||
|
>(minX: number, minY: number, maxX: number, maxY: number) {
|
||||||
|
return rectangle(pointFrom<Point>(minX, minY), pointFrom<Point>(maxX, maxY));
|
||||||
|
}
|
||||||
|
|
||||||
export function rectangleIntersectLineSegment<
|
export function rectangleIntersectLineSegment<
|
||||||
Point extends LocalPoint | GlobalPoint,
|
Point extends LocalPoint | GlobalPoint,
|
||||||
>(r: Rectangle<Point>, l: LineSegment<Point>): Point[] {
|
>(r: Rectangle<Point>, l: LineSegment<Point>): Point[] {
|
||||||
@ -22,3 +28,12 @@ export function rectangleIntersectLineSegment<
|
|||||||
.map((s) => lineSegmentIntersectionPoints(l, s))
|
.map((s) => lineSegmentIntersectionPoints(l, s))
|
||||||
.filter((i): i is Point => !!i);
|
.filter((i): i is Point => !!i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rectangleIntersectRectangle<
|
||||||
|
Point extends LocalPoint | GlobalPoint,
|
||||||
|
>(rectangle1: Rectangle<Point>, rectangle2: Rectangle<Point>): boolean {
|
||||||
|
const [[minX1, minY1], [maxX1, maxY1]] = rectangle1;
|
||||||
|
const [[minX2, minY2], [maxX2, maxY2]] = rectangle2;
|
||||||
|
|
||||||
|
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||||
|
}
|
||||||
|
|||||||
@ -21,13 +21,23 @@ export function vector(
|
|||||||
*
|
*
|
||||||
* @param p The point to turn into a vector
|
* @param p The point to turn into a vector
|
||||||
* @param origin The origin point in a given coordiante system
|
* @param origin The origin point in a given coordiante system
|
||||||
* @returns The created vector from the point and the origin
|
* @param threshold The threshold to consider the vector as 'undefined'
|
||||||
|
* @param defaultValue The default value to return if the vector is 'undefined'
|
||||||
|
* @returns The created vector from the point and the origin or default
|
||||||
*/
|
*/
|
||||||
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
|
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
|
||||||
p: Point,
|
p: Point,
|
||||||
origin: Point = [0, 0] as Point,
|
origin: Point = [0, 0] as Point,
|
||||||
|
threshold?: number,
|
||||||
|
defaultValue: Vector = [0, 1] as Vector,
|
||||||
): Vector {
|
): Vector {
|
||||||
return vector(p[0] - origin[0], p[1] - origin[1]);
|
const vec = vector(p[0] - origin[0], p[1] - origin[1]);
|
||||||
|
|
||||||
|
if (threshold && vectorMagnitudeSq(vec) < threshold * threshold) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
import {
|
|
||||||
lineSegment,
|
|
||||||
pointFrom,
|
|
||||||
polygonIncludesPoint,
|
|
||||||
pointOnLineSegment,
|
|
||||||
pointOnPolygon,
|
|
||||||
polygonFromPoints,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
type Polygon,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { Curve } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import { pointInEllipse, pointOnEllipse } from "./shape";
|
|
||||||
|
|
||||||
import type { Polycurve, Polyline, GeometricShape } from "./shape";
|
|
||||||
|
|
||||||
// check if the given point is considered on the given shape's border
|
|
||||||
export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
point: Point,
|
|
||||||
shape: GeometricShape<Point>,
|
|
||||||
tolerance = 0,
|
|
||||||
) => {
|
|
||||||
// get the distance from the given point to the given element
|
|
||||||
// check if the distance is within the given epsilon range
|
|
||||||
switch (shape.type) {
|
|
||||||
case "polygon":
|
|
||||||
return pointOnPolygon(point, shape.data, tolerance);
|
|
||||||
case "ellipse":
|
|
||||||
return pointOnEllipse(point, shape.data, tolerance);
|
|
||||||
case "line":
|
|
||||||
return pointOnLineSegment(point, shape.data, tolerance);
|
|
||||||
case "polyline":
|
|
||||||
return pointOnPolyline(point, shape.data, tolerance);
|
|
||||||
case "curve":
|
|
||||||
return pointOnCurve(point, shape.data, tolerance);
|
|
||||||
case "polycurve":
|
|
||||||
return pointOnPolycurve(point, shape.data, tolerance);
|
|
||||||
default:
|
|
||||||
throw Error(`shape ${shape} is not implemented`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// check if the given point is considered inside the element's border
|
|
||||||
export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
point: Point,
|
|
||||||
shape: GeometricShape<Point>,
|
|
||||||
) => {
|
|
||||||
switch (shape.type) {
|
|
||||||
case "polygon":
|
|
||||||
return polygonIncludesPoint(point, shape.data);
|
|
||||||
case "line":
|
|
||||||
return false;
|
|
||||||
case "curve":
|
|
||||||
return false;
|
|
||||||
case "ellipse":
|
|
||||||
return pointInEllipse(point, shape.data);
|
|
||||||
case "polyline": {
|
|
||||||
const polygon = polygonFromPoints(shape.data.flat());
|
|
||||||
return polygonIncludesPoint(point, polygon);
|
|
||||||
}
|
|
||||||
case "polycurve": {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw Error(`shape ${shape} is not implemented`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// check if the given element is in the given bounds
|
|
||||||
export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
|
|
||||||
point: Point,
|
|
||||||
bounds: Polygon<Point>,
|
|
||||||
) => {
|
|
||||||
return polygonIncludesPoint(point, bounds);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
|
|
||||||
point: Point,
|
|
||||||
polycurve: Polycurve<Point>,
|
|
||||||
tolerance: number,
|
|
||||||
) => {
|
|
||||||
return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
|
|
||||||
};
|
|
||||||
|
|
||||||
const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
|
|
||||||
curve: Curve<Point>,
|
|
||||||
) => {
|
|
||||||
const [p0, p1, p2, p3] = curve;
|
|
||||||
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
|
|
||||||
return (t: number, idx: number) =>
|
|
||||||
Math.pow(1 - t, 3) * p3[idx] +
|
|
||||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
|
||||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
|
||||||
p0[idx] * Math.pow(t, 3);
|
|
||||||
};
|
|
||||||
|
|
||||||
const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
|
|
||||||
curve: Curve<Point>,
|
|
||||||
segments = 10,
|
|
||||||
): Polyline<Point> => {
|
|
||||||
const equation = cubicBezierEquation(curve);
|
|
||||||
let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
|
|
||||||
const lineSegments: Polyline<Point> = [];
|
|
||||||
let t = 0;
|
|
||||||
const increment = 1 / segments;
|
|
||||||
|
|
||||||
for (let i = 0; i < segments; i++) {
|
|
||||||
t += increment;
|
|
||||||
if (t <= 1) {
|
|
||||||
const nextPoint: Point = pointFrom(equation(t, 0), equation(t, 1));
|
|
||||||
lineSegments.push(lineSegment(startingPoint, nextPoint));
|
|
||||||
startingPoint = nextPoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lineSegments;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
|
|
||||||
point: Point,
|
|
||||||
curve: Curve<Point>,
|
|
||||||
threshold: number,
|
|
||||||
) => {
|
|
||||||
return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
|
|
||||||
point: Point,
|
|
||||||
polyline: Polyline<Point>,
|
|
||||||
threshold = 10e-5,
|
|
||||||
) => {
|
|
||||||
return polyline.some((line) => pointOnLineSegment(point, line, threshold));
|
|
||||||
};
|
|
||||||
@ -24,7 +24,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||||||
"currentItemFontSize": 20,
|
"currentItemFontSize": 20,
|
||||||
"currentItemOpacity": 100,
|
"currentItemOpacity": 100,
|
||||||
"currentItemRoughness": 1,
|
"currentItemRoughness": 1,
|
||||||
"currentItemRoundness": "round",
|
"currentItemRoundness": "sharp",
|
||||||
"currentItemStartArrowhead": null,
|
"currentItemStartArrowhead": null,
|
||||||
"currentItemStrokeColor": "#1e1e1e",
|
"currentItemStrokeColor": "#1e1e1e",
|
||||||
"currentItemStrokeStyle": "solid",
|
"currentItemStrokeStyle": "solid",
|
||||||
@ -81,7 +81,6 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||||||
},
|
},
|
||||||
"penDetected": false,
|
"penDetected": false,
|
||||||
"penMode": false,
|
"penMode": false,
|
||||||
"pendingImageElementId": null,
|
|
||||||
"previousSelectedElementIds": {},
|
"previousSelectedElementIds": {},
|
||||||
"resizingElement": null,
|
"resizingElement": null,
|
||||||
"scrollX": 0,
|
"scrollX": 0,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user