Compare commits

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

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

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

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

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

* clean up test

* simplify check

* add tests

* prevent text editing on dblclick when inside arrow editor

---------

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

This is with reference to [this](https://github.com/excalidraw/excalidraw/issues/9562)
2025-05-29 22:03:20 +02:00
7cad3645a0 perf: Simplify normalizeRadians function (#9572)
Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-05-28 15:58:42 +02:00
Márk TolmácsandGitHub 5921ebc416 fix: Regression in long press context menu closure (#9588) 2025-05-28 13:38:47 +02:00
Márk TolmácsandGitHub 864353be5f feat: Try to preserve line angle on SHIFT+drag (#9570) 2025-05-27 12:39:45 +02:00
cheapsterandGitHub db2911c6c4 fix: ghost point issue when moving a shape after dragging a point in the line editor (#9530)
fix: ghost point issue when moving a shape after
dragging a point in the line editor
2025-05-26 21:34:41 +02:00
94 changed files with 6452 additions and 4769 deletions
@@ -363,13 +363,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
```ts ```ts
( (
tool: ( tool: (
| ( | { type: ToolType }
| { type: Exclude<ToolType, "image"> }
| {
type: Extract<ToolType, "image">;
insertOnCanvasDirectly?: boolean;
}
)
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }, ) & { locked?: boolean },
) => {}; ) => {};
@@ -377,7 +371,7 @@ This API has the below signature. It sets the `tool` passed in param as the acti
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool. When setting `image` as active tool, the insertion onto canvas when using image tool is disabled by default, so you can enable it by setting `insertOnCanvasDirectly` to `true` | | `type` | [ToolType](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L91) | `selection` | The tool type which should be set as active tool |
| `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface | | `locked` | `boolean` | `false` | Indicates whether the the active tool should be locked. It behaves the same way when using the `lock` tool in the editor interface |
## setCursor ## setCursor
@@ -38,6 +38,8 @@ If you want to only import `Excalidraw` component you can do :point_down:
```jsx showLineNumbers ```jsx showLineNumbers
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import "@excalidraw/excalidraw/index.css";
const Excalidraw = dynamic( const Excalidraw = dynamic(
async () => (await import("@excalidraw/excalidraw")).Excalidraw, async () => (await import("@excalidraw/excalidraw")).Excalidraw,
{ {
+271
View File
@@ -4,6 +4,7 @@ import {
TTDDialogTrigger, TTDDialogTrigger,
CaptureUpdateAction, CaptureUpdateAction,
reconcileElements, reconcileElements,
convertToExcalidrawElements,
} from "@excalidraw/excalidraw"; } from "@excalidraw/excalidraw";
import { trackEvent } from "@excalidraw/excalidraw/analytics"; import { trackEvent } from "@excalidraw/excalidraw/analytics";
import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
@@ -471,6 +472,276 @@ const ExcalidrawWrapper = () => {
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => { initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, /* isInitialLoad */ true); loadImages(data, /* isInitialLoad */ true);
if (data.scene) {
data.scene.elements = data.scene.elements?.length
? data.scene.elements
: convertToExcalidrawElements([
{
id: "MyWwgGwbjyZoxKSa2pfBI",
type: "rectangle",
x: 43.5,
y: 52.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ff8787",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 246249428,
version: 427,
versionNonce: 2079096276,
boundElements: [],
link: null,
locked: false,
},
{
id: "ABor4pClpJJs6adQb4iqs",
type: "rectangle",
x: 357.5,
y: 481.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#b2f2bb",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1800887636,
version: 445,
versionNonce: 650486612,
boundElements: [],
link: null,
locked: false,
},
{
id: "-FGknlZxdFGYZNoUr2lG5",
type: "rectangle",
x: 1280.5,
y: 47.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#69db7c",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 883462868,
version: 532,
versionNonce: 912136404,
boundElements: [],
link: null,
locked: false,
},
{
id: "XQ3bRtQnS_ydtq13c_p99",
type: "rectangle",
x: 863.5,
y: 334.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#a5d8ff",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 2033653844,
version: 459,
versionNonce: 1984683604,
boundElements: [],
link: null,
locked: false,
},
{
id: "GmC3zgvVq3xUd2Qn0cNX3",
type: "rectangle",
x: 897.5,
y: 697.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ffec99",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 81325524,
version: 488,
versionNonce: 1332016084,
boundElements: [],
link: null,
locked: false,
},
{
id: "8nvmvwbmFmNAcLAmwZryR",
type: "rectangle",
x: 456.5,
y: 229.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#eebefa",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 950276948,
version: 649,
versionNonce: 532633940,
boundElements: [],
link: null,
locked: false,
},
{
id: "7dyXEvhPDLA0-e3XCaM2H",
type: "rectangle",
x: 1446.5,
y: 609.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#d0bfff",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1659799764,
version: 570,
versionNonce: 313593556,
boundElements: [],
link: null,
locked: false,
},
{
id: "gJubXeAjkEAr6yywtiZ0T",
type: "rectangle",
x: 1182.5,
y: 800.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#96f2d7",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 391007828,
version: 563,
versionNonce: 2026902612,
boundElements: [],
link: null,
locked: false,
},
{
id: "pSB2Izj9osR_XxVCzyVnD",
type: "rectangle",
x: 1477.5,
y: 295.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#9775fa",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 110715860,
version: 590,
versionNonce: 535980500,
boundElements: [],
link: null,
locked: false,
},
{
id: "yc7IKdtbnjLRaxJI5_q7m",
type: "rectangle",
x: 696.5,
y: 21.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#4dabf7",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 1457899860,
version: 587,
versionNonce: 724438868,
boundElements: [],
link: null,
locked: false,
},
{
id: "7KjY1vcMQm_i_i05wowTC",
type: "rectangle",
x: 447.5,
y: 771.5,
width: 357,
height: 280,
strokeColor: "transparent",
backgroundColor: "#ffc9c9",
fillStyle: "solid",
strokeWidth: 2,
strokeStyle: "solid",
roughness: 1,
opacity: 100,
groupIds: [],
frameId: null,
roundness: { type: 3 },
seed: 2072608468,
version: 610,
versionNonce: 1792607444,
boundElements: [],
link: null,
locked: false,
},
]);
}
initialStatePromiseRef.current.promise.resolve(data.scene); initialStatePromiseRef.current.promise.resolve(data.scene);
}); });
+1 -74
View File
@@ -205,6 +205,7 @@ describe("collaboration", () => {
// with explicit undo (as addition) we expect our item to be restored from the snapshot! // with explicit undo (as addition) we expect our item to be restored from the snapshot!
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1); expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([ expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props), expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }), expect.objectContaining({ ...rect2Props, isDeleted: false }),
@@ -247,79 +248,5 @@ describe("collaboration", () => {
expect.objectContaining({ ...rect2Props, isDeleted: true }), expect.objectContaining({ ...rect2Props, isDeleted: true }),
]); ]);
}); });
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
API.updateScene({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
API.updateScene({
elements: syncInvalidIndices([rect1]),
captureUpdate: CaptureUpdateAction.NEVER,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
});
}); });
}); });
+32 -2
View File
@@ -147,19 +147,49 @@ export const FONT_FAMILY = {
Assistant: 10, Assistant: 10,
}; };
// Segoe UI Emoji fails to properly fallback for some glyphs: ∞, ∫, ≠
// so we need to have generic font fallback before it
export const SANS_SERIF_GENERIC_FONT = "sans-serif";
export const MONOSPACE_GENERIC_FONT = "monospace";
export const FONT_FAMILY_GENERIC_FALLBACKS = {
[SANS_SERIF_GENERIC_FONT]: 998,
[MONOSPACE_GENERIC_FONT]: 999,
};
export const FONT_FAMILY_FALLBACKS = { export const FONT_FAMILY_FALLBACKS = {
[CJK_HAND_DRAWN_FALLBACK_FONT]: 100, [CJK_HAND_DRAWN_FALLBACK_FONT]: 100,
...FONT_FAMILY_GENERIC_FALLBACKS,
[WINDOWS_EMOJI_FALLBACK_FONT]: 1000, [WINDOWS_EMOJI_FALLBACK_FONT]: 1000,
}; };
export function getGenericFontFamilyFallback(
fontFamily: number,
): keyof typeof FONT_FAMILY_GENERIC_FALLBACKS {
switch (fontFamily) {
case FONT_FAMILY.Cascadia:
case FONT_FAMILY["Comic Shanns"]:
return MONOSPACE_GENERIC_FONT;
default:
return SANS_SERIF_GENERIC_FONT;
}
}
export const getFontFamilyFallbacks = ( export const getFontFamilyFallbacks = (
fontFamily: number, fontFamily: number,
): Array<keyof typeof FONT_FAMILY_FALLBACKS> => { ): Array<keyof typeof FONT_FAMILY_FALLBACKS> => {
const genericFallbackFont = getGenericFontFamilyFallback(fontFamily);
switch (fontFamily) { switch (fontFamily) {
case FONT_FAMILY.Excalifont: case FONT_FAMILY.Excalifont:
return [CJK_HAND_DRAWN_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT]; return [
CJK_HAND_DRAWN_FALLBACK_FONT,
genericFallbackFont,
WINDOWS_EMOJI_FALLBACK_FONT,
];
default: default:
return [WINDOWS_EMOJI_FALLBACK_FONT]; return [genericFallbackFont, WINDOWS_EMOJI_FALLBACK_FONT];
} }
}; };
+7 -9
View File
@@ -1,10 +1,12 @@
import { average, pointFrom, type GlobalPoint } from "@excalidraw/math"; import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
import type { import type {
ExcalidrawBindableElement, ExcalidrawBindableElement,
FontFamilyValues, FontFamilyValues,
FontString, FontString,
ExcalidrawElement, ExcalidrawElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -101,7 +103,6 @@ export const getFontFamilyString = ({
}) => { }) => {
for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
if (id === fontFamily) { if (id === fontFamily) {
// TODO: we should fallback first to generic family names first
return `${fontFamilyString}${getFontFamilyFallbacks(id) return `${fontFamilyString}${getFontFamilyFallbacks(id)
.map((x) => `, ${x}`) .map((x) => `, ${x}`)
.join("")}`; .join("")}`;
@@ -712,8 +713,8 @@ export const arrayToObject = <T>(
array: readonly T[], array: readonly T[],
groupBy?: (value: T) => string | number, groupBy?: (value: T) => string | number,
) => ) =>
array.reduce((acc, value) => { array.reduce((acc, value, idx) => {
acc[groupBy ? groupBy(value) : String(value)] = value; acc[groupBy ? groupBy(value) : idx] = value;
return acc; return acc;
}, {} as { [key: string]: T }); }, {} as { [key: string]: T });
@@ -1240,16 +1241,13 @@ export const castArray = <T>(value: T | T[]): T[] =>
export const elementCenterPoint = ( export const elementCenterPoint = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0, xOffset: number = 0,
yOffset: number = 0, yOffset: number = 0,
) => { ) => {
const { x, y, width, height } = element; const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
const centerXPoint = x + width / 2 + xOffset; return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
const centerYPoint = y + height / 2 + yOffset;
return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
}; };
/** hack for Array.isArray type guard not working with readonly value[] */ /** hack for Array.isArray type guard not working with readonly value[] */
+184 -3
View File
@@ -1,8 +1,17 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import {
pointFrom,
pointDistance,
type LocalPoint,
pointRotateRads,
} from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { GlobalPoint } from "@excalidraw/math";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
@@ -20,7 +29,12 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import { generateFreeDrawShape } from "./renderElement"; import { generateFreeDrawShape } from "./renderElement";
import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import {
getArrowheadPoints,
getCenterForBounds,
getDiamondPoints,
getElementBounds,
} from "./bounds";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
@@ -28,10 +42,11 @@ import type {
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
Arrowhead, Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -303,6 +318,172 @@ const getArrowheadShapes = (
} }
}; };
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
) => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
disableMultiStrokeFill: true,
roughness: 0,
preserveVertices: true,
};
const center = getCenterForBounds(
getElementBounds(element, elementsMap, true),
);
switch (element.type) {
case "line":
case "arrow": {
// points array can be empty in the beginning, so it is important to add
// initial position to it
const points = element.points.length
? element.points
: [pointFrom<LocalPoint>(0, 0)];
if (isElbowArrow(element)) {
return generator.path(generateElbowArrowShape(points, 16), options)
.sets[0].ops;
} else if (!element.roundness) {
return points.map((point, idx) => {
const p = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
}
return generator
.curve(points as unknown as RoughPoint[], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
case "freedraw": {
if (element.points.length < 2) {
return [];
}
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
/** /**
* Generates the roughjs shape for given element. * Generates the roughjs shape for given element.
* *
+142 -127
View File
@@ -27,8 +27,6 @@ import {
PRECISION, PRECISION,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isPointOnShape } from "@excalidraw/utils/collision";
import type { LocalPoint, Radians } from "@excalidraw/math"; import type { LocalPoint, Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types"; import type { AppState } from "@excalidraw/excalidraw/types";
@@ -41,7 +39,7 @@ import {
doBoundsIntersect, doBoundsIntersect,
} from "./bounds"; } from "./bounds";
import { intersectElementWithLineSegment } from "./collision"; import { intersectElementWithLineSegment } from "./collision";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
headingForPointFromElement, headingForPointFromElement,
headingIsHorizontal, headingIsHorizontal,
@@ -63,7 +61,7 @@ import {
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes"; import { aabbForElement } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow"; import { updateElbowArrowPoints } from "./elbowArrow";
import type { Scene } from "./Scene"; import type { Scene } from "./Scene";
@@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
export const FIXED_BINDING_DISTANCE = 5; export const FIXED_BINDING_DISTANCE = 5;
export const BINDING_HIGHLIGHT_THICKNESS = 10; export const BINDING_HIGHLIGHT_THICKNESS = 10;
export const BINDING_HIGHLIGHT_OFFSET = 4;
const getNonDeletedElements = ( const getNonDeletedElements = (
scene: Scene, scene: Scene,
@@ -131,6 +128,7 @@ export const bindOrUnbindLinearElement = (
endBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep",
scene: Scene, scene: Scene,
): void => { ): void => {
const elementsMap = scene.getNonDeletedElementsMap();
const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set(); const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
@@ -141,6 +139,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
bindOrUnbindLinearElementEdge( bindOrUnbindLinearElementEdge(
linearElement, linearElement,
@@ -150,6 +149,7 @@ export const bindOrUnbindLinearElement = (
boundToElementIds, boundToElementIds,
unboundFromElementIds, unboundFromElementIds,
scene, scene,
elementsMap,
); );
const onlyUnbound = Array.from(unboundFromElementIds).filter( const onlyUnbound = Array.from(unboundFromElementIds).filter(
@@ -176,6 +176,7 @@ const bindOrUnbindLinearElementEdge = (
// Is mutated // Is mutated
unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>, unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
scene: Scene, scene: Scene,
elementsMap: ElementsMap,
): void => { ): void => {
// "keep" is for method chaining convenience, a "no-op", so just bail out // "keep" is for method chaining convenience, a "no-op", so just bail out
if (bindableElement === "keep") { if (bindableElement === "keep") {
@@ -216,43 +217,29 @@ const bindOrUnbindLinearElementEdge = (
} }
}; };
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
linearElement: NonDeleted<ExcalidrawLinearElement>,
edge: "start" | "end",
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
): NonDeleted<ExcalidrawElement> | null => {
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
const elementId =
edge === "start"
? linearElement.startBinding?.elementId
: linearElement.endBinding?.elementId;
if (elementId) {
const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
};
const getOriginalBindingsIfStillCloseToArrowEnds = ( const getOriginalBindingsIfStillCloseToArrowEnds = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): (NonDeleted<ExcalidrawElement> | null)[] => ): (NonDeleted<ExcalidrawElement> | null)[] =>
["start", "end"].map((edge) => (["start", "end"] as const).map((edge) => {
getOriginalBindingIfStillCloseOfLinearElementEdge( const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
linearElement, const elementId =
edge as "start" | "end", edge === "start"
elementsMap, ? linearElement.startBinding?.elementId
zoom, : linearElement.endBinding?.elementId;
), if (elementId) {
); const element = elementsMap.get(elementId);
if (
isBindableElement(element) &&
bindingBorderTest(element, coors, elementsMap, zoom)
) {
return element;
}
}
return null;
});
const getBindingStrategyForDraggingArrowEndpoints = ( const getBindingStrategyForDraggingArrowEndpoints = (
selectedElement: NonDeleted<ExcalidrawLinearElement>, selectedElement: NonDeleted<ExcalidrawLinearElement>,
@@ -268,7 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1; const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
const start = startDragged const start = startDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@@ -279,7 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
: "keep"; : "keep";
const end = endDragged const end = endDragged
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@@ -311,7 +298,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
); );
const start = startIsClose const start = startIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"start", "start",
elementsMap, elementsMap,
@@ -322,7 +309,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
: null; : null;
const end = endIsClose const end = endIsClose
? isBindingEnabled ? isBindingEnabled
? getElligibleElementForBindingElement( ? getEligibleElementForBindingElement(
selectedElement, selectedElement,
"end", "end",
elementsMap, elementsMap,
@@ -441,22 +428,13 @@ export const maybeBindLinearElement = (
const normalizePointBinding = ( const normalizePointBinding = (
binding: { focus: number; gap: number }, binding: { focus: number; gap: number },
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
) => { ) => ({
let gap = binding.gap; ...binding,
const maxGap = maxBindingGap( gap: Math.min(
hoveredElement, binding.gap,
hoveredElement.width, maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
hoveredElement.height, ),
); });
if (gap > maxGap) {
gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
}
return {
...binding,
gap,
};
};
export const bindLinearElement = ( export const bindLinearElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
@@ -488,6 +466,7 @@ export const bindLinearElement = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
scene.getNonDeletedElementsMap(),
), ),
}; };
} }
@@ -703,8 +682,13 @@ const calculateFocusAndGap = (
); );
return { return {
focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), focus: determineFocusDistance(
gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), hoveredElement,
elementsMap,
adjacentPoint,
edgePoint,
),
gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
}; };
}; };
@@ -874,6 +858,7 @@ export const getHeadingForElbowArrowSnap = (
bindableElement: ExcalidrawBindableElement | undefined | null, bindableElement: ExcalidrawBindableElement | undefined | null,
aabb: Bounds | undefined | null, aabb: Bounds | undefined | null,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
): Heading => { ): Heading => {
const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p)); const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -882,11 +867,16 @@ export const getHeadingForElbowArrowSnap = (
return otherPointHeading; return otherPointHeading;
} }
const distance = getDistanceForBinding(origPoint, bindableElement, zoom); const distance = getDistanceForBinding(
origPoint,
bindableElement,
elementsMap,
zoom,
);
if (!distance) { if (!distance) {
return vectorToHeading( return vectorToHeading(
vectorFromPoint(p, elementCenterPoint(bindableElement)), vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
); );
} }
@@ -896,9 +886,10 @@ export const getHeadingForElbowArrowSnap = (
const getDistanceForBinding = ( const getDistanceForBinding = (
point: Readonly<GlobalPoint>, point: Readonly<GlobalPoint>,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap,
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
) => { ) => {
const distance = distanceToBindableElement(bindableElement, point); const distance = distanceToElement(bindableElement, elementsMap, point);
const bindDistance = maxBindingGap( const bindDistance = maxBindingGap(
bindableElement, bindableElement,
bindableElement.width, bindableElement.width,
@@ -913,12 +904,13 @@ export const bindPointToSnapToElementOutline = (
arrow: ExcalidrawElbowArrowElement, arrow: ExcalidrawElbowArrowElement,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
if (isDevEnv() || isTestEnv()) { if (isDevEnv() || isTestEnv()) {
invariant(arrow.points.length > 1, "Arrow should have at least 2 points"); invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
} }
const aabb = aabbForElement(bindableElement); const aabb = aabbForElement(bindableElement, elementsMap);
const localP = const localP =
arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
const globalP = pointFrom<GlobalPoint>( const globalP = pointFrom<GlobalPoint>(
@@ -926,7 +918,7 @@ export const bindPointToSnapToElementOutline = (
arrow.y + localP[1], arrow.y + localP[1],
); );
const edgePoint = isRectanguloidElement(bindableElement) const edgePoint = isRectanguloidElement(bindableElement)
? avoidRectangularCorner(bindableElement, globalP) ? avoidRectangularCorner(bindableElement, elementsMap, globalP)
: globalP; : globalP;
const elbowed = isElbowArrow(arrow); const elbowed = isElbowArrow(arrow);
const center = getCenterForBounds(aabb); const center = getCenterForBounds(aabb);
@@ -945,26 +937,31 @@ export const bindPointToSnapToElementOutline = (
const isHorizontal = headingIsHorizontal( const isHorizontal = headingIsHorizontal(
headingForPointFromElement(bindableElement, aabb, globalP), headingForPointFromElement(bindableElement, aabb, globalP),
); );
const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
const otherPoint = pointFrom<GlobalPoint>( const otherPoint = pointFrom<GlobalPoint>(
isHorizontal ? center[0] : edgePoint[0], isHorizontal ? center[0] : snapPoint[0],
!isHorizontal ? center[1] : edgePoint[1], !isHorizontal ? center[1] : snapPoint[1],
);
const intersector = lineSegment(
otherPoint,
pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
); );
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
lineSegment( elementsMap,
otherPoint, intersector,
pointFromVector( FIXED_BINDING_DISTANCE,
vectorScale( ).sort(pointDistanceSq)[0];
vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
Math.max(bindableElement.width, bindableElement.height) * 2,
),
otherPoint,
),
),
)[0];
} else { } else {
intersection = intersectElementWithLineSegment( intersection = intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment( lineSegment(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@@ -991,31 +988,15 @@ export const bindPointToSnapToElementOutline = (
return edgePoint; return edgePoint;
} }
if (elbowed) { return elbowed ? intersection : edgePoint;
const scalar =
pointDistanceSq(edgePoint, center) -
pointDistanceSq(intersection, center) >
0
? FIXED_BINDING_DISTANCE
: -FIXED_BINDING_DISTANCE;
return pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, intersection)),
scalar,
),
intersection,
);
}
return edgePoint;
}; };
export const avoidRectangularCorner = ( export const avoidRectangularCorner = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) { if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@@ -1108,35 +1089,34 @@ export const avoidRectangularCorner = (
export const snapToMid = ( export const snapToMid = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
tolerance: number = 0.05, tolerance: number = 0.05,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, height, angle } = element; const { x, y, width, height, angle } = element;
const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
const center = elementCenterPoint(element, -0.1, -0.1);
const nonRotated = pointRotateRads(p, center, -angle as Radians); const nonRotated = pointRotateRads(p, center, -angle as Radians);
// snap-to-center point is adaptive to element size, but we don't want to go // snap-to-center point is adaptive to element size, but we don't want to go
// above and below certain px distance // above and below certain px distance
const verticalThrehsold = clamp(tolerance * height, 5, 80); const verticalThreshold = clamp(tolerance * height, 5, 80);
const horizontalThrehsold = clamp(tolerance * width, 5, 80); const horizontalThreshold = clamp(tolerance * width, 5, 80);
if ( if (
nonRotated[0] <= x + width / 2 && nonRotated[0] <= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// LEFT // LEFT
return pointRotateRads( return pointRotateRads<GlobalPoint>(
pointFrom(x - FIXED_BINDING_DISTANCE, center[1]), pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
center, center,
angle, angle,
); );
} else if ( } else if (
nonRotated[1] <= y + height / 2 && nonRotated[1] <= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// TOP // TOP
return pointRotateRads( return pointRotateRads(
@@ -1146,8 +1126,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[0] >= x + width / 2 && nonRotated[0] >= x + width / 2 &&
nonRotated[1] > center[1] - verticalThrehsold && nonRotated[1] > center[1] - verticalThreshold &&
nonRotated[1] < center[1] + verticalThrehsold nonRotated[1] < center[1] + verticalThreshold
) { ) {
// RIGHT // RIGHT
return pointRotateRads( return pointRotateRads(
@@ -1157,8 +1137,8 @@ export const snapToMid = (
); );
} else if ( } else if (
nonRotated[1] >= y + height / 2 && nonRotated[1] >= y + height / 2 &&
nonRotated[0] > center[0] - horizontalThrehsold && nonRotated[0] > center[0] - horizontalThreshold &&
nonRotated[0] < center[0] + horizontalThrehsold nonRotated[0] < center[0] + horizontalThreshold
) { ) {
// DOWN // DOWN
return pointRotateRads( return pointRotateRads(
@@ -1167,7 +1147,7 @@ export const snapToMid = (
angle, angle,
); );
} else if (element.type === "diamond") { } else if (element.type === "diamond") {
const distance = FIXED_BINDING_DISTANCE - 1; const distance = FIXED_BINDING_DISTANCE;
const topLeft = pointFrom<GlobalPoint>( const topLeft = pointFrom<GlobalPoint>(
x + width / 4 - distance, x + width / 4 - distance,
y + height / 4 - distance, y + height / 4 - distance,
@@ -1184,27 +1164,28 @@ export const snapToMid = (
x + (3 * width) / 4 + distance, x + (3 * width) / 4 + distance,
y + (3 * height) / 4 + distance, y + (3 * height) / 4 + distance,
); );
if ( if (
pointDistance(topLeft, nonRotated) < pointDistance(topLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topLeft, center, angle); return pointRotateRads(topLeft, center, angle);
} }
if ( if (
pointDistance(topRight, nonRotated) < pointDistance(topRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(topRight, center, angle); return pointRotateRads(topRight, center, angle);
} }
if ( if (
pointDistance(bottomLeft, nonRotated) < pointDistance(bottomLeft, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomLeft, center, angle); return pointRotateRads(bottomLeft, center, angle);
} }
if ( if (
pointDistance(bottomRight, nonRotated) < pointDistance(bottomRight, nonRotated) <
Math.max(horizontalThrehsold, verticalThrehsold) Math.max(horizontalThreshold, verticalThreshold)
) { ) {
return pointRotateRads(bottomRight, center, angle); return pointRotateRads(bottomRight, center, angle);
} }
@@ -1239,8 +1220,9 @@ const updateBoundPoint = (
linearElement, linearElement,
bindableElement, bindableElement,
startOrEnd === "startBinding" ? "start" : "end", startOrEnd === "startBinding" ? "start" : "end",
elementsMap,
).fixedPoint; ).fixedPoint;
const globalMidPoint = elementCenterPoint(bindableElement); const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
const global = pointFrom<GlobalPoint>( const global = pointFrom<GlobalPoint>(
bindableElement.x + fixedPoint[0] * bindableElement.width, bindableElement.x + fixedPoint[0] * bindableElement.width,
bindableElement.y + fixedPoint[1] * bindableElement.height, bindableElement.y + fixedPoint[1] * bindableElement.height,
@@ -1266,6 +1248,7 @@ const updateBoundPoint = (
); );
const focusPointAbsolute = determineFocusPoint( const focusPointAbsolute = determineFocusPoint(
bindableElement, bindableElement,
elementsMap,
binding.focus, binding.focus,
adjacentPoint, adjacentPoint,
); );
@@ -1284,7 +1267,7 @@ const updateBoundPoint = (
elementsMap, elementsMap,
); );
const center = elementCenterPoint(bindableElement); const center = elementCenterPoint(bindableElement, elementsMap);
const interceptorLength = const interceptorLength =
pointDistance(adjacentPoint, edgePointAbsolute) + pointDistance(adjacentPoint, edgePointAbsolute) +
pointDistance(adjacentPoint, center) + pointDistance(adjacentPoint, center) +
@@ -1292,6 +1275,7 @@ const updateBoundPoint = (
const intersections = [ const intersections = [
...intersectElementWithLineSegment( ...intersectElementWithLineSegment(
bindableElement, bindableElement,
elementsMap,
lineSegment<GlobalPoint>( lineSegment<GlobalPoint>(
adjacentPoint, adjacentPoint,
pointFromVector( pointFromVector(
@@ -1342,6 +1326,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement: NonDeleted<ExcalidrawElbowArrowElement>, linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
hoveredElement: ExcalidrawBindableElement, hoveredElement: ExcalidrawBindableElement,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: ElementsMap,
): { fixedPoint: FixedPoint } => { ): { fixedPoint: FixedPoint } => {
const bounds = [ const bounds = [
hoveredElement.x, hoveredElement.x,
@@ -1353,6 +1338,7 @@ export const calculateFixedPointForElbowArrowBinding = (
linearElement, linearElement,
hoveredElement, hoveredElement,
startOrEnd, startOrEnd,
elementsMap,
); );
const globalMidPoint = pointFrom( const globalMidPoint = pointFrom(
bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[0] + (bounds[2] - bounds[0]) / 2,
@@ -1396,7 +1382,7 @@ const maybeCalculateNewGapWhenScaling = (
return { ...currentBinding, gap: newGap }; return { ...currentBinding, gap: newGap };
}; };
const getElligibleElementForBindingElement = ( const getEligibleElementForBindingElement = (
linearElement: NonDeleted<ExcalidrawLinearElement>, linearElement: NonDeleted<ExcalidrawLinearElement>,
startOrEnd: "start" | "end", startOrEnd: "start" | "end",
elementsMap: NonDeletedSceneElementsMap, elementsMap: NonDeletedSceneElementsMap,
@@ -1548,14 +1534,38 @@ export const bindingBorderTest = (
zoom?: AppState["zoom"], zoom?: AppState["zoom"],
fullShape?: boolean, fullShape?: boolean,
): boolean => { ): boolean => {
const p = pointFrom<GlobalPoint>(x, y);
const threshold = maxBindingGap(element, element.width, element.height, zoom); const threshold = maxBindingGap(element, element.width, element.height, zoom);
const shouldTestInside =
// disable fullshape snapping for frame elements so we
// can bind to frame children
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element);
const shape = getElementShape(element, elementsMap); // PERF: Run a cheap test to see if the binding element
return ( // is even close to the element
isPointOnShape(pointFrom(x, y), shape, threshold) || const bounds = [
(fullShape === true && x - threshold,
pointInsideBounds(pointFrom(x, y), aabbForElement(element))) y - threshold,
x + threshold,
y + threshold,
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(bounds, elementBounds)) {
return false;
}
// Do the intersection test against the element since it's close enough
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
lineSegment(elementCenterPoint(element, elementsMap), p),
); );
const distance = distanceToElement(element, elementsMap, p);
return shouldTestInside
? intersections.length === 0 || distance <= threshold
: intersections.length > 0 && distance <= threshold;
}; };
export const maxBindingGap = ( export const maxBindingGap = (
@@ -1575,7 +1585,7 @@ export const maxBindingGap = (
// bigger bindable boundary for bigger elements // bigger bindable boundary for bigger elements
Math.min(0.25 * smallerDimension, 32), Math.min(0.25 * smallerDimension, 32),
// keep in sync with the zoomed highlight // keep in sync with the zoomed highlight
BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET, BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
); );
}; };
@@ -1586,12 +1596,13 @@ export const maxBindingGap = (
// of the element. // of the element.
const determineFocusDistance = ( const determineFocusDistance = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// Point on the line, in absolute coordinates // Point on the line, in absolute coordinates
a: GlobalPoint, a: GlobalPoint,
// Another point on the line, in absolute coordinates (closer to element) // Another point on the line, in absolute coordinates (closer to element)
b: GlobalPoint, b: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (pointsEqual(a, b)) { if (pointsEqual(a, b)) {
return 0; return 0;
@@ -1716,12 +1727,13 @@ const determineFocusDistance = (
const determineFocusPoint = ( const determineFocusPoint = (
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
// The oriented, relative distance from the center of `element` of the // The oriented, relative distance from the center of `element` of the
// returned focusPoint // returned focusPoint
focus: number, focus: number,
adjacentPoint: GlobalPoint, adjacentPoint: GlobalPoint,
): GlobalPoint => { ): GlobalPoint => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
if (focus === 0) { if (focus === 0) {
return center; return center;
@@ -2144,6 +2156,7 @@ export class BindableElement {
export const getGlobalFixedPointForBindableElement = ( export const getGlobalFixedPointForBindableElement = (
fixedPointRatio: [number, number], fixedPointRatio: [number, number],
element: ExcalidrawBindableElement, element: ExcalidrawBindableElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio); const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
@@ -2152,7 +2165,7 @@ export const getGlobalFixedPointForBindableElement = (
element.x + element.width * fixedX, element.x + element.width * fixedX,
element.y + element.height * fixedY, element.y + element.height * fixedY,
), ),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
element.angle, element.angle,
); );
}; };
@@ -2176,6 +2189,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint, arrow.startBinding.fixedPoint,
startElement as ExcalidrawBindableElement, startElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[0][0], arrow.x + arrow.points[0][0],
@@ -2186,6 +2200,7 @@ export const getGlobalFixedPoints = (
? getGlobalFixedPointForBindableElement( ? getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint, arrow.endBinding.fixedPoint,
endElement as ExcalidrawBindableElement, endElement as ExcalidrawBindableElement,
elementsMap,
) )
: pointFrom<GlobalPoint>( : pointFrom<GlobalPoint>(
arrow.x + arrow.points[arrow.points.length - 1][0], arrow.x + arrow.points[arrow.points.length - 1][0],
+35 -3
View File
@@ -102,9 +102,23 @@ export class ElementBounds {
version: ExcalidrawElement["version"]; version: ExcalidrawElement["version"];
} }
>(); >();
private static nonRotatedBoundsCache = new WeakMap<
ExcalidrawElement,
{
bounds: Bounds;
version: ExcalidrawElement["version"];
}
>();
static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { static getBounds(
const cachedBounds = ElementBounds.boundsCache.get(element); element: ExcalidrawElement,
elementsMap: ElementsMap,
nonRotated: boolean = false,
) {
const cachedBounds =
nonRotated && element.angle !== 0
? ElementBounds.nonRotatedBoundsCache.get(element)
: ElementBounds.boundsCache.get(element);
if ( if (
cachedBounds?.version && cachedBounds?.version &&
@@ -115,6 +129,23 @@ export class ElementBounds {
) { ) {
return cachedBounds.bounds; return cachedBounds.bounds;
} }
if (nonRotated && element.angle !== 0) {
const nonRotatedBounds = ElementBounds.calculateBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
ElementBounds.nonRotatedBoundsCache.set(element, {
version: element.version,
bounds: nonRotatedBounds,
});
return nonRotatedBounds;
}
const bounds = ElementBounds.calculateBounds(element, elementsMap); const bounds = ElementBounds.calculateBounds(element, elementsMap);
ElementBounds.boundsCache.set(element, { ElementBounds.boundsCache.set(element, {
@@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
export const getElementBounds = ( export const getElementBounds = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
nonRotated: boolean = false,
): Bounds => { ): Bounds => {
return ElementBounds.getBounds(element, elementsMap); return ElementBounds.getBounds(element, elementsMap, nonRotated);
}; };
export const getCommonBounds = ( export const getCommonBounds = (
+318 -132
View File
@@ -2,51 +2,60 @@ import { isTransparent, elementCenterPoint } from "@excalidraw/common";
import { import {
curveIntersectLineSegment, curveIntersectLineSegment,
isPointWithinBounds, isPointWithinBounds,
line,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
pointsEqual, pointsEqual,
vectorFromPoint,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { import {
ellipse, ellipse,
ellipseLineIntersectionPoints, ellipseSegmentInterceptPoints,
} from "@excalidraw/math/ellipse"; } from "@excalidraw/math/ellipse";
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision"; import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
import type {
GlobalPoint,
LineSegment,
LocalPoint,
Polygon,
Radians,
} from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { getBoundTextShape, isPathALoop } from "./shapes"; import { isPathALoop } from "./shapes";
import { getElementBounds } from "./bounds"; import {
type Bounds,
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isFreeDrawElement,
isIframeLikeElement, isIframeLikeElement,
isImageElement, isImageElement,
isLinearElement,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { distanceToElement } from "./distance";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawRectangleElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
@@ -72,45 +81,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
return isDraggableFromInside || isImageElement(element); return isDraggableFromInside || isImageElement(element);
}; };
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = { export type HitTestArgs = {
x: number; point: GlobalPoint;
y: number;
element: ExcalidrawElement; element: ExcalidrawElement;
shape: GeometricShape<Point>; threshold: number;
threshold?: number; elementsMap: ElementsMap;
frameNameBound?: FrameNameBounds | null; frameNameBound?: FrameNameBounds | null;
}; };
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({ export const hitElementItself = ({
x, point,
y,
element, element,
shape, threshold,
threshold = 10, elementsMap,
frameNameBound = null, frameNameBound = null,
}: HitTestArgs<Point>) => { }: HitTestArgs) => {
let hit = shouldTestInside(element) // Hit test against a frame's name
? // Since `inShape` tests STRICTLY againt the insides of a shape const hitFrameName = frameNameBound
// we would need `onShape` as well to include the "borders" ? isPointWithinBounds(
isPointInShape(pointFrom(x, y), shape) || pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
isPointOnShape(pointFrom(x, y), shape, threshold) point,
: isPointOnShape(pointFrom(x, y), shape, threshold); pointFrom(
frameNameBound.x + frameNameBound.width + threshold,
frameNameBound.y + frameNameBound.height + threshold,
),
)
: false;
// hit test against a frame's name // Hit test against the extended, rotated bounding box of the element first
if (!hit && frameNameBound) { const bounds = getElementBounds(element, elementsMap, true);
hit = isPointInShape(pointFrom(x, y), { const hitBounds = isPointWithinBounds(
type: "polygon", pointFrom(bounds[0] - threshold, bounds[1] - threshold),
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement) pointRotateRads(
.data as Polygon<Point>, point,
}); getCenterForBounds(bounds),
-element.angle as Radians,
),
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
);
// PERF: Bail out early if the point is not even in the
// rotated bounding box or not hitting the frame name (saves 99%)
if (!hitBounds && !hitFrameName) {
return false;
} }
return hit; // Do the precise (and relatively costly) hit test
const hitElement = shouldTestInside(element)
? // Since `inShape` tests STRICTLY againt the insides of a shape
// we would need `onShape` as well to include the "borders"
isPointInElement(point, element, elementsMap) ||
isPointOnElementOutline(point, element, elementsMap, threshold)
: isPointOnElementOutline(point, element, elementsMap, threshold);
return hitElement || hitFrameName;
}; };
export const hitElementBoundingBox = ( export const hitElementBoundingBox = (
x: number, point: GlobalPoint,
y: number,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
tolerance = 0, tolerance = 0,
@@ -120,37 +148,42 @@ export const hitElementBoundingBox = (
y1 -= tolerance; y1 -= tolerance;
x2 += tolerance; x2 += tolerance;
y2 += tolerance; y2 += tolerance;
return isPointWithinBounds( return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
pointFrom(x1, y1),
pointFrom(x, y),
pointFrom(x2, y2),
);
}; };
export const hitElementBoundingBoxOnly = < export const hitElementBoundingBoxOnly = (
Point extends GlobalPoint | LocalPoint, hitArgs: HitTestArgs,
>(
hitArgs: HitTestArgs<Point>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
) => { ) =>
return ( !hitElementItself(hitArgs) &&
!hitElementItself(hitArgs) && // bound text is considered part of the element (even if it's outside the bounding box)
// bound text is considered part of the element (even if it's outside the bounding box) !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
!hitElementBoundText( hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
hitArgs.x,
hitArgs.y,
getBoundTextShape(hitArgs.element, elementsMap),
) &&
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
);
};
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>( export const hitElementBoundText = (
x: number, point: GlobalPoint,
y: number, element: ExcalidrawElement,
textShape: GeometricShape<Point> | null, elementsMap: ElementsMap,
): boolean => { ): boolean => {
return !!textShape && isPointInShape(pointFrom(x, y), textShape); const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
if (!boundTextElementCandidate) {
return false;
}
const boundTextElement = isLinearElement(element)
? {
...boundTextElementCandidate,
// arrow's bound text accurate position is not stored in the element's property
// but rather calculated and returned from the following static method
...LinearElementEditor.getBoundTextElementPosition(
element,
boundTextElementCandidate,
elementsMap,
),
}
: boundTextElementCandidate;
return isPointInElement(point, boundTextElement, elementsMap);
}; };
/** /**
@@ -163,9 +196,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
*/ */
export const intersectElementWithLineSegment = ( export const intersectElementWithLineSegment = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
line: LineSegment<GlobalPoint>, line: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
// First check if the line intersects the element's axis-aligned bounding box
// as it is much faster than checking intersection against the element's shape
const intersectorBounds = [
Math.min(line[0][0] - offset, line[1][0] - offset),
Math.min(line[0][1] - offset, line[1][1] - offset),
Math.max(line[0][0] + offset, line[1][0] + offset),
Math.max(line[0][1] + offset, line[1][1] + offset),
] as Bounds;
const elementBounds = getElementBounds(element, elementsMap);
if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
return [];
}
// Do the actual intersection test against the element's shape
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "image": case "image":
@@ -173,23 +223,88 @@ export const intersectElementWithLineSegment = (
case "iframe": case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "selection":
case "magicframe": case "magicframe":
return intersectRectanguloidWithLineSegment(element, line, offset); return intersectRectanguloidWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "diamond": case "diamond":
return intersectDiamondWithLineSegment(element, line, offset); return intersectDiamondWithLineSegment(
element,
elementsMap,
line,
offset,
onlyFirst,
);
case "ellipse": case "ellipse":
return intersectEllipseWithLineSegment(element, line, offset); return intersectEllipseWithLineSegment(
default: element,
throw new Error(`Unimplemented element type '${element.type}'`); elementsMap,
line,
offset,
);
case "line":
case "freedraw":
case "arrow":
return intersectLinearOrFreeDrawWithLineSegment(
element,
elementsMap,
line,
onlyFirst,
);
} }
}; };
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
segment: LineSegment<GlobalPoint>,
onlyFirst = false,
): GlobalPoint[] => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
const intersections = [];
for (const l of lines) {
const intersection = lineSegmentIntersectionPoints(l, segment);
if (intersection) {
intersections.push(intersection);
if (onlyFirst) {
return intersections;
}
}
}
for (const c of curves) {
const hits = curveIntersectLineSegment(c, segment);
if (hits.length > 0) {
intersections.push(...hits);
if (onlyFirst) {
return intersections;
}
}
}
return intersections;
};
const intersectRectanguloidWithLineSegment = ( const intersectRectanguloidWithLineSegment = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedA = pointRotateRads<GlobalPoint>( const rotatedA = pointRotateRads<GlobalPoint>(
@@ -206,34 +321,37 @@ const intersectRectanguloidWithLineSegment = (
// Get the element's building components we can test against // Get the element's building components we can test against
const [sides, corners] = deconstructRectanguloidElement(element, offset); const [sides, corners] = deconstructRectanguloidElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
// Test intersection against the sides, keep only the valid
// intersection points and rotate them back to scene space for (const s of sides) {
sides const intersection = lineSegmentIntersectionPoints(
.map((s) => lineSegment(rotatedA, rotatedB),
lineSegmentIntersectionPoints( s,
lineSegment<GlobalPoint>(rotatedA, rotatedB), );
s, if (intersection) {
), intersections.push(pointRotateRads(intersection, center, element.angle));
)
.filter((x) => x != null) if (onlyFirst) {
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)) return intersections;
// Test intersection against the corners which are cubic bezier curves, }
// keep only the valid intersection points and rotate them back to scene }
// space }
.concat(
corners for (const t of corners) {
.flatMap((t) => const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
) if (hits.length > 0) {
.filter((i) => i != null) for (const j of hits) {
.map((j) => pointRotateRads(j, center, element.angle)), intersections.push(pointRotateRads(j, center, element.angle));
) }
// Remove duplicates
.filter( if (onlyFirst) {
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, return intersections;
) }
); }
}
return intersections;
}; };
/** /**
@@ -245,43 +363,51 @@ const intersectRectanguloidWithLineSegment = (
*/ */
const intersectDiamondWithLineSegment = ( const intersectDiamondWithLineSegment = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
onlyFirst = false,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
const [sides, curves] = deconstructDiamondElement(element, offset); const [sides, corners] = deconstructDiamondElement(element, offset);
return ( const intersections: GlobalPoint[] = [];
sides
.map((s) => for (const s of sides) {
lineSegmentIntersectionPoints( const intersection = lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
s, s,
), );
) if (intersection) {
.filter((p): p is GlobalPoint => p != null) intersections.push(pointRotateRads(intersection, center, element.angle));
// Rotate back intersection points
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)) if (onlyFirst) {
.concat( return intersections;
curves }
.flatMap((p) => }
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), }
)
.filter((p) => p != null) for (const t of corners) {
// Rotate back intersection points const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
.map((p) => pointRotateRads(p, center, element.angle)),
) if (hits.length > 0) {
// Remove duplicates for (const j of hits) {
.filter( intersections.push(pointRotateRads(j, center, element.angle));
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, }
)
); if (onlyFirst) {
return intersections;
}
}
}
return intersections;
}; };
/** /**
@@ -293,16 +419,76 @@ const intersectDiamondWithLineSegment = (
*/ */
const intersectEllipseWithLineSegment = ( const intersectEllipseWithLineSegment = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
l: LineSegment<GlobalPoint>, l: LineSegment<GlobalPoint>,
offset: number = 0, offset: number = 0,
): GlobalPoint[] => { ): GlobalPoint[] => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
return ellipseLineIntersectionPoints( return ellipseSegmentInterceptPoints(
ellipse(center, element.width / 2 + offset, element.height / 2 + offset), ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
line(rotatedA, rotatedB), lineSegment(rotatedA, rotatedB),
).map((p) => pointRotateRads(p, center, element.angle)); ).map((p) => pointRotateRads(p, center, element.angle));
}; };
/**
* Check if the given point is considered on the given shape's border
*
* @param point
* @param element
* @param tolerance
* @returns
*/
const isPointOnElementOutline = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
tolerance = 1,
) => distanceToElement(element, elementsMap, point) <= tolerance;
/**
* Check if the given point is considered inside the element's border
*
* @param point
* @param element
* @returns
*/
export const isPointInElement = (
point: GlobalPoint,
element: ExcalidrawElement,
elementsMap: ElementsMap,
) => {
if (
(isLinearElement(element) || isFreeDrawElement(element)) &&
!isPathALoop(element.points)
) {
// There isn't any "inside" for a non-looping path
return false;
}
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
return false;
}
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
const otherPoint = pointFromVector(
vectorScale(
vectorNormalize(vectorFromPoint(point, center, 0.1)),
Math.max(element.width, element.height) * 2,
),
center,
);
const intersector = lineSegment(point, otherPoint);
const intersections = intersectElementWithLineSegment(
element,
elementsMap,
intersector,
).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
return intersections.length % 2 === 1;
};
+2 -1
View File
@@ -34,6 +34,7 @@ export const MINIMAL_CROP_SIZE = 10;
export const cropElement = ( export const cropElement = (
element: ExcalidrawImageElement, element: ExcalidrawImageElement,
elementsMap: ElementsMap,
transformHandle: TransformHandleType, transformHandle: TransformHandleType,
naturalWidth: number, naturalWidth: number,
naturalHeight: number, naturalHeight: number,
@@ -63,7 +64,7 @@ export const cropElement = (
const rotatedPointer = pointRotateRads( const rotatedPointer = pointRotateRads(
pointFrom(pointerX, pointerY), pointFrom(pointerX, pointerY),
elementCenterPoint(element), elementCenterPoint(element, elementsMap),
-element.angle as Radians, -element.angle as Radians,
); );
+262 -93
View File
@@ -5,11 +5,12 @@ import {
isDevEnv, isDevEnv,
isShallowEqual, isShallowEqual,
isTestEnv, isTestEnv,
randomInteger,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawImageElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeleted, NonDeleted,
@@ -18,7 +19,12 @@ import type {
SceneElementsMap, SceneElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { DTO, SubtypeOf, ValueOf } from "@excalidraw/common/utility-types"; import type {
DTO,
Mutable,
SubtypeOf,
ValueOf,
} from "@excalidraw/common/utility-types";
import type { import type {
AppState, AppState,
@@ -51,6 +57,8 @@ import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene"; import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding"; import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement"; import type { ElementUpdate } from "./mutateElement";
@@ -73,13 +81,20 @@ export class Delta<T> {
public static create<T>( public static create<T>(
deleted: Partial<T>, deleted: Partial<T>,
inserted: Partial<T>, inserted: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>, modifier?: (
modifierOptions?: "deleted" | "inserted", delta: Partial<T>,
partialType: "deleted" | "inserted",
) => Partial<T>,
modifierOptions?: "deleted" | "inserted" | "both",
) { ) {
const modifiedDeleted = const modifiedDeleted =
modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; modifier && modifierOptions !== "inserted"
? modifier(deleted, "deleted")
: deleted;
const modifiedInserted = const modifiedInserted =
modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; modifier && modifierOptions !== "deleted"
? modifier(inserted, "inserted")
: inserted;
return new Delta(modifiedDeleted, modifiedInserted); return new Delta(modifiedDeleted, modifiedInserted);
} }
@@ -113,11 +128,7 @@ export class Delta<T> {
// - we do this only on previously detected changed elements // - we do this only on previously detected changed elements
// - we do shallow compare only on the first level of properties (not going any deeper) // - we do shallow compare only on the first level of properties (not going any deeper)
// - # of properties is reasonably small // - # of properties is reasonably small
for (const key of this.distinctKeysIterator( for (const key of this.getDifferences(prevObject, nextObject)) {
"full",
prevObject,
nextObject,
)) {
deleted[key as keyof T] = prevObject[key]; deleted[key as keyof T] = prevObject[key];
inserted[key as keyof T] = nextObject[key]; inserted[key as keyof T] = nextObject[key];
} }
@@ -256,12 +267,14 @@ export class Delta<T> {
arrayToObject(deletedArray, groupBy), arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy), arrayToObject(insertedArray, groupBy),
), ),
(x) => x,
); );
const insertedDifferences = arrayToObject( const insertedDifferences = arrayToObject(
Delta.getRightDifferences( Delta.getRightDifferences(
arrayToObject(deletedArray, groupBy), arrayToObject(deletedArray, groupBy),
arrayToObject(insertedArray, groupBy), arrayToObject(insertedArray, groupBy),
), ),
(x) => x,
); );
if ( if (
@@ -320,6 +333,42 @@ export class Delta<T> {
return !!anyDistinctKey; return !!anyDistinctKey;
} }
/**
* Compares if shared properties of object1 and object2 contain any different value (aka inner join).
*/
public static isInnerDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = !!this.distinctKeysIterator(
"inner",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/**
* Compares if any properties of object1 and object2 contain any different value (aka full join).
*/
public static isDifferent<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
): boolean {
const anyDistinctKey = !!this.distinctKeysIterator(
"full",
object1,
object2,
skipShallowCompare,
).next().value;
return !!anyDistinctKey;
}
/** /**
* Returns sorted object1 keys that have distinct values. * Returns sorted object1 keys that have distinct values.
*/ */
@@ -346,6 +395,32 @@ export class Delta<T> {
).sort(); ).sort();
} }
/**
* Returns sorted keys of shared object1 and object2 properties that have distinct values (aka inner join).
*/
public static getInnerDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("inner", object1, object2, skipShallowCompare),
).sort();
}
/**
* Returns sorted keys that have distinct values between object1 and object2 (aka full join).
*/
public static getDifferences<T extends {}>(
object1: T,
object2: T,
skipShallowCompare = false,
) {
return Array.from(
this.distinctKeysIterator("full", object1, object2, skipShallowCompare),
).sort();
}
/** /**
* Iterator comparing values of object properties based on the passed joining strategy. * Iterator comparing values of object properties based on the passed joining strategy.
* *
@@ -354,7 +429,7 @@ export class Delta<T> {
* WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that. * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that.
*/ */
private static *distinctKeysIterator<T extends {}>( private static *distinctKeysIterator<T extends {}>(
join: "left" | "right" | "full", join: "left" | "right" | "inner" | "full",
object1: T, object1: T,
object2: T, object2: T,
skipShallowCompare = false, skipShallowCompare = false,
@@ -369,6 +444,8 @@ export class Delta<T> {
keys = Object.keys(object1); keys = Object.keys(object1);
} else if (join === "right") { } else if (join === "right") {
keys = Object.keys(object2); keys = Object.keys(object2);
} else if (join === "inner") {
keys = Object.keys(object1).filter((key) => key in object2);
} else if (join === "full") { } else if (join === "full") {
keys = Array.from( keys = Array.from(
new Set([...Object.keys(object1), ...Object.keys(object2)]), new Set([...Object.keys(object1), ...Object.keys(object2)]),
@@ -382,17 +459,17 @@ export class Delta<T> {
} }
for (const key of keys) { for (const key of keys) {
const object1Value = object1[key as keyof T]; const value1 = object1[key as keyof T];
const object2Value = object2[key as keyof T]; const value2 = object2[key as keyof T];
if (object1Value !== object2Value) { if (value1 !== value2) {
if ( if (
!skipShallowCompare && !skipShallowCompare &&
typeof object1Value === "object" && typeof value1 === "object" &&
typeof object2Value === "object" && typeof value2 === "object" &&
object1Value !== null && value1 !== null &&
object2Value !== null && value2 !== null &&
isShallowEqual(object1Value, object2Value) isShallowEqual(value1, value2)
) { ) {
continue; continue;
} }
@@ -617,14 +694,20 @@ export class AppStateDelta implements DeltaContainer<AppState> {
break; break;
case "croppingElementId": { case "croppingElementId": {
const croppingElementId = nextAppState[key]; const croppingElementId = nextAppState[key];
const element =
croppingElementId && nextElements.get(croppingElementId);
if (element && !element.isDeleted) { if (!croppingElementId) {
// previously there was a croppingElementId (assuming visible), now there is none
visibleDifferenceFlag.value = true; visibleDifferenceFlag.value = true;
} else { } else {
nextAppState[key] = null; const element = nextElements.get(croppingElementId);
if (element && !element.isDeleted) {
visibleDifferenceFlag.value = true;
} else {
nextAppState[key] = null;
}
} }
break; break;
} }
case "editingGroupId": case "editingGroupId":
@@ -858,10 +941,17 @@ export class AppStateDelta implements DeltaContainer<AppState> {
} }
} }
type ElementPartial<T extends ExcalidrawElement = ExcalidrawElement> = Omit< type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
ElementUpdate<Ordered<T>>, Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
"seed"
>; export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>;
};
type ApplyToFlags = {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
};
/** /**
* Elements change is a low level primitive to capture a change between two sets of elements. * Elements change is a low level primitive to capture a change between two sets of elements.
@@ -944,13 +1034,33 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted, inserted,
}: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted; }: Delta<ElementPartial>) => !!deleted.isDeleted === !!inserted.isDeleted;
private static satisfiesCommmonInvariants = ({
deleted,
inserted,
}: Delta<ElementPartial>) =>
!!(
deleted.version &&
inserted.version &&
// versions are required integers
Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) &&
// versions should be positive, zero included
deleted.version >= 0 &&
inserted.version >= 0 &&
// versions should never be the same
deleted.version !== inserted.version
);
private static validate( private static validate(
elementsDelta: ElementsDelta, elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated", type: "added" | "removed" | "updated",
satifies: (delta: Delta<ElementPartial>) => boolean, satifiesSpecialInvariants: (delta: Delta<ElementPartial>) => boolean,
) { ) {
for (const [id, delta] of Object.entries(elementsDelta[type])) { for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (!satifies(delta)) { if (
!this.satisfiesCommmonInvariants(delta) ||
!satifiesSpecialInvariants(delta)
) {
console.error( console.error(
`Broken invariant for "${type}" delta, element "${id}", delta:`, `Broken invariant for "${type}" delta, element "${id}", delta:`,
delta, delta,
@@ -986,7 +1096,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
if (!nextElement) { if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial; const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = { isDeleted: true } as ElementPartial;
const inserted = {
isDeleted: true,
version: prevElement.version + 1,
versionNonce: randomInteger(),
} as ElementPartial;
const delta = Delta.create( const delta = Delta.create(
deleted, deleted,
@@ -1002,7 +1117,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const prevElement = prevElements.get(nextElement.id); const prevElement = prevElements.get(nextElement.id);
if (!prevElement) { if (!prevElement) {
const deleted = { isDeleted: true } as ElementPartial; const deleted = {
isDeleted: true,
version: nextElement.version - 1,
versionNonce: randomInteger(),
} as ElementPartial;
const inserted = { const inserted = {
...nextElement, ...nextElement,
isDeleted: false, isDeleted: false,
@@ -1087,16 +1207,40 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
/** /**
* Update delta/s based on the existing elements. * Update delta/s based on the existing elements.
* *
* @param elements current elements * @param nextElements current elements
* @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated
* @returns new instance with modified delta/s * @returns new instance with modified delta/s
*/ */
public applyLatestChanges( public applyLatestChanges(
elements: SceneElementsMap, prevElements: SceneElementsMap,
modifierOptions: "deleted" | "inserted", nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): ElementsDelta { ): ElementsDelta {
const modifier = const modifier =
(element: OrderedExcalidrawElement) => (partial: ElementPartial) => { (
prevElement: OrderedExcalidrawElement | undefined,
nextElement: OrderedExcalidrawElement | undefined,
) =>
(partial: ElementPartial, partialType: "deleted" | "inserted") => {
let element: OrderedExcalidrawElement | undefined;
switch (partialType) {
case "deleted":
element = prevElement;
break;
case "inserted":
element = nextElement;
break;
}
// the element wasn't found -> don't update the partial
if (!element) {
console.error(
`Element not found when trying to apply latest changes`,
);
return partial;
}
const latestPartial: { [key: string]: unknown } = {}; const latestPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial) as Array<keyof typeof partial>) { for (const key of Object.keys(partial) as Array<keyof typeof partial>) {
@@ -1120,19 +1264,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const modifiedDeltas: Record<string, Delta<ElementPartial>> = {}; const modifiedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) { for (const [id, delta] of Object.entries(deltas)) {
const existingElement = elements.get(id); const prevElement = prevElements.get(id);
const nextElement = nextElements.get(id);
if (existingElement) { let latestDelta: Delta<ElementPartial> | null = null;
const modifiedDelta = Delta.create(
if (prevElement || nextElement) {
latestDelta = Delta.create(
delta.deleted, delta.deleted,
delta.inserted, delta.inserted,
modifier(existingElement), modifier(prevElement, nextElement),
modifierOptions, modifierOptions,
); );
modifiedDeltas[id] = modifiedDelta;
} else { } else {
modifiedDeltas[id] = delta; latestDelta = delta;
}
// it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
modifiedDeltas[id] = latestDelta;
} }
} }
@@ -1150,12 +1300,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo( public applyTo(
elements: SceneElementsMap, elements: SceneElementsMap,
elementsSnapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] { ): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap; let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>; let changedElements: Map<string, OrderedExcalidrawElement>;
const flags = { const flags: ApplyToFlags = {
containsVisibleDifference: false, containsVisibleDifference: false,
containsZindexDifference: false, containsZindexDifference: false,
}; };
@@ -1164,13 +1317,14 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
try { try {
const applyDeltas = ElementsDelta.createApplier( const applyDeltas = ElementsDelta.createApplier(
nextElements, nextElements,
elementsSnapshot, snapshot,
options,
flags, flags,
); );
const addedElements = applyDeltas("added", this.added); const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas("removed", this.removed); const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas("updated", this.updated); const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements); const affectedElements = this.resolveConflicts(elements, nextElements);
@@ -1229,18 +1383,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createApplier = private static createApplier =
( (
nextElements: SceneElementsMap, nextElements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"],
flags: { options: ApplyToOptions,
containsVisibleDifference: boolean; flags: ApplyToFlags,
containsZindexDifference: boolean;
},
) => ) =>
( (deltas: Record<string, Delta<ElementPartial>>) => {
type: "added" | "removed" | "updated",
deltas: Record<string, Delta<ElementPartial>>,
) => {
const getElement = ElementsDelta.createGetter( const getElement = ElementsDelta.createGetter(
type,
nextElements, nextElements,
snapshot, snapshot,
flags, flags,
@@ -1250,7 +1398,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted); const element = getElement(id, delta.inserted);
if (element) { if (element) {
const newElement = ElementsDelta.applyDelta(element, delta, flags); const newElement = ElementsDelta.applyDelta(
element,
delta,
options,
flags,
);
nextElements.set(newElement.id, newElement); nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement); acc.set(newElement.id, newElement);
} }
@@ -1261,13 +1415,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static createGetter = private static createGetter =
( (
type: "added" | "removed" | "updated",
elements: SceneElementsMap, elements: SceneElementsMap,
snapshot: Map<string, OrderedExcalidrawElement>, snapshot: StoreSnapshot["elements"],
flags: { flags: ApplyToFlags,
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
},
) => ) =>
(id: string, partial: ElementPartial) => { (id: string, partial: ElementPartial) => {
let element = elements.get(id); let element = elements.get(id);
@@ -1281,10 +1431,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
flags.containsZindexDifference = true; flags.containsZindexDifference = true;
// as the element was force deleted, we need to check if adding it back results in a visible change // as the element was force deleted, we need to check if adding it back results in a visible change
if ( if (!partial.isDeleted || (partial.isDeleted && !element.isDeleted)) {
partial.isDeleted === false ||
(partial.isDeleted !== true && element.isDeleted === false)
) {
flags.containsVisibleDifference = true; flags.containsVisibleDifference = true;
} }
} else { } else {
@@ -1304,16 +1451,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta( private static applyDelta(
element: OrderedExcalidrawElement, element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>, delta: Delta<ElementPartial>,
flags: { options: ApplyToOptions,
containsVisibleDifference: boolean; flags: ApplyToFlags,
containsZindexDifference: boolean;
} = {
// by default we don't care about about the flags
containsVisibleDifference: true,
containsZindexDifference: true,
},
) { ) {
const { boundElements, ...directlyApplicablePartial } = delta.inserted; const directlyApplicablePartial: Mutable<ElementPartial> = {};
// some properties are not directly applicable, such as:
// - boundElements which contains only diff)
// - version & versionNonce, if we don't want to return to previous versions
for (const key of Object.keys(delta.inserted) as Array<
keyof typeof delta.inserted
>) {
if (key === "boundElements") {
continue;
}
if (options.excludedProperties.has(key)) {
continue;
}
const value = delta.inserted[key];
Reflect.set(directlyApplicablePartial, key, value);
}
if ( if (
delta.deleted.boundElements?.length || delta.deleted.boundElements?.length ||
@@ -1331,19 +1490,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}); });
} }
// TODO: this looks wrong, shouldn't be here
if (element.type === "image") {
const _delta = delta as Delta<ElementPartial<ExcalidrawImageElement>>;
// we want to override `crop` only if modified so that we don't reset
// when undoing/redoing unrelated change
if (_delta.deleted.crop || _delta.inserted.crop) {
Object.assign(directlyApplicablePartial, {
// apply change verbatim
crop: _delta.inserted.crop ?? null,
});
}
}
if (!flags.containsVisibleDifference) { if (!flags.containsVisibleDifference) {
// strip away fractional index, as even if it would be different, it doesn't have to result in visible change // strip away fractional index, as even if it would be different, it doesn't have to result in visible change
const { index, ...rest } = directlyApplicablePartial; const { index, ...rest } = directlyApplicablePartial;
@@ -1650,6 +1796,29 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
): [ElementPartial, ElementPartial] { ): [ElementPartial, ElementPartial] {
try { try {
Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id);
// don't diff the points as:
// - we can't ensure the multiplayer order consistency without fractional index on each point
// - we prefer to not merge the points, as it might just lead to unexpected / incosistent results
const deletedPoints =
(
deleted as ElementPartial<
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
>
).points ?? [];
const insertedPoints =
(
inserted as ElementPartial<
ExcalidrawFreeDrawElement | ExcalidrawLinearElement
>
).points ?? [];
if (!Delta.isDifferent(deletedPoints, insertedPoints)) {
// delete the points from delta if there is no difference, otherwise leave them as they were captured due to consistency
Reflect.deleteProperty(deleted, "points");
Reflect.deleteProperty(inserted, "points");
}
} catch (e) { } catch (e) {
// if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess elements delta.`); console.error(`Couldn't postprocess elements delta.`);
@@ -1665,7 +1834,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static stripIrrelevantProps( private static stripIrrelevantProps(
partial: Partial<OrderedExcalidrawElement>, partial: Partial<OrderedExcalidrawElement>,
): ElementPartial { ): ElementPartial {
const { id, updated, version, versionNonce, ...strippedPartial } = partial; const { id, updated, ...strippedPartial } = partial;
return strippedPartial; return strippedPartial;
} }
+37 -9
View File
@@ -12,21 +12,27 @@ import type { GlobalPoint, Radians } from "@excalidraw/math";
import { import {
deconstructDiamondElement, deconstructDiamondElement,
deconstructLinearOrFreeDrawElement,
deconstructRectanguloidElement, deconstructRectanguloidElement,
} from "./utils"; } from "./utils";
import type { import type {
ExcalidrawBindableElement, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
export const distanceToBindableElement = ( export const distanceToElement = (
element: ExcalidrawBindableElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
switch (element.type) { switch (element.type) {
case "selection":
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe": case "magicframe":
return distanceToRectanguloidElement(element, p); return distanceToRectanguloidElement(element, elementsMap, p);
case "diamond": case "diamond":
return distanceToDiamondElement(element, p); return distanceToDiamondElement(element, elementsMap, p);
case "ellipse": case "ellipse":
return distanceToEllipseElement(element, p); return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
case "freedraw":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
} }
}; };
@@ -52,9 +62,10 @@ export const distanceToBindableElement = (
*/ */
const distanceToRectanguloidElement = ( const distanceToRectanguloidElement = (
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
) => { ) => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// To emulate a rotated rectangle we rotate the point in the inverse angle // To emulate a rotated rectangle we rotate the point in the inverse angle
// instead. It's all the same distance-wise. // instead. It's all the same distance-wise.
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
*/ */
const distanceToDiamondElement = ( const distanceToDiamondElement = (
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
// Rotate the point to the inverse direction to simulate the rotated diamond // Rotate the point to the inverse direction to simulate the rotated diamond
// points. It's all the same distance-wise. // points. It's all the same distance-wise.
@@ -108,12 +120,28 @@ const distanceToDiamondElement = (
*/ */
const distanceToEllipseElement = ( const distanceToEllipseElement = (
element: ExcalidrawEllipseElement, element: ExcalidrawEllipseElement,
elementsMap: ElementsMap,
p: GlobalPoint, p: GlobalPoint,
): number => { ): number => {
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
return ellipseDistanceFromPoint( return ellipseDistanceFromPoint(
// Instead of rotating the ellipse, rotate the point to the inverse angle // Instead of rotating the ellipse, rotate the point to the inverse angle
pointRotateRads(p, center, -element.angle as Radians), pointRotateRads(p, center, -element.angle as Radians),
ellipse(center, element.width / 2, element.height / 2), ellipse(center, element.width / 2, element.height / 2),
); );
}; };
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines, curves] = deconstructLinearOrFreeDrawElement(
element,
elementsMap,
);
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
);
};
+23 -56
View File
@@ -29,10 +29,9 @@ import {
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap, getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding, getHoveredElementForBinding,
} from "./binding"; } from "./binding";
import { distanceToBindableElement } from "./distance"; import { distanceToElement } from "./distance";
import { import {
compareHeading, compareHeading,
flipHeading, flipHeading,
@@ -898,50 +897,6 @@ export const updateElbowArrowPoints = (
return { points: updates.points ?? arrow.points }; return { points: updates.points ?? arrow.points };
} }
// NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
// arrow size is valid. This check will be removed once the issue is identified
if (
arrow.x < -MAX_POS ||
arrow.x > MAX_POS ||
arrow.y < -MAX_POS ||
arrow.y > MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-MAX_POS ||
arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-MAX_POS ||
arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
) {
console.error(
"Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
{
arrow,
updates,
},
);
}
// @ts-ignore See above note
arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
// @ts-ignore See above note
arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
if (updates.points) {
updates.points = updates.points.map(([x, y]) =>
pointFrom<LocalPoint>(
clamp(x, -MAX_POS, MAX_POS),
clamp(y, -MAX_POS, MAX_POS),
),
);
}
if (!import.meta.env.PROD) { if (!import.meta.env.PROD) {
invariant( invariant(
!updates.points || updates.points.length >= 2, !updates.points || updates.points.length >= 2,
@@ -1273,6 +1228,7 @@ const getElbowArrowData = (
arrow.startBinding?.fixedPoint, arrow.startBinding?.fixedPoint,
origStartGlobalPoint, origStartGlobalPoint,
hoveredStartElement, hoveredStartElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const endGlobalPoint = getGlobalPoint( const endGlobalPoint = getGlobalPoint(
@@ -1286,6 +1242,7 @@ const getElbowArrowData = (
arrow.endBinding?.fixedPoint, arrow.endBinding?.fixedPoint,
origEndGlobalPoint, origEndGlobalPoint,
hoveredEndElement, hoveredEndElement,
elementsMap,
options?.isDragging, options?.isDragging,
); );
const startHeading = getBindPointHeading( const startHeading = getBindPointHeading(
@@ -1293,12 +1250,14 @@ const getElbowArrowData = (
endGlobalPoint, endGlobalPoint,
hoveredStartElement, hoveredStartElement,
origStartGlobalPoint, origStartGlobalPoint,
elementsMap,
); );
const endHeading = getBindPointHeading( const endHeading = getBindPointHeading(
endGlobalPoint, endGlobalPoint,
startGlobalPoint, startGlobalPoint,
hoveredEndElement, hoveredEndElement,
origEndGlobalPoint, origEndGlobalPoint,
elementsMap,
); );
const startPointBounds = [ const startPointBounds = [
startGlobalPoint[0] - 2, startGlobalPoint[0] - 2,
@@ -1315,6 +1274,7 @@ const getElbowArrowData = (
const startElementBounds = hoveredStartElement const startElementBounds = hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
startHeading, startHeading,
arrow.startArrowhead arrow.startArrowhead
@@ -1327,6 +1287,7 @@ const getElbowArrowData = (
const endElementBounds = hoveredEndElement const endElementBounds = hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading( offsetFromHeading(
endHeading, endHeading,
arrow.endArrowhead arrow.endArrowhead
@@ -1342,6 +1303,7 @@ const getElbowArrowData = (
hoveredEndElement hoveredEndElement
? aabbForElement( ? aabbForElement(
hoveredEndElement, hoveredEndElement,
elementsMap,
offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
) )
: endPointBounds, : endPointBounds,
@@ -1351,6 +1313,7 @@ const getElbowArrowData = (
hoveredStartElement hoveredStartElement
? aabbForElement( ? aabbForElement(
hoveredStartElement, hoveredStartElement,
elementsMap,
offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING), offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
) )
: startPointBounds, : startPointBounds,
@@ -1397,8 +1360,8 @@ const getElbowArrowData = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement), hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
hoveredEndElement && aabbForElement(hoveredEndElement), hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -2229,34 +2192,35 @@ const getGlobalPoint = (
fixedPointRatio: [number, number] | undefined | null, fixedPointRatio: [number, number] | undefined | null,
initialPoint: GlobalPoint, initialPoint: GlobalPoint,
element?: ExcalidrawBindableElement | null, element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean, isDragging?: boolean,
): GlobalPoint => { ): GlobalPoint => {
if (isDragging) { if (isDragging) {
if (element) { if (element && elementsMap) {
const snapPoint = bindPointToSnapToElementOutline( return bindPointToSnapToElementOutline(
arrow, arrow,
element, element,
startOrEnd, startOrEnd,
elementsMap,
); );
return snapToMid(element, snapPoint);
} }
return initialPoint; return initialPoint;
} }
if (element) { if (element && elementsMap) {
const fixedGlobalPoint = getGlobalFixedPointForBindableElement( const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
fixedPointRatio || [0, 0], fixedPointRatio || [0, 0],
element, element,
elementsMap,
); );
// NOTE: Resize scales the binding position point too, so we need to update it // NOTE: Resize scales the binding position point too, so we need to update it
return Math.abs( return Math.abs(
distanceToBindableElement(element, fixedGlobalPoint) - distanceToElement(element, elementsMap, fixedGlobalPoint) -
FIXED_BINDING_DISTANCE, FIXED_BINDING_DISTANCE,
) > 0.01 ) > 0.01
? bindPointToSnapToElementOutline(arrow, element, startOrEnd) ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
: fixedGlobalPoint; : fixedGlobalPoint;
} }
@@ -2268,6 +2232,7 @@ const getBindPointHeading = (
otherPoint: GlobalPoint, otherPoint: GlobalPoint,
hoveredElement: ExcalidrawBindableElement | null | undefined, hoveredElement: ExcalidrawBindableElement | null | undefined,
origPoint: GlobalPoint, origPoint: GlobalPoint,
elementsMap: ElementsMap,
): Heading => ): Heading =>
getHeadingForElbowArrowSnap( getHeadingForElbowArrowSnap(
p, p,
@@ -2276,7 +2241,8 @@ const getBindPointHeading = (
hoveredElement && hoveredElement &&
aabbForElement( aabbForElement(
hoveredElement, hoveredElement,
Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ elementsMap,
Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
number, number,
number, number,
number, number,
@@ -2284,6 +2250,7 @@ const getBindPointHeading = (
], ],
), ),
origPoint, origPoint,
elementsMap,
); );
const getHoveredElement = ( const getHoveredElement = (
+5 -4
View File
@@ -95,10 +95,11 @@ const getNodeRelatives = (
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0] type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
) as Readonly<LocalPoint>; ) as Readonly<LocalPoint>;
const heading = headingForPointFromElement(node, aabbForElement(node), [ const heading = headingForPointFromElement(
edgePoint[0] + el.x, node,
edgePoint[1] + el.y, aabbForElement(node, elementsMap),
] as Readonly<GlobalPoint>); [edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
);
acc.push({ acc.push({
relative, relative,
+35 -9
View File
@@ -2,7 +2,7 @@ import { generateNKeysBetween } from "fractional-indexing";
import { arrayToMap } from "@excalidraw/common"; import { arrayToMap } from "@excalidraw/common";
import { mutateElement } from "./mutateElement"; import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement"; import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks"; import { hasBoundTextElement } from "./typeChecks";
@@ -11,6 +11,7 @@ import type {
ExcalidrawElement, ExcalidrawElement,
FractionalIndex, FractionalIndex,
OrderedExcalidrawElement, OrderedExcalidrawElement,
SceneElementsMap,
} from "./types"; } from "./types";
export class InvalidFractionalIndexError extends Error { export class InvalidFractionalIndexError extends Error {
@@ -161,9 +162,15 @@ export const syncMovedIndices = (
// try generatating indices, throws on invalid movedElements // try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
const elementsCandidates = elements.map((x) => const elementsCandidates = elements.map((x) => {
elementsUpdates.has(x) ? { ...x, ...elementsUpdates.get(x) } : x, const elementUpdates = elementsUpdates.get(x);
);
if (elementUpdates) {
return { ...x, index: elementUpdates.index };
}
return x;
});
// ensure next indices are valid before mutation, throws on invalid ones // ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices( validateFractionalIndices(
@@ -177,8 +184,8 @@ export const syncMovedIndices = (
); );
// split mutation so we don't end up in an incosistent state // split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
} catch (e) { } catch (e) {
// fallback to default sync // fallback to default sync
@@ -189,7 +196,7 @@ export const syncMovedIndices = (
}; };
/** /**
* Synchronizes all invalid fractional indices with the array order by mutating passed elements. * Synchronizes all invalid fractional indices within the array order by mutating elements in the passed array.
* *
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself. * WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/ */
@@ -200,13 +207,32 @@ export const syncInvalidIndices = (
const indicesGroups = getInvalidIndicesGroups(elements); const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups); const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) { for (const [element, { index }] of elementsUpdates) {
mutateElement(element, elementsMap, update); mutateElement(element, elementsMap, { index });
} }
return elements as OrderedExcalidrawElement[]; return elements as OrderedExcalidrawElement[];
}; };
/**
* Synchronizes all invalid fractional indices within the array order by creating new instances of elements with corrected indices.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndicesImmutable = (
elements: readonly ExcalidrawElement[],
): SceneElementsMap | undefined => {
const syncedElements = arrayToMap(elements);
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, { index }] of elementsUpdates) {
syncedElements.set(element.id, newElementWith(element, { index }));
}
return syncedElements as SceneElementsMap;
};
/** /**
* Get contiguous groups of indices of passed moved elements. * Get contiguous groups of indices of passed moved elements.
* *
@@ -149,6 +149,7 @@ export class LinearElementEditor {
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean; public readonly elbowed: boolean;
public readonly customLineAngle: number | null;
constructor( constructor(
element: NonDeleted<ExcalidrawLinearElement>, element: NonDeleted<ExcalidrawLinearElement>,
@@ -186,6 +187,7 @@ export class LinearElementEditor {
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed; this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -289,6 +291,7 @@ export class LinearElementEditor {
const { elementId } = linearElementEditor; const { elementId } = linearElementEditor;
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap); const element = LinearElementEditor.getElement(elementId, elementsMap);
let customLineAngle = linearElementEditor.customLineAngle;
if (!element) { if (!element) {
return null; return null;
} }
@@ -329,6 +332,12 @@ export class LinearElementEditor {
const selectedIndex = selectedPointsIndices[0]; const selectedIndex = selectedPointsIndices[0];
const referencePoint = const referencePoint =
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1]; element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
customLineAngle =
linearElementEditor.customLineAngle ??
Math.atan2(
element.points[selectedIndex][1] - referencePoint[1],
element.points[selectedIndex][0] - referencePoint[0],
);
const [width, height] = LinearElementEditor._getShiftLockedDelta( const [width, height] = LinearElementEditor._getShiftLockedDelta(
element, element,
@@ -336,6 +345,7 @@ export class LinearElementEditor {
referencePoint, referencePoint,
pointFrom(scenePointerX, scenePointerY), pointFrom(scenePointerX, scenePointerY),
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(), event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
customLineAngle,
); );
LinearElementEditor.movePoints( LinearElementEditor.movePoints(
@@ -457,6 +467,7 @@ export class LinearElementEditor {
? lastClickedPoint ? lastClickedPoint
: -1, : -1,
isDragging: true, isDragging: true,
customLineAngle,
}; };
} }
@@ -551,6 +562,8 @@ export class LinearElementEditor {
return { return {
...editingLinearElement, ...editingLinearElement,
...bindings, ...bindings,
segmentMidPointHoveredCoords: null,
hoverPointIndex: -1,
// if clicking without previously dragging a point(s), and not holding // if clicking without previously dragging a point(s), and not holding
// shift, deselect all points except the one clicked. If holding shift, // shift, deselect all points except the one clicked. If holding shift,
// toggle the point. // toggle the point.
@@ -572,6 +585,7 @@ export class LinearElementEditor {
: selectedPointsIndices, : selectedPointsIndices,
isDragging: false, isDragging: false,
pointerOffset: { x: 0, y: 0 }, pointerOffset: { x: 0, y: 0 },
customLineAngle: null,
}; };
} }
@@ -1593,6 +1607,7 @@ export class LinearElementEditor {
referencePoint: LocalPoint, referencePoint: LocalPoint,
scenePointer: GlobalPoint, scenePointer: GlobalPoint,
gridSize: NullableGridSize, gridSize: NullableGridSize,
customLineAngle?: number,
) { ) {
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
element, element,
@@ -1618,6 +1633,7 @@ export class LinearElementEditor {
referencePointCoords[1], referencePointCoords[1],
gridX, gridX,
gridY, gridY,
customLineAngle,
); );
return pointRotateRads( return pointRotateRads(
+5 -5
View File
@@ -23,7 +23,7 @@ import type {
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit< export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>, Partial<TElement>,
"id" | "version" | "versionNonce" | "updated" "id" | "updated"
>; >;
/** /**
@@ -137,8 +137,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
ShapeCache.delete(element); ShapeCache.delete(element);
} }
element.version++; element.version = updates.version ?? element.version + 1;
element.versionNonce = randomInteger(); element.versionNonce = updates.versionNonce ?? randomInteger();
element.updated = getUpdatedTimestamp(); element.updated = getUpdatedTimestamp();
return element; return element;
@@ -172,9 +172,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
return { return {
...element, ...element,
...updates, ...updates,
version: updates.version ?? element.version + 1,
versionNonce: updates.versionNonce ?? randomInteger(),
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
version: element.version + 1,
versionNonce: randomInteger(),
}; };
}; };
+2 -1
View File
@@ -291,6 +291,7 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
*/ */
export const aabbForElement = ( export const aabbForElement = (
element: Readonly<ExcalidrawElement>, element: Readonly<ExcalidrawElement>,
elementsMap: ElementsMap,
offset?: [number, number, number, number], offset?: [number, number, number, number],
) => { ) => {
const bbox = { const bbox = {
@@ -302,7 +303,7 @@ export const aabbForElement = (
midY: element.y + element.height / 2, midY: element.y + element.height / 2,
}; };
const center = elementCenterPoint(element); const center = elementCenterPoint(element, elementsMap);
const [topLeftX, topLeftY] = pointRotateRads( const [topLeftX, topLeftY] = pointRotateRads(
pointFrom(bbox.minX, bbox.minY), pointFrom(bbox.minX, bbox.minY),
center, center,
+38 -3
View File
@@ -2,6 +2,12 @@ import {
SHIFT_LOCKING_ANGLE, SHIFT_LOCKING_ANGLE,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
} from "@excalidraw/common"; } from "@excalidraw/common";
import {
normalizeRadians,
radiansBetweenAngles,
radiansDifference,
type Radians,
} from "@excalidraw/math";
import { pointsEqual } from "@excalidraw/math"; import { pointsEqual } from "@excalidraw/math";
@@ -152,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
originY: number, originY: number,
x: number, x: number,
y: number, y: number,
customAngle?: number,
) => { ) => {
let width = x - originX; let width = x - originX;
let height = y - originY; let height = y - originY;
const lockedAngle = const angle = Math.atan2(height, width) as Radians;
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) * let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE; SHIFT_LOCKING_ANGLE) as Radians;
if (customAngle) {
// If custom angle is provided, we check if the angle is close to the
// custom angle, snap to that if close engough, otherwise snap to the
// higher or lower angle depending on the current angle vs custom angle.
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
SHIFT_LOCKING_ANGLE) as Radians;
if (
radiansBetweenAngles(
angle,
lower,
(lower + SHIFT_LOCKING_ANGLE) as Radians,
)
) {
if (
radiansDifference(angle, customAngle as Radians) <
SHIFT_LOCKING_ANGLE / 6
) {
lockedAngle = customAngle as Radians;
} else if (
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
) {
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
} else {
lockedAngle = lower;
}
}
}
if (lockedAngle === 0) { if (lockedAngle === 0) {
height = 0; height = 0;
+78 -48
View File
@@ -19,9 +19,17 @@ import { newElementWith } from "./mutateElement";
import { ElementsDelta, AppStateDelta, Delta } from "./delta"; import { ElementsDelta, AppStateDelta, Delta } from "./delta";
import { hashElementsVersion, hashString } from "./index"; import {
syncInvalidIndicesImmutable,
hashElementsVersion,
hashString,
} from "./index";
import type { OrderedExcalidrawElement, SceneElementsMap } from "./types"; import type {
ExcalidrawElement,
OrderedExcalidrawElement,
SceneElementsMap,
} from "./types";
export const CaptureUpdateAction = { export const CaptureUpdateAction = {
/** /**
@@ -105,7 +113,7 @@ export class Store {
params: params:
| { | {
action: CaptureUpdateActionType; action: CaptureUpdateActionType;
elements: SceneElementsMap | undefined; elements: readonly ExcalidrawElement[] | undefined;
appState: AppState | ObservedAppState | undefined; appState: AppState | ObservedAppState | undefined;
} }
| { | {
@@ -133,9 +141,15 @@ export class Store {
this.app.scene.getElementsMapIncludingDeleted(), this.app.scene.getElementsMapIncludingDeleted(),
this.app.state, this.app.state,
); );
const scheduledSnapshot = currentSnapshot.maybeClone( const scheduledSnapshot = currentSnapshot.maybeClone(
action, action,
params.elements, // let's sync invalid indices first, so that we could detect this change
// also have the synced elements immutable, so that we don't mutate elements,
// that are already in the scene, otherwise we wouldn't see any change
params.elements
? syncInvalidIndicesImmutable(params.elements)
: undefined,
params.appState, params.appState,
); );
@@ -213,16 +227,7 @@ export class Store {
// using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again // using the same instance, since in history we have a check against `HistoryEntry`, so that we don't re-record the same delta again
storeDelta = delta; storeDelta = delta;
} else { } else {
// calculate the deltas based on the previous and next snapshot storeDelta = StoreDelta.calculate(prevSnapshot, snapshot);
const elementsDelta = snapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, snapshot.elements)
: ElementsDelta.empty();
const appStateDelta = snapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, snapshot.appState)
: AppStateDelta.empty();
storeDelta = StoreDelta.create(elementsDelta, appStateDelta);
} }
if (!storeDelta.isEmpty()) { if (!storeDelta.isEmpty()) {
@@ -505,6 +510,24 @@ export class StoreDelta {
return new this(opts.id, elements, appState); return new this(opts.id, elements, appState);
} }
/**
* Calculate the delta between the previous and next snapshot.
*/
public static calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
const elementsDelta = nextSnapshot.metadata.didElementsChange
? ElementsDelta.calculate(prevSnapshot.elements, nextSnapshot.elements)
: ElementsDelta.empty();
const appStateDelta = nextSnapshot.metadata.didAppStateChange
? AppStateDelta.calculate(prevSnapshot.appState, nextSnapshot.appState)
: AppStateDelta.empty();
return this.create(elementsDelta, appStateDelta);
}
/** /**
* Restore a store delta instance from a DTO. * Restore a store delta instance from a DTO.
*/ */
@@ -524,9 +547,7 @@ export class StoreDelta {
id, id,
elements: { added, removed, updated }, elements: { added, removed, updated },
}: DTO<StoreDelta>) { }: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated, { const elements = ElementsDelta.create(added, removed, updated);
shouldRedistribute: false,
});
return new this(id, elements, AppStateDelta.empty()); return new this(id, elements, AppStateDelta.empty());
} }
@@ -534,27 +555,10 @@ export class StoreDelta {
/** /**
* Inverse store delta, creates new instance of `StoreDelta`. * Inverse store delta, creates new instance of `StoreDelta`.
*/ */
public static inverse(delta: StoreDelta): StoreDelta { public static inverse(delta: StoreDelta) {
return this.create(delta.elements.inverse(), delta.appState.inverse()); return this.create(delta.elements.inverse(), delta.appState.inverse());
} }
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
elements: SceneElementsMap,
modifierOptions: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(elements, modifierOptions),
delta.appState,
{
id: delta.id,
},
);
}
/** /**
* Apply the delta to the passed elements and appState, does not modify the snapshot. * Apply the delta to the passed elements and appState, does not modify the snapshot.
*/ */
@@ -562,12 +566,9 @@ export class StoreDelta {
delta: StoreDelta, delta: StoreDelta,
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
prevSnapshot: StoreSnapshot = StoreSnapshot.empty(),
): [SceneElementsMap, AppState, boolean] { ): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo( const [nextElements, elementsContainVisibleChange] =
elements, delta.elements.applyTo(elements);
prevSnapshot.elements,
);
const [nextAppState, appStateContainsVisibleChange] = const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements); delta.appState.applyTo(appState, nextElements);
@@ -578,6 +579,28 @@ export class StoreDelta {
return [nextElements, nextAppState, appliedVisibleChanges]; return [nextElements, nextAppState, appliedVisibleChanges];
} }
/**
* Apply latest (remote) changes to the delta, creates new instance of `StoreDelta`.
*/
public static applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
): StoreDelta {
return this.create(
delta.elements.applyLatestChanges(
prevElements,
nextElements,
modifierOptions,
),
delta.appState,
{
id: delta.id,
},
);
}
public isEmpty() { public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty(); return this.elements.isEmpty() && this.appState.isEmpty();
} }
@@ -687,11 +710,10 @@ export class StoreSnapshot {
nextElements.set(id, changedElement); nextElements.set(id, changedElement);
} }
const nextAppState = Object.assign( const nextAppState = getObservedAppState({
{}, ...this.appState,
this.appState, ...change.appState,
change.appState, });
) as ObservedAppState;
return StoreSnapshot.create(nextElements, nextAppState, { return StoreSnapshot.create(nextElements, nextAppState, {
// by default we assume that change is different from what we have in the snapshot // by default we assume that change is different from what we have in the snapshot
@@ -944,18 +966,26 @@ const getDefaultObservedAppState = (): ObservedAppState => {
}; };
}; };
export const getObservedAppState = (appState: AppState): ObservedAppState => { export const getObservedAppState = (
appState: AppState | ObservedAppState,
): ObservedAppState => {
const observedAppState = { const observedAppState = {
name: appState.name, name: appState.name,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
editingLinearElementId: appState.editingLinearElement?.elementId || null,
selectedLinearElementId: appState.selectedLinearElement?.elementId || null,
croppingElementId: appState.croppingElementId, croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId, activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections, lockedMultiSelections: appState.lockedMultiSelections,
editingLinearElementId:
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
null,
selectedLinearElementId:
(appState as AppState).selectedLinearElement?.elementId ??
(appState as ObservedAppState).selectedLinearElementId ??
null,
}; };
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, { Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
+9
View File
@@ -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 => {
+2 -1
View File
@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawIframeLikeElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameLikeElement | ExcalidrawFrameLikeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement
| ExcalidrawSelectionElement;
/** /**
* ExcalidrawElement should be JSON serializable and (eventually) contain * ExcalidrawElement should be JSON serializable and (eventually) contain
+314 -239
View File
@@ -1,28 +1,168 @@
import { import {
curve, curve,
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment, lineSegment,
pointFrom, pointFrom,
pointFromVector, pointFromArray,
rectangle, rectangle,
vectorFromPoint,
vectorNormalize,
vectorScale,
type GlobalPoint, type GlobalPoint,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { elementCenterPoint } from "@excalidraw/common"; import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
import type { Curve, LineSegment } from "@excalidraw/math";
import { getCornerRadius } from "./shapes"; import { getCornerRadius } from "./shapes";
import { getDiamondPoints } from "./bounds"; import { getDiamondPoints } from "./bounds";
import { generateLinearCollisionShape } from "./Shape";
import type { import type {
ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
const ElementShapesCache = new WeakMap<
ExcalidrawElement,
{ version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
>();
const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
offset: number,
): ElementShape | undefined => {
const record = ElementShapesCache.get(element);
if (!record) {
return undefined;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.delete(element);
return undefined;
}
return shapes.get(offset);
};
const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
element: T,
shape: ElementShape,
offset: number,
) => {
const record = ElementShapesCache.get(element);
if (!record) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
const { version, shapes } = record;
if (version !== element.version) {
ElementShapesCache.set(element, {
version: element.version,
shapes: new Map([[offset, shape]]),
});
return;
}
shapes.set(offset, shape);
};
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
if (cachedShape) {
return cachedShape;
}
const ops = generateLinearCollisionShape(element, elementsMap) as {
op: string;
data: number[];
}[];
const lines = [];
const curves = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "lineTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
lines.push(
lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
),
);
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
curves.push(
curve<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + prevPoint[0],
element.y + prevPoint[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
const shape = [lines, curves] as ElementShape;
setElementShapesCacheEntry(element, shape, 0);
return shape;
}
/** /**
* Get the building components of a rectanguloid element in the form of * Get the building components of a rectanguloid element in the form of
* line segments and curves. * line segments and curves.
@@ -35,175 +175,132 @@ export function deconstructRectanguloidElement(
element: ExcalidrawRectanguloidElement, element: ExcalidrawRectanguloidElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const roundness = getCornerRadius( const cachedShape = getElementShapesCacheEntry(element, offset);
if (cachedShape) {
return cachedShape;
}
let radius = getCornerRadius(
Math.min(element.width, element.height), Math.min(element.width, element.height),
element, element,
); );
if (roundness <= 0) { if (radius === 0) {
const r = rectangle( radius = 0.01;
pointFrom(element.x - offset, element.y - offset),
pointFrom(
element.x + element.width + offset,
element.y + element.height + offset,
),
);
const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
);
const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
);
const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
);
const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
);
const sides = [top, right, bottom, left];
return [sides, []];
} }
const center = elementCenterPoint(element);
const r = rectangle( const r = rectangle(
pointFrom(element.x, element.y), pointFrom(element.x, element.y),
pointFrom(element.x + element.width, element.y + element.height), pointFrom(element.x + element.width, element.y + element.height),
); );
const top = lineSegment<GlobalPoint>( const top = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
); );
const right = lineSegment<GlobalPoint>( const right = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
); );
const bottom = lineSegment<GlobalPoint>( const bottom = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]), pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]), pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
); );
const left = lineSegment<GlobalPoint>( const left = lineSegment<GlobalPoint>(
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness), pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness), pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
); );
const offsets = [ const baseCorners = [
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
),
offset,
), // TOP LEFT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
),
offset,
), //TOP RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM RIGHT
vectorScale(
vectorNormalize(
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
),
offset,
), // BOTTOM LEFT
];
const corners = [
curve( curve(
pointFromVector(offsets[0], left[1]), left[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
pointFrom<GlobalPoint>( left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
pointFrom<GlobalPoint>( top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
),
), ),
pointFromVector(offsets[0], top[0]), top[0],
), // TOP LEFT ), // TOP LEFT
curve( curve(
pointFromVector(offsets[1], top[1]), top[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
pointFrom<GlobalPoint>( top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
pointFrom<GlobalPoint>( right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
),
), ),
pointFromVector(offsets[1], right[0]), right[0],
), // TOP RIGHT ), // TOP RIGHT
curve( curve(
pointFromVector(offsets[2], right[1]), right[1],
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
pointFrom<GlobalPoint>( right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
pointFrom<GlobalPoint>( bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
),
), ),
pointFromVector(offsets[2], bottom[1]), bottom[1],
), // BOTTOM RIGHT ), // BOTTOM RIGHT
curve( curve(
pointFromVector(offsets[3], bottom[0]), bottom[0],
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
pointFrom<GlobalPoint>( bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
),
), ),
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
pointFrom<GlobalPoint>( left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
),
), ),
pointFromVector(offsets[3], left[0]), left[0],
), // BOTTOM LEFT ), // BOTTOM LEFT
]; ];
const sides = [ const corners =
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), offset > 0
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), ? baseCorners.map(
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), (corner) =>
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), curveCatmullRomCubicApproxPoints(
]; curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
return [sides, corners]; const sides = [
lineSegment<GlobalPoint>(
corners[0][corners[0].length - 1][3],
corners[1][0][0],
),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
];
const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
/** /**
@@ -218,42 +315,20 @@ export function deconstructDiamondElement(
element: ExcalidrawDiamondElement, element: ExcalidrawDiamondElement,
offset: number = 0, offset: number = 0,
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] { ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = const cachedShape = getElementShapesCacheEntry(element, offset);
getDiamondPoints(element);
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
if (element.roundness?.type == null) { if (cachedShape) {
const [top, right, bottom, left]: GlobalPoint[] = [ return cachedShape;
pointFrom(element.x + topX, element.y + topY - offset),
pointFrom(element.x + rightX + offset, element.y + rightY),
pointFrom(element.x + bottomX, element.y + bottomY + offset),
pointFrom(element.x + leftX - offset, element.y + leftY),
];
// Create the line segment parts of the diamond
// NOTE: Horizontal and vertical seems to be flipped here
const topRight = lineSegment<GlobalPoint>(
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
);
const bottomRight = lineSegment<GlobalPoint>(
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
);
const bottomLeft = lineSegment<GlobalPoint>(
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
);
const topLeft = lineSegment<GlobalPoint>(
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
);
return [[topRight, bottomRight, bottomLeft, topLeft], []];
} }
const center = elementCenterPoint(element); const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
getDiamondPoints(element);
const verticalRadius = element.roundness
? getCornerRadius(Math.abs(topX - leftX), element)
: (topX - leftX) * 0.01;
const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01;
const [top, right, bottom, left]: GlobalPoint[] = [ const [top, right, bottom, left]: GlobalPoint[] = [
pointFrom(element.x + topX, element.y + topY), pointFrom(element.x + topX, element.y + topY),
@@ -262,94 +337,94 @@ export function deconstructDiamondElement(
pointFrom(element.x + leftX, element.y + leftY), pointFrom(element.x + leftX, element.y + leftY),
]; ];
const offsets = [ const baseCorners = [
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
];
const corners = [
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] - horizontalRadius,
right[0] - verticalRadius,
right[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[0], right), right,
pointFromVector(offsets[0], right), right,
pointFromVector( pointFrom<GlobalPoint>(
offsets[0], right[0] - verticalRadius,
pointFrom<GlobalPoint>( right[1] + horizontalRadius,
right[0] - verticalRadius,
right[1] + horizontalRadius,
),
), ),
), // RIGHT ), // RIGHT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] + verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] + verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
pointFromVector(offsets[1], bottom), bottom,
pointFromVector(offsets[1], bottom), bottom,
pointFromVector( pointFrom<GlobalPoint>(
offsets[1], bottom[0] - verticalRadius,
pointFrom<GlobalPoint>( bottom[1] - horizontalRadius,
bottom[0] - verticalRadius,
bottom[1] - horizontalRadius,
),
), ),
), // BOTTOM ), // BOTTOM
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] + horizontalRadius,
left[0] + verticalRadius,
left[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[2], left), left,
pointFromVector(offsets[2], left), left,
pointFromVector( pointFrom<GlobalPoint>(
offsets[2], left[0] + verticalRadius,
pointFrom<GlobalPoint>( left[1] - horizontalRadius,
left[0] + verticalRadius,
left[1] - horizontalRadius,
),
), ),
), // LEFT ), // LEFT
curve( curve(
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] - verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] - verticalRadius,
top[1] + horizontalRadius,
),
), ),
pointFromVector(offsets[3], top), top,
pointFromVector(offsets[3], top), top,
pointFromVector( pointFrom<GlobalPoint>(
offsets[3], top[0] + verticalRadius,
pointFrom<GlobalPoint>( top[1] + horizontalRadius,
top[0] + verticalRadius,
top[1] + horizontalRadius,
),
), ),
), // TOP ), // TOP
]; ];
const corners =
offset > 0
? baseCorners.map(
(corner) =>
curveCatmullRomCubicApproxPoints(
curveOffsetPoints(corner, offset),
)!,
)
: [
[baseCorners[0]],
[baseCorners[1]],
[baseCorners[2]],
[baseCorners[3]],
];
const sides = [ const sides = [
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]), lineSegment<GlobalPoint>(
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]), corners[0][corners[0].length - 1][3],
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]), corners[1][0][0],
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]), ),
lineSegment<GlobalPoint>(
corners[1][corners[1].length - 1][3],
corners[2][0][0],
),
lineSegment<GlobalPoint>(
corners[2][corners[2].length - 1][3],
corners[3][0][0],
),
lineSegment<GlobalPoint>(
corners[3][corners[3].length - 1][3],
corners[0][0][0],
),
]; ];
return [sides, corners]; const shape = [sides, corners.flat()] as ElementShape;
setElementShapesCacheEntry(element, shape, offset);
return shape;
} }
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg" class="excalidraw-wysiwyg"
data-type="wysiwyg" data-type="wysiwyg"
dir="auto" dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;" style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
tabindex="0" tabindex="0"
wrap="off" wrap="off"
/> />
+10 -2
View File
@@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -202,6 +204,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -215,6 +218,7 @@ describe("aligning", () => {
// Add the created group to the current selection // Add the created group to the current selection
mouse.restorePosition(0, 0); mouse.restorePosition(0, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -316,6 +320,7 @@ describe("aligning", () => {
// The second rectangle is already selected because it was the last element created // The second rectangle is already selected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -330,7 +335,7 @@ describe("aligning", () => {
mouse.down(); mouse.down();
mouse.up(100, 100); mouse.up(100, 100);
mouse.restorePosition(200, 200); mouse.restorePosition(210, 200);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@@ -341,6 +346,7 @@ describe("aligning", () => {
// The second group is already selected because it was the last group created // The second group is already selected because it was the last group created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
@@ -454,6 +460,7 @@ describe("aligning", () => {
// The second rectangle is already reselected because it was the last element created // The second rectangle is already reselected because it was the last element created
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -466,7 +473,7 @@ describe("aligning", () => {
mouse.up(100, 100); mouse.up(100, 100);
// Add group to current selection // Add group to current selection
mouse.restorePosition(0, 0); mouse.restorePosition(10, 0);
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(); mouse.click();
}); });
@@ -482,6 +489,7 @@ describe("aligning", () => {
// Select the nested group, the rectangle is already selected // Select the nested group, the rectangle is already selected
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
}; };
+10 -16
View File
@@ -11,6 +11,10 @@ import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils"; import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
import { getTransformHandles } from "../src/transformHandles"; import { getTransformHandles } from "../src/transformHandles";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
const { h } = window; const { h } = window;
@@ -172,12 +176,12 @@ describe("element binding", () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
x: 0, x: 0,
y: 0, y: 0,
size: 50, size: 49,
}); });
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
mouse.downAt(50, 50); mouse.downAt(49, 49);
mouse.moveTo(51, 0); mouse.moveTo(51, 0);
mouse.up(0, 0); mouse.up(0, 0);
@@ -244,18 +248,12 @@ describe("element binding", () => {
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "" } }); fireEvent.change(editor, { target: { value: "" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding).toBe(null); expect(arrow.endBinding).toBe(null);
}); });
@@ -285,18 +283,14 @@ describe("element binding", () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(text.x + 50, text.y + 50); mouse.clickAt(text.x + 50, text.y + 50);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).not.toBe(null); expect(editor).not.toBe(null);
fireEvent.change(editor, { target: { value: "asdasdasdasdas" } }); fireEvent.change(editor, { target: { value: "asdasdasdasdas" } });
fireEvent.keyDown(editor, { key: KEYS.ESCAPE }); fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
expect( expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
document.querySelector(".excalidraw-textEditorContainer > textarea"),
).toBe(null);
expect(arrow.endBinding?.elementId).toBe(text.id); expect(arrow.endBinding?.elementId).toBe(text.id);
}); });
+38
View File
@@ -0,0 +1,38 @@
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import "@excalidraw/utils/test-utils";
import { render } from "@excalidraw/excalidraw/tests/test-utils";
import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />);
});
it("arrow", () => {
UI.createElement("arrow", {
x: 0,
y: 0,
width: 124,
height: 302,
angle: 1.8700426423973724,
points: [
[0, 0],
[120, -198],
[-4, -302],
] as LocalPoint[],
});
//const p = [120, -211];
//const p = [0, 13];
const hit = hitElementItself({
point: pointFrom<GlobalPoint>(87, -68),
element: window.h.elements[0],
threshold: 10,
elementsMap: window.h.scene.getNonDeletedElementsMap(),
});
expect(hit).toBe(true);
});
});
@@ -505,8 +505,6 @@ describe("group-related duplication", () => {
mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50); mouse.up(frame.x + frame.width + 50, frame.y + frame.height + 50);
}); });
// console.log(h.elements);
assertElements(h.elements, [ assertElements(h.elements, [
{ id: frame.id }, { id: frame.id },
{ id: rectangle1.id, frameId: frame.id }, { id: rectangle1.id, frameId: frame.id },
@@ -1,6 +1,5 @@
import { pointCenter, pointFrom } from "@excalidraw/math"; import { pointCenter, pointFrom } from "@excalidraw/math";
import { act, queryByTestId, queryByText } from "@testing-library/react"; import { act, queryByTestId, queryByText } from "@testing-library/react";
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { import {
@@ -33,6 +32,11 @@ import { getBoundTextElementPosition, getBoundTextMaxWidth } from "../src";
import { LinearElementEditor } from "../src"; import { LinearElementEditor } from "../src";
import { newArrowElement } from "../src"; import { newArrowElement } from "../src";
import {
getTextEditor,
TEXT_EDITOR_SELECTOR,
} from "../../excalidraw/tests/queries/dom";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -252,7 +256,49 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor when using double clicked with ctrl key", () => { it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
// ctrl+enter alias (to align with arrows)
it("should enter line editor via ctrl+enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined(); expect(h.state.editingLinearElement?.elementId).toBeUndefined();
@@ -262,6 +308,37 @@ describe("Test Linear Elements", () => {
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id); expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
}); });
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.editingLinearElement).toEqual(null);
await getTextEditor();
});
it("shouldn't create text element on double click in line editor (arrow)", async () => {
createTwoPointerLinearElement("arrow");
const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
});
describe("Inside editor", () => { describe("Inside editor", () => {
it("should not drag line and add midpoint when dragged irrespective of threshold", () => { it("should not drag line and add midpoint when dragged irrespective of threshold", () => {
createTwoPointerLinearElement("line"); createTwoPointerLinearElement("line");
@@ -292,7 +369,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
expect(line.points.length).toEqual(3); expect(line.points.length).toEqual(3);
expect(line.points).toMatchInlineSnapshot(` expect(line.points).toMatchInlineSnapshot(`
@@ -333,7 +410,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`9`, `9`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints( const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
h.elements[0] as ExcalidrawLinearElement, h.elements[0] as ExcalidrawLinearElement,
@@ -394,7 +471,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect([line.x, line.y]).toEqual([ expect([line.x, line.y]).toEqual([
points[0][0] + deltaX, points[0][0] + deltaX,
@@ -462,7 +539,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
@@ -513,7 +590,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -554,7 +631,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -602,7 +679,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`18`, `18`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
const newMidPoints = LinearElementEditor.getEditorMidPoints( const newMidPoints = LinearElementEditor.getEditorMidPoints(
line, line,
@@ -660,7 +737,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`16`, `16`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5); expect(line.points.length).toEqual(5);
expect((h.elements[0] as ExcalidrawLinearElement).points) expect((h.elements[0] as ExcalidrawLinearElement).points)
@@ -758,7 +835,7 @@ describe("Test Linear Elements", () => {
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`12`, `12`,
); );
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
const newPoints = LinearElementEditor.getPointsGlobalCoordinates( const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
line, line,
@@ -990,12 +1067,10 @@ describe("Test Linear Elements", () => {
}); });
}); });
it("should match styles for text editor", () => { it("should match styles for text editor", async () => {
createTwoPointerLinearElement("arrow"); createTwoPointerLinearElement("arrow");
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
expect(editor).toMatchSnapshot(); expect(editor).toMatchSnapshot();
}); });
@@ -1012,9 +1087,7 @@ describe("Test Linear Elements", () => {
expect(text.type).toBe("text"); expect(text.type).toBe("text");
expect(text.containerId).toBe(arrow.id); expect(text.containerId).toBe(arrow.id);
mouse.down(); mouse.down();
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@@ -1042,9 +1115,7 @@ describe("Test Linear Elements", () => {
const textElement = h.elements[1] as ExcalidrawTextElementWithContainer; const textElement = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(textElement.type).toBe("text"); expect(textElement.type).toBe("text");
expect(textElement.containerId).toBe(arrow.id); expect(textElement.containerId).toBe(arrow.id);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { fireEvent.change(editor, {
target: { value: DEFAULT_TEXT }, target: { value: DEFAULT_TEXT },
@@ -1063,13 +1134,7 @@ describe("Test Linear Elements", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
mouse.doubleClickAt(line.x, line.y); mouse.doubleClickAt(line.x, line.y);
expect(h.elements.length).toBe(1);
expect(h.elements.length).toBe(2);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBeNull();
expect(line.boundElements).toBeNull();
}); });
// TODO fix #7029 and rewrite this test // TODO fix #7029 and rewrite this test
@@ -1234,9 +1299,7 @@ describe("Test Linear Elements", () => {
mouse.select(arrow); mouse.select(arrow);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
const editor = document.querySelector( const editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
) as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: DEFAULT_TEXT } }); fireEvent.change(editor, { target: { value: DEFAULT_TEXT } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@@ -1262,7 +1325,7 @@ describe("Test Linear Elements", () => {
mouse.downAt(rect.x, rect.y); mouse.downAt(rect.x, rect.y);
mouse.moveTo(200, 0); mouse.moveTo(200, 0);
mouse.upAt(200, 0); mouse.upAt(200, 0);
expect(arrow.width).toBeCloseTo(204, 0); expect(arrow.width).toBeCloseTo(200, 0);
expect(rect.x).toBe(200); expect(rect.x).toBe(200);
expect(rect.y).toBe(0); expect(rect.y).toBe(0);
expect(handleBindTextResizeSpy).toHaveBeenCalledWith( expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
@@ -1411,5 +1474,55 @@ describe("Test Linear Elements", () => {
expect(line.points[line.points.length - 1][0]).toBe(20); expect(line.points[line.points.length - 1][0]).toBe(20);
expect(line.points[line.points.length - 1][1]).toBe(-20); expect(line.points[line.points.length - 1][1]).toBe(-20);
}); });
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
createTwoPointerLinearElement("line");
const line = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(line);
const elementsMap = arrayToMap(h.elements);
const points = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate original angle between first and last point
const originalAngle = Math.atan2(
points[1][1] - points[0][1],
points[1][0] - points[0][0],
);
// Drag the second point (endpoint) with SHIFT key pressed
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
const endPoint = pointFrom<GlobalPoint>(
startPoint[0] + 4,
startPoint[1] + 4,
);
// Perform drag with SHIFT key modifier
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.downAt(startPoint[0], startPoint[1]);
mouse.moveTo(endPoint[0], endPoint[1]);
mouse.upAt(endPoint[0], endPoint[1]);
});
// Get updated points after drag
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
line,
elementsMap,
);
// Calculate new angle
const newAngle = Math.atan2(
updatedPoints[1][1] - updatedPoints[0][1],
updatedPoints[1][0] - updatedPoints[0][0],
);
// The angle should be preserved (within a small tolerance for floating point precision)
const angleDifference = Math.abs(newAngle - originalAngle);
const tolerance = 0.01; // Small tolerance for floating point precision
expect(angleDifference).toBeLessThan(tolerance);
});
}); });
}); });
+6 -6
View File
@@ -510,12 +510,12 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]); UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
}); });
@@ -538,11 +538,11 @@ describe("arrow element", () => {
h.state, h.state,
)[0] as ExcalidrawElbowArrowElement; )[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]); UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0); expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
}); });
}); });
@@ -819,7 +819,7 @@ describe("image element", () => {
UI.resize(image, "ne", [40, 0]); UI.resize(image, "ne", [40, 0]);
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0); expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
const imageWidth = image.width; const imageWidth = image.width;
const scale = 20 / image.height; const scale = 20 / image.height;
@@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.x).toBeCloseTo(-110);
expect(leftBoundArrow.y).toBeCloseTo(50); expect(leftBoundArrow.y).toBeCloseTo(50);
expect(leftBoundArrow.width).toBeCloseTo(143, 0); expect(leftBoundArrow.width).toBeCloseTo(140, 0);
expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0);
expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.angle).toEqual(0);
expect(leftBoundArrow.startBinding).toBeNull(); expect(leftBoundArrow.startBinding).toBeNull();
@@ -1,7 +1,5 @@
import { vi } from "vitest"; import { vi } from "vitest";
import * as constants from "@excalidraw/common";
import { getPerfectElementSize } from "../src/sizeHelpers"; import { getPerfectElementSize } from "../src/sizeHelpers";
const EPSILON_DIGITS = 3; const EPSILON_DIGITS = 3;
@@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
expect(width).toBeCloseTo(0, EPSILON_DIGITS); expect(width).toBeCloseTo(0, EPSILON_DIGITS);
expect(height).toBeCloseTo(0, EPSILON_DIGITS); expect(height).toBeCloseTo(0, EPSILON_DIGITS);
}); });
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
const { height, width } = getPerfectElementSize("arrow", 120, 185);
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
});
});
}); });
@@ -122,18 +122,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();
} }
@@ -280,7 +268,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,
@@ -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";
@@ -1661,63 +1658,16 @@ export const actionChangeArrowType = register({
-1, -1,
elementsMap, elementsMap,
); );
const startHoveredElement = const startElement =
!newElement.startBinding && newElement.startBinding &&
getHoveredElementForBinding( (elementsMap.get(
tupleToCoors(startGlobalPoint), newElement.startBinding.elementId,
elements, ) as ExcalidrawBindableElement);
elementsMap, const endElement =
appState.zoom, newElement.endBinding &&
false, (elementsMap.get(
true, newElement.endBinding.elementId,
); ) as ExcalidrawBindableElement);
const endHoveredElement =
!newElement.endBinding &&
getHoveredElementForBinding(
tupleToCoors(endGlobalPoint),
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
: newElement.startBinding &&
(elementsMap.get(
newElement.startBinding.elementId,
) as ExcalidrawBindableElement);
const endElement = endHoveredElement
? endHoveredElement
: newElement.endBinding &&
(elementsMap.get(
newElement.endBinding.elementId,
) as ExcalidrawBindableElement);
const finalStartPoint = startHoveredElement
? bindPointToSnapToElementOutline(
newElement,
startHoveredElement,
"start",
)
: startGlobalPoint;
const finalEndPoint = endHoveredElement
? bindPointToSnapToElementOutline(
newElement,
endHoveredElement,
"end",
)
: endGlobalPoint;
startHoveredElement &&
bindLinearElement(
newElement,
startHoveredElement,
"start",
app.scene,
);
endHoveredElement &&
bindLinearElement(newElement, endHoveredElement, "end", app.scene);
const startBinding = const startBinding =
startElement && newElement.startBinding startElement && newElement.startBinding
@@ -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
+2 -3
View File
@@ -10,6 +10,7 @@ import {
STATS_PANELS, STATS_PANELS,
THEME, THEME,
DEFAULT_GRID_STEP, DEFAULT_GRID_STEP,
isTestEnv,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { AppState, NormalizedZoomValue } from "./types"; import type { AppState, NormalizedZoomValue } from "./types";
@@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness, currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
currentItemStartArrowhead: null, currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor, currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round", currentItemRoundness: isTestEnv() ? "sharp" : "round",
currentItemArrowType: ARROW_TYPE.round, currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle, currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth, currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
@@ -108,7 +109,6 @@ export const getDefaultAppState = (): Omit<
value: 1 as NormalizedZoomValue, value: 1 as NormalizedZoomValue,
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElementId: null,
showHyperlinkPopup: false, showHyperlinkPopup: false,
selectedLinearElement: null, selectedLinearElement: null,
snapLines: [], snapLines: [],
@@ -237,7 +237,6 @@ const APP_STATE_STORAGE_CONF = (<
zenModeEnabled: { browser: true, export: false, server: false }, zenModeEnabled: { browser: true, export: false, server: false },
zoom: { browser: true, export: false, server: false }, zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false }, selectedLinearElement: { browser: true, export: false, server: false },
snapLines: { browser: false, export: false, server: false }, snapLines: { browser: false, export: false, server: false },
+3 -1
View File
@@ -2,7 +2,8 @@
.undo-redo-buttons { .undo-redo-buttons {
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
box-shadow: 0 0 0 1px var(--color-surface-lowest); // box-shadow: 0 0 0 1px var(--color-surface-lowest);
backdrop-filter: blur(40px);
} }
.zoom-button, .zoom-button,
@@ -12,6 +13,7 @@
font-size: 0.875rem !important; font-size: 0.875rem !important;
width: var(--lg-button-size); width: var(--lg-button-size);
height: var(--lg-button-size); height: var(--lg-button-size);
backdrop-filter: blur(40px);
svg { svg {
width: var(--lg-icon-size) !important; width: var(--lg-icon-size) !important;
@@ -352,7 +352,6 @@ export const ShapesSwitcher = ({
if (value === "image") { if (value === "image") {
app.setActiveTool({ app.setActiveTool({
type: value, type: value,
insertOnCanvasDirectly: pointerType !== "mouse",
}); });
} else { } else {
app.setActiveTool({ type: value }); app.setActiveTool({ type: value });
File diff suppressed because it is too large Load Diff
@@ -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 &&
@@ -3,6 +3,8 @@
--padding: 0; --padding: 0;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--island-bg-color); background-color: var(--island-bg-color);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
box-shadow: var(--shadow-island); box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg); border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor)); padding: calc(var(--padding) * var(--space-factor));
@@ -297,6 +297,10 @@ export const SearchMenu = () => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (app.state.openDialog) {
return;
}
if (!searchInputRef.current?.matches(":focus")) { if (!searchInputRef.current?.matches(":focus")) {
if (app.state.openDialog) { if (app.state.openDialog) {
setAppState({ setAppState({
@@ -133,7 +133,6 @@ describe("binding with linear elements", () => {
const inputX = UI.queryStatsProperty("X")?.querySelector( const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input", ".drag-input",
) as HTMLInputElement; ) as HTMLInputElement;
expect(linear.startBinding).not.toBe(null); expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull(); expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204")); UI.updateInput(inputX, String("204"));
@@ -382,8 +381,7 @@ describe("stats for a non-generic element", () => {
it("text element", async () => { it("text element", async () => {
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { act(() => {
editor.blur(); editor.blur();
@@ -576,8 +574,7 @@ describe("stats for multiple elements", () => {
// text, rectangle, frame // text, rectangle, frame
UI.clickTool("text"); UI.clickTool("text");
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const textEditorSelector = ".excalidraw-textEditorContainer > textarea"; const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector, true);
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { act(() => {
editor.blur(); editor.blur();
@@ -657,6 +654,7 @@ describe("stats for multiple elements", () => {
mouse.reset(); mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => { Keyboard.withModifierKeys({ shift: true }, () => {
mouse.moveTo(10, 0);
mouse.click(); mouse.click();
}); });
@@ -198,7 +198,6 @@ const getRelevantAppStateProps = (
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
selectionElement: appState.selectionElement, selectionElement: appState.selectionElement,
selectedGroupIds: appState.selectedGroupIds, selectedGroupIds: appState.selectedGroupIds,
selectedLinearElement: appState.selectedLinearElement, selectedLinearElement: appState.selectedLinearElement,
@@ -100,7 +100,6 @@ const getRelevantAppStateProps = (appState: AppState): StaticCanvasAppState => {
offsetLeft: appState.offsetLeft, offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop, offsetTop: appState.offsetTop,
theme: appState.theme, theme: appState.theme,
pendingImageElementId: appState.pendingImageElementId,
shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: appState.shouldCacheIgnoreZoom,
viewBackgroundColor: appState.viewBackgroundColor, viewBackgroundColor: appState.viewBackgroundColor,
exportScale: appState.exportScale, exportScale: appState.exportScale,
@@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value; const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box // hitbox to prevent hiding when hovered in element bounding box
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) { if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
return false; return false;
} }
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
@@ -92,7 +92,7 @@ export const isPointHittingLink = (
if ( if (
!isMobile && !isMobile &&
appState.viewModeEnabled && appState.viewModeEnabled &&
hitElementBoundingBox(x, y, element, elementsMap) hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
) { ) {
return true; return true;
} }
+7 -2
View File
@@ -386,6 +386,9 @@ body.excalidraw-cursor-resize * {
width: 12.5rem; width: 12.5rem;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
background: transparent !important;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
} }
.dropdown-select { .dropdown-select {
@@ -648,14 +651,16 @@ body.excalidraw-cursor-resize * {
.App-mobile-menu, .App-mobile-menu,
.App-menu__left { .App-menu__left {
--button-border: transparent; --button-border: transparent;
--button-bg: var(--color-surface-mid); // --button-bg: var(--color-surface-mid);
--button-bg: rgba(255, 255, 255, 0.3);
} }
@at-root .excalidraw.theme--dark#{&} { @at-root .excalidraw.theme--dark#{&} {
.App-mobile-menu, .App-mobile-menu,
.App-menu__left { .App-menu__left {
--button-hover-bg: #363541; --button-hover-bg: #363541;
--button-bg: var(--color-surface-high); // --button-bg: var(--color-surface-high);
--button-bg: rgba(0, 0, 0, 0.15);
} }
} }
+6 -7
View File
@@ -19,7 +19,7 @@
--input-border-color: #{$oc-gray-4}; --input-border-color: #{$oc-gray-4};
--input-hover-bg-color: #{$oc-gray-1}; --input-hover-bg-color: #{$oc-gray-1};
--input-label-color: #{$oc-gray-7}; --input-label-color: #{$oc-gray-7};
--island-bg-color: #ffffff; --island-bg-color: transparent !important;
--keybinding-color: var(--color-gray-40); --keybinding-color: var(--color-gray-40);
--link-color: #{$oc-blue-7}; --link-color: #{$oc-blue-7};
--overlay-bg-color: #{transparentize($oc-white, 0.12)}; --overlay-bg-color: #{transparentize($oc-white, 0.12)};
@@ -149,16 +149,16 @@
--color-surface-high: #f1f0ff; --color-surface-high: #f1f0ff;
--color-surface-mid: #f6f6f9; --color-surface-mid: #f6f6f9;
--color-surface-low: #ececf4; --color-surface-low: rgba(236, 236, 244, 0.1);
--color-surface-lowest: #ffffff; --color-surface-lowest: #ffffff;
--color-on-surface: #1b1b1f; --color-on-surface: #1b1b1f;
--color-brand-hover: #5753d0; --color-brand-hover: #5753d0;
--color-on-primary-container: #030064; --color-on-primary-container: #030064;
--color-surface-primary-container: #e0dfff; --color-surface-primary-container: rgba(224, 223, 255, 0.7);
--color-brand-active: #4440bf; --color-brand-active: #4440bf;
--color-border-outline: #767680; --color-border-outline: #767680;
--color-border-outline-variant: #c5c5d0; --color-border-outline-variant: #c5c5d0;
--color-surface-primary-container: #e0dfff; // --color-surface-primary-container: #e0dfff;
--color-badge: #0b6513; --color-badge: #0b6513;
--background-color-badge: #d3ffd2; --background-color-badge: #d3ffd2;
@@ -253,16 +253,15 @@
--color-logo-text: #e2dfff; --color-logo-text: #e2dfff;
--color-surface-high: #2e2d39; --color-surface-high: #2e2d39;
--color-surface-low: hsl(240, 8%, 15%); --color-surface-low: rgba(35, 35, 41, 0.1);
--color-surface-mid: hsl(240 6% 10%); --color-surface-mid: hsl(240 6% 10%);
--color-surface-lowest: hsl(0, 0%, 7%); --color-surface-lowest: hsl(0, 0%, 7%);
--color-on-surface: #e3e3e8; --color-on-surface: #e3e3e8;
--color-brand-hover: #bbb8ff; --color-brand-hover: #bbb8ff;
--color-on-primary-container: #e0dfff; --color-on-primary-container: #e0dfff;
--color-surface-primary-container: #403e6a; --color-surface-primary-container: rgba(64, 62, 106, 0.7);
--color-brand-active: #d0ccff; --color-brand-active: #d0ccff;
--color-border-outline: #8e8d9c; --color-border-outline: #8e8d9c;
--color-border-outline-variant: #46464f; --color-border-outline-variant: #46464f;
--color-surface-primary-container: #403e6a;
} }
} }
@@ -79,6 +79,10 @@
color: var(--button-color, var(--color-on-surface)); color: var(--button-color, var(--color-on-surface));
font-family: var(--ui-font); font-family: var(--ui-font);
// background: transparent !important;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
svg { svg {
width: var(--button-width, var(--lg-icon-size)); width: var(--button-width, var(--lg-icon-size));
height: var(--button-height, var(--lg-icon-size)); height: var(--button-height, var(--lg-icon-size));
@@ -183,6 +187,8 @@
border: none; border: none;
box-shadow: 0 0 0 1px var(--color-surface-lowest); box-shadow: 0 0 0 1px var(--color-surface-lowest);
background-color: var(--color-surface-low); background-color: var(--color-surface-low);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
&:active { &:active {
box-shadow: 0 0 0 1px var(--color-brand-active); box-shadow: 0 0 0 1px var(--color-brand-active);
@@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"startBinding": { "startBinding": {
"elementId": "diamond-1", "elementId": "diamond-1",
"focus": 0, "focus": 0,
"gap": 4.545343408287929, "gap": 4.535423522449215,
}, },
"strokeColor": "#e67700", "strokeColor": "#e67700",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 14, "gap": 16,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -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,
+1 -1
View File
@@ -781,7 +781,7 @@ describe("Test Transform", () => {
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: -0, focus: -0,
gap: 14, gap: 25,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {
+39 -70
View File
@@ -1,25 +1,19 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common"; import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import { getElementLineSegments } from "@excalidraw/element";
import { import {
lineSegment, computeBoundTextPosition,
lineSegmentIntersectionPoints, getBoundTextElement,
pointFrom, intersectElementWithLineSegment,
} from "@excalidraw/math"; isPointInElement,
} from "@excalidraw/element";
import { lineSegment, pointFrom } from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element"; import { getElementsInGroup } from "@excalidraw/element";
import { getElementShape } from "@excalidraw/element";
import { shouldTestInside } from "@excalidraw/element"; import { shouldTestInside } from "@excalidraw/element";
import { isPointInShape } from "@excalidraw/utils/collision";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element"; import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element"; import { getBoundTextElementId } from "@excalidraw/element";
import type { GeometricShape } from "@excalidraw/utils/shape"; import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type {
ElementsSegmentsMap,
GlobalPoint,
LineSegment,
} from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { AnimatedTrail } from "../animated-trail"; import { AnimatedTrail } from "../animated-trail";
@@ -28,15 +22,9 @@ import type { AnimationFrameHandler } from "../animation-frame-handler";
import type App from "../components/App"; import type App from "../components/App";
// just enough to form a segment; this is sufficient for eraser
const POINTS_ON_TRAIL = 2;
export class EraserTrail extends AnimatedTrail { export class EraserTrail extends AnimatedTrail {
private elementsToErase: Set<ExcalidrawElement["id"]> = new Set(); private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
private groupsToErase: Set<ExcalidrawElement["id"]> = new Set(); private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
new Map();
constructor(animationFrameHandler: AnimationFrameHandler, app: App) { constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
super(animationFrameHandler, app, { super(animationFrameHandler, app, {
@@ -79,14 +67,21 @@ export class EraserTrail extends AnimatedTrail {
} }
private updateElementsToBeErased(restoreToErase?: boolean) { private updateElementsToBeErased(restoreToErase?: boolean) {
let eraserPath: GlobalPoint[] = const eraserPath: GlobalPoint[] =
super super
.getCurrentTrail() .getCurrentTrail()
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || []; ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
if (eraserPath.length < 2) {
return [];
}
// for efficiency and avoid unnecessary calculations, // for efficiency and avoid unnecessary calculations,
// take only POINTS_ON_TRAIL points to form some number of segments // take only POINTS_ON_TRAIL points to form some number of segments
eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL); const pathSegment = lineSegment<GlobalPoint>(
eraserPath[eraserPath.length - 1],
eraserPath[eraserPath.length - 2],
);
const candidateElements = this.app.visibleElements.filter( const candidateElements = this.app.visibleElements.filter(
(el) => !el.locked, (el) => !el.locked,
@@ -94,28 +89,13 @@ export class EraserTrail extends AnimatedTrail {
const candidateElementsMap = arrayToMap(candidateElements); const candidateElementsMap = arrayToMap(candidateElements);
const pathSegments = eraserPath.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
acc.push(lineSegment(eraserPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
if (pathSegments.length === 0) {
return [];
}
for (const element of candidateElements) { for (const element of candidateElements) {
// restore only if already added to the to-be-erased set // restore only if already added to the to-be-erased set
if (restoreToErase && this.elementsToErase.has(element.id)) { if (restoreToErase && this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@@ -148,12 +128,9 @@ export class EraserTrail extends AnimatedTrail {
} }
} else if (!restoreToErase && !this.elementsToErase.has(element.id)) { } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
const intersects = eraserTest( const intersects = eraserTest(
pathSegments, pathSegment,
element, element,
this.segmentsCache,
this.geometricShapesCache,
candidateElementsMap, candidateElementsMap,
this.app,
); );
if (intersects) { if (intersects) {
@@ -196,45 +173,37 @@ export class EraserTrail extends AnimatedTrail {
super.clearTrails(); super.clearTrails();
this.elementsToErase.clear(); this.elementsToErase.clear();
this.groupsToErase.clear(); this.groupsToErase.clear();
this.segmentsCache.clear();
} }
} }
const eraserTest = ( const eraserTest = (
pathSegments: LineSegment<GlobalPoint>[], pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap,
shapesCache: Map<string, GeometricShape<GlobalPoint>>,
elementsMap: ElementsMap, elementsMap: ElementsMap,
app: App,
): boolean => { ): boolean => {
let shape = shapesCache.get(element.id); const lastPoint = pathSegment[1];
if (
if (!shape) { shouldTestInside(element) &&
shape = getElementShape<GlobalPoint>(element, elementsMap); isPointInElement(lastPoint, element, elementsMap)
shapesCache.set(element.id, shape); ) {
}
const lastPoint = pathSegments[pathSegments.length - 1][1];
if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
return true; return true;
} }
let elementSegments = elementsSegments.get(element.id); const boundTextElement = getBoundTextElement(element, elementsMap);
if (!elementSegments) { return (
elementSegments = getElementLineSegments(element, elementsMap); intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
elementsSegments.set(element.id, elementSegments); .length > 0 ||
} (!!boundTextElement &&
intersectElementWithLineSegment(
return pathSegments.some((pathSegment) => {
elementSegments?.some( ...boundTextElement,
(elementSegment) => ...computeBoundTextPosition(element, boundTextElement, elementsMap),
lineSegmentIntersectionPoints( },
pathSegment, elementsMap,
elementSegment, pathSegment,
app.getElementHitThreshold(), 0,
) !== null, true,
), ).length > 0)
); );
}; };
+108 -45
View File
@@ -4,14 +4,81 @@ import {
CaptureUpdateAction, CaptureUpdateAction,
StoreChange, StoreChange,
StoreDelta, StoreDelta,
type Store,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { StoreSnapshot, Store } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types"; import type { SceneElementsMap } from "@excalidraw/element/types";
import type { AppState } from "./types"; import type { AppState } from "./types";
class HistoryEntry extends StoreDelta {} export class HistoryDelta extends StoreDelta {
/**
* Apply the delta to the passed elements and appState, does not modify the snapshot.
*/
public applyTo(
elements: SceneElementsMap,
appState: AppState,
snapshot: StoreSnapshot,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = this.elements.applyTo(
elements,
// used to fallback into local snapshot in case we couldn't apply the delta
// due to a missing (force deleted) elements in the scene
snapshot.elements,
// we don't want to apply the `version` and `versionNonce` properties for history
// as we always need to end up with a new version due to collaboration,
// approaching each undo / redo as a new user action
{
excludedProperties: new Set(["version", "versionNonce"]),
},
);
const [nextAppState, appStateContainsVisibleChange] = this.appState.applyTo(
appState,
nextElements,
);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override calculate(
prevSnapshot: StoreSnapshot,
nextSnapshot: StoreSnapshot,
) {
return super.calculate(prevSnapshot, nextSnapshot) as HistoryDelta;
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override inverse(delta: StoreDelta): HistoryDelta {
return super.inverse(delta) as HistoryDelta;
}
/**
* Overriding once to avoid type casting everywhere.
*/
public static override applyLatestChanges(
delta: StoreDelta,
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
modifierOptions?: "deleted" | "inserted",
) {
return super.applyLatestChanges(
delta,
prevElements,
nextElements,
modifierOptions,
) as HistoryDelta;
}
}
export class HistoryChangedEvent { export class HistoryChangedEvent {
constructor( constructor(
@@ -25,8 +92,8 @@ export class History {
[HistoryChangedEvent] [HistoryChangedEvent]
>(); >();
public readonly undoStack: HistoryEntry[] = []; public readonly undoStack: HistoryDelta[] = [];
public readonly redoStack: HistoryEntry[] = []; public readonly redoStack: HistoryDelta[] = [];
public get isUndoStackEmpty() { public get isUndoStackEmpty() {
return this.undoStack.length === 0; return this.undoStack.length === 0;
@@ -48,16 +115,16 @@ export class History {
* Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action. * Do not re-record history entries, which were already pushed to undo / redo stack, as part of history action.
*/ */
public record(delta: StoreDelta) { public record(delta: StoreDelta) {
if (delta.isEmpty() || delta instanceof HistoryEntry) { if (delta.isEmpty() || delta instanceof HistoryDelta) {
return; return;
} }
// construct history entry, so once it's emitted, it's not recorded again // construct history entry, so once it's emitted, it's not recorded again
const entry = HistoryEntry.inverse(delta); const historyDelta = HistoryDelta.inverse(delta);
this.undoStack.push(entry); this.undoStack.push(historyDelta);
if (!entry.elements.isEmpty()) { if (!historyDelta.elements.isEmpty()) {
// don't reset redo stack on local appState changes, // don't reset redo stack on local appState changes,
// as a simple click (unselect) could lead to losing all the redo entries // as a simple click (unselect) could lead to losing all the redo entries
// only reset on non empty elements changes! // only reset on non empty elements changes!
@@ -74,7 +141,7 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.undoStack), () => History.pop(this.undoStack),
(entry: HistoryEntry) => History.push(this.redoStack, entry, elements), (entry: HistoryDelta) => History.push(this.redoStack, entry),
); );
} }
@@ -83,20 +150,20 @@ export class History {
elements, elements,
appState, appState,
() => History.pop(this.redoStack), () => History.pop(this.redoStack),
(entry: HistoryEntry) => History.push(this.undoStack, entry, elements), (entry: HistoryDelta) => History.push(this.undoStack, entry),
); );
} }
private perform( private perform(
elements: SceneElementsMap, elements: SceneElementsMap,
appState: AppState, appState: AppState,
pop: () => HistoryEntry | null, pop: () => HistoryDelta | null,
push: (entry: HistoryEntry) => void, push: (entry: HistoryDelta) => void,
): [SceneElementsMap, AppState] | void { ): [SceneElementsMap, AppState] | void {
try { try {
let historyEntry = pop(); let historyDelta = pop();
if (historyEntry === null) { if (historyDelta === null) {
return; return;
} }
@@ -108,41 +175,47 @@ export class History {
let nextAppState = appState; let nextAppState = appState;
let containsVisibleChange = false; let containsVisibleChange = false;
// iterate through the history entries in case they result in no visible changes // iterate through the history entries in case ;they result in no visible changes
while (historyEntry) { while (historyDelta) {
try { try {
[nextElements, nextAppState, containsVisibleChange] = [nextElements, nextAppState, containsVisibleChange] =
StoreDelta.applyTo( historyDelta.applyTo(nextElements, nextAppState, prevSnapshot);
historyEntry,
nextElements,
nextAppState,
prevSnapshot,
);
const prevElements = prevSnapshot.elements;
const nextSnapshot = prevSnapshot.maybeClone( const nextSnapshot = prevSnapshot.maybeClone(
action, action,
nextElements, nextElements,
nextAppState, nextAppState,
); );
// schedule immediate capture, so that it's emitted for the sync purposes const change = StoreChange.create(prevSnapshot, nextSnapshot);
this.store.scheduleMicroAction({ const delta = HistoryDelta.applyLatestChanges(
action, historyDelta,
change: StoreChange.create(prevSnapshot, nextSnapshot), prevElements,
delta: historyEntry, nextElements,
}); );
if (!delta.isEmpty()) {
// schedule immediate capture, so that it's emitted for the sync purposes
this.store.scheduleMicroAction({
action,
change,
delta,
});
historyDelta = delta;
}
prevSnapshot = nextSnapshot; prevSnapshot = nextSnapshot;
} finally { } finally {
// make sure to always push, even if the delta is corrupted push(historyDelta);
push(historyEntry);
} }
if (containsVisibleChange) { if (containsVisibleChange) {
break; break;
} }
historyEntry = pop(); historyDelta = pop();
} }
return [nextElements, nextAppState]; return [nextElements, nextAppState];
@@ -155,7 +228,7 @@ export class History {
} }
} }
private static pop(stack: HistoryEntry[]): HistoryEntry | null { private static pop(stack: HistoryDelta[]): HistoryDelta | null {
if (!stack.length) { if (!stack.length) {
return null; return null;
} }
@@ -169,18 +242,8 @@ export class History {
return null; return null;
} }
private static push( private static push(stack: HistoryDelta[], entry: HistoryDelta) {
stack: HistoryEntry[], const inversedEntry = HistoryDelta.inverse(entry);
entry: HistoryEntry, return stack.push(inversedEntry);
prevElements: SceneElementsMap,
) {
const inversedEntry = HistoryEntry.inverse(entry);
const updatedEntry = HistoryEntry.applyLatestChanges(
inversedEntry,
prevElements,
"inserted",
);
return stack.push(updatedEntry);
} }
} }
+1
View File
@@ -199,6 +199,7 @@ export class LassoTrail extends AnimatedTrail {
const { selectedElementIds } = getLassoSelectedElementIds({ const { selectedElementIds } = getLassoSelectedElementIds({
lassoPath, lassoPath,
elements: this.app.visibleElements, elements: this.app.visibleElements,
elementsMap: this.app.scene.getNonDeletedElementsMap(),
elementsSegments: this.elementsSegments, elementsSegments: this.elementsSegments,
intersectedElements: this.intersectedElements, intersectedElements: this.intersectedElements,
enclosedElements: this.enclosedElements, enclosedElements: this.enclosedElements,
+58 -26
View File
@@ -3,20 +3,25 @@ import { simplify } from "points-on-curve";
import { import {
polygonFromPoints, polygonFromPoints,
lineSegment, lineSegment,
lineSegmentIntersectionPoints,
polygonIncludesPointNonZero, polygonIncludesPointNonZero,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import {
ElementsSegmentsMap, type Bounds,
GlobalPoint, computeBoundTextPosition,
LineSegment, doBoundsIntersect,
} from "@excalidraw/math/types"; getBoundTextElement,
import type { ExcalidrawElement } from "@excalidraw/element/types"; getElementBounds,
intersectElementWithLineSegment,
} from "@excalidraw/element";
import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
export const getLassoSelectedElementIds = (input: { export const getLassoSelectedElementIds = (input: {
lassoPath: GlobalPoint[]; lassoPath: GlobalPoint[];
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
elementsMap: ElementsMap;
elementsSegments: ElementsSegmentsMap; elementsSegments: ElementsSegmentsMap;
intersectedElements: Set<ExcalidrawElement["id"]>; intersectedElements: Set<ExcalidrawElement["id"]>;
enclosedElements: Set<ExcalidrawElement["id"]>; enclosedElements: Set<ExcalidrawElement["id"]>;
@@ -27,6 +32,7 @@ export const getLassoSelectedElementIds = (input: {
const { const {
lassoPath, lassoPath,
elements, elements,
elementsMap,
elementsSegments, elementsSegments,
intersectedElements, intersectedElements,
enclosedElements, enclosedElements,
@@ -40,8 +46,26 @@ export const getLassoSelectedElementIds = (input: {
const unlockedElements = elements.filter((el) => !el.locked); const unlockedElements = elements.filter((el) => !el.locked);
// as the path might not enclose a shape anymore, clear before checking // as the path might not enclose a shape anymore, clear before checking
enclosedElements.clear(); enclosedElements.clear();
intersectedElements.clear();
const lassoBounds = lassoPath.reduce(
(acc, item) => {
return [
Math.min(acc[0], item[0]),
Math.min(acc[1], item[1]),
Math.max(acc[2], item[0]),
Math.max(acc[3], item[1]),
];
},
[Infinity, Infinity, -Infinity, -Infinity],
) as Bounds;
for (const element of unlockedElements) { for (const element of unlockedElements) {
// First check if the lasso segment intersects the element's axis-aligned
// bounding box as it is much faster than checking intersection against
// the element's shape
const elementBounds = getElementBounds(element, elementsMap);
if ( if (
doBoundsIntersect(lassoBounds, elementBounds) &&
!intersectedElements.has(element.id) && !intersectedElements.has(element.id) &&
!enclosedElements.has(element.id) !enclosedElements.has(element.id)
) { ) {
@@ -49,7 +73,7 @@ export const getLassoSelectedElementIds = (input: {
if (enclosed) { if (enclosed) {
enclosedElements.add(element.id); enclosedElements.add(element.id);
} else { } else {
const intersects = intersectionTest(path, element, elementsSegments); const intersects = intersectionTest(path, element, elementsMap);
if (intersects) { if (intersects) {
intersectedElements.add(element.id); intersectedElements.add(element.id);
} }
@@ -85,26 +109,34 @@ const enclosureTest = (
const intersectionTest = ( const intersectionTest = (
lassoPath: GlobalPoint[], lassoPath: GlobalPoint[],
element: ExcalidrawElement, element: ExcalidrawElement,
elementsSegments: ElementsSegmentsMap, elementsMap: ElementsMap,
): boolean => { ): boolean => {
const elementSegments = elementsSegments.get(element.id); const lassoSegments = lassoPath
if (!elementSegments) { .slice(1)
return false; .map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
} .concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
const lassoSegments = lassoPath.reduce((acc, point, index) => { const boundTextElement = getBoundTextElement(element, elementsMap);
if (index === 0) {
return acc;
}
acc.push(lineSegment(lassoPath[index - 1], point));
return acc;
}, [] as LineSegment<GlobalPoint>[]);
return lassoSegments.some((lassoSegment) => return lassoSegments.some(
elementSegments.some( (lassoSegment) =>
(elementSegment) => intersectElementWithLineSegment(
// introduce a bit of tolerance to account for roughness and simplification of paths element,
lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null, elementsMap,
), lassoSegment,
0,
true,
).length > 0 ||
(!!boundTextElement &&
intersectElementWithLineSegment(
{
...boundTextElement,
...computeBoundTextPosition(element, boundTextElement, elementsMap),
},
elementsMap,
lassoSegment,
0,
true,
).length > 0),
); );
}; };
+1 -1
View File
@@ -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",
+96 -160
View File
@@ -5,17 +5,14 @@ import { getDiamondPoints } from "@excalidraw/element";
import { getCornerRadius } from "@excalidraw/element"; import { getCornerRadius } from "@excalidraw/element";
import { import {
bezierEquation,
curve, curve,
curveTangent, curveCatmullRomCubicApproxPoints,
curveCatmullRomQuadraticApproxPoints,
curveOffsetPoints,
type GlobalPoint, type GlobalPoint,
offsetPointsForQuadraticBezier,
pointFrom, pointFrom,
pointFromVector,
pointRotateRads, pointRotateRads,
vector,
vectorNormal,
vectorNormalize,
vectorScale,
} from "@excalidraw/math"; } from "@excalidraw/math";
import type { import type {
@@ -102,25 +99,14 @@ export const bootstrapCanvas = ({
function drawCatmullRomQuadraticApprox( function drawCatmullRomQuadraticApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < pointSets.length - 1; i++) {
const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
for (let i = 0; i < points.length - 1; i++) { ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
const p0 = points[i - 1 < 0 ? 0 : i - 1];
const p1 = points[i];
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const x =
(1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
const y =
(1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
ctx.lineTo(x, y);
} }
} }
} }
@@ -128,35 +114,13 @@ function drawCatmullRomQuadraticApprox(
function drawCatmullRomCubicApprox( function drawCatmullRomCubicApprox(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
points: GlobalPoint[], points: GlobalPoint[],
segments = 20, tension = 0.5,
) { ) {
ctx.lineTo(points[0][0], points[0][1]); const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
if (pointSets) {
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < pointSets.length; i++) {
const p0 = points[i - 1 < 0 ? 0 : i - 1]; const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
const p1 = points[i]; ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
for (let t = 0; t <= 1; t += 1 / segments) {
const t2 = t * t;
const t3 = t2 * t;
const x =
0.5 *
(2 * p1[0] +
(-p0[0] + p2[0]) * t +
(2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
(-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
const y =
0.5 *
(2 * p1[1] +
(-p0[1] + p2[1]) * t +
(2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
(-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
ctx.lineTo(x, y);
} }
} }
} }
@@ -168,7 +132,10 @@ export const drawHighlightForRectWithRotation = (
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(
element,
window.h.app.scene.getElementsMapIncludingDeleted(),
),
element.angle, element.angle,
); );
@@ -187,25 +154,25 @@ export const drawHighlightForRectWithRotation = (
context.beginPath(); context.beginPath();
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
padding, padding,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width, radius), pointFrom(element.width, radius),
padding, padding,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
padding, padding,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(radius, element.height), pointFrom(radius, element.height),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
@@ -230,25 +197,25 @@ export const drawHighlightForRectWithRotation = (
// mask" on a filled shape for the diamond highlight, because stroking creates // mask" on a filled shape for the diamond highlight, because stroking creates
// sharp inset edges on line joins < 90 degrees. // sharp inset edges on line joins < 90 degrees.
{ {
const topLeftApprox = offsetQuadraticBezier( const topLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0 + radius, 0), pointFrom(0 + radius, 0),
pointFrom(0, 0), pointFrom(0, 0),
pointFrom(0, 0 + radius), pointFrom(0, 0 + radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const topRightApprox = offsetQuadraticBezier( const topRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width, radius), pointFrom(element.width, radius),
pointFrom(element.width, 0), pointFrom(element.width, 0),
pointFrom(element.width - radius, 0), pointFrom(element.width - radius, 0),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomRightApprox = offsetQuadraticBezier( const bottomRightApprox = offsetPointsForQuadraticBezier(
pointFrom(element.width - radius, element.height), pointFrom(element.width - radius, element.height),
pointFrom(element.width, element.height), pointFrom(element.width, element.height),
pointFrom(element.width, element.height - radius), pointFrom(element.width, element.height - radius),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomLeftApprox = offsetQuadraticBezier( const bottomLeftApprox = offsetPointsForQuadraticBezier(
pointFrom(0, element.height - radius), pointFrom(0, element.height - radius),
pointFrom(0, element.height), pointFrom(0, element.height),
pointFrom(radius, element.height), pointFrom(radius, element.height),
@@ -325,7 +292,10 @@ export const drawHighlightForDiamondWithRotation = (
) => { ) => {
const [x, y] = pointRotateRads( const [x, y] = pointRotateRads(
pointFrom<GlobalPoint>(element.x, element.y), pointFrom<GlobalPoint>(element.x, element.y),
elementCenterPoint(element), elementCenterPoint(
element,
window.h.app.scene.getElementsMapIncludingDeleted(),
),
element.angle, element.angle,
); );
context.save(); context.save();
@@ -343,32 +313,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX - verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX - verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX + verticalRadius, topY + horizontalRadius),
),
padding, padding,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
),
padding, padding,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
),
padding, padding,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
),
padding, padding,
); );
@@ -376,13 +354,13 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
@@ -398,32 +376,40 @@ export const drawHighlightForDiamondWithRotation = (
const horizontalRadius = element.roundness const horizontalRadius = element.roundness
? getCornerRadius(Math.abs(rightY - topY), element) ? getCornerRadius(Math.abs(rightY - topY), element)
: (rightY - topY) * 0.01; : (rightY - topY) * 0.01;
const topApprox = offsetCubicBezier( const topApprox = curveOffsetPoints(
pointFrom(topX + verticalRadius, topY + horizontalRadius), curve(
pointFrom(topX, topY), pointFrom(topX + verticalRadius, topY + horizontalRadius),
pointFrom(topX, topY), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius), pointFrom(topX, topY),
pointFrom(topX - verticalRadius, topY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const rightApprox = offsetCubicBezier( const rightApprox = curveOffsetPoints(
pointFrom(rightX - verticalRadius, rightY + horizontalRadius), curve(
pointFrom(rightX, rightY), pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
pointFrom(rightX, rightY), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius), pointFrom(rightX, rightY),
pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const bottomApprox = offsetCubicBezier( const bottomApprox = curveOffsetPoints(
pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius), curve(
pointFrom(bottomX, bottomY), pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
pointFrom(bottomX, bottomY), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius), pointFrom(bottomX, bottomY),
pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
const leftApprox = offsetCubicBezier( const leftApprox = curveOffsetPoints(
pointFrom(leftX + verticalRadius, leftY - horizontalRadius), curve(
pointFrom(leftX, leftY), pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
pointFrom(leftX, leftY), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius), pointFrom(leftX, leftY),
pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
),
-FIXED_BINDING_DISTANCE, -FIXED_BINDING_DISTANCE,
); );
@@ -431,66 +417,16 @@ export const drawHighlightForDiamondWithRotation = (
topApprox[topApprox.length - 1][0], topApprox[topApprox.length - 1][0],
topApprox[topApprox.length - 1][1], topApprox[topApprox.length - 1][1],
); );
context.lineTo(leftApprox[0][0], leftApprox[0][1]); context.lineTo(leftApprox[1][0], leftApprox[1][1]);
drawCatmullRomCubicApprox(context, leftApprox); drawCatmullRomCubicApprox(context, leftApprox);
context.lineTo(bottomApprox[0][0], bottomApprox[0][1]); context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
drawCatmullRomCubicApprox(context, bottomApprox); drawCatmullRomCubicApprox(context, bottomApprox);
context.lineTo(rightApprox[0][0], rightApprox[0][1]); context.lineTo(rightApprox[1][0], rightApprox[1][1]);
drawCatmullRomCubicApprox(context, rightApprox); drawCatmullRomCubicApprox(context, rightApprox);
context.lineTo(topApprox[0][0], topApprox[0][1]); context.lineTo(topApprox[1][0], topApprox[1][1]);
drawCatmullRomCubicApprox(context, topApprox); drawCatmullRomCubicApprox(context, topApprox);
} }
context.closePath(); context.closePath();
context.fill(); context.fill();
context.restore(); context.restore();
}; };
function offsetCubicBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
p3: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const c = curve(p0, p1, p2, p3);
const point = bezierEquation(c, t);
const tangent = vectorNormalize(curveTangent(c, t));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
function offsetQuadraticBezier(
p0: GlobalPoint,
p1: GlobalPoint,
p2: GlobalPoint,
offsetDist: number,
steps = 20,
) {
const offsetPoints = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const t1 = 1 - t;
const point = pointFrom<GlobalPoint>(
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
);
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
const tangent = vectorNormalize(vector(tangentX, tangentY));
const normal = vectorNormal(tangent);
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
}
return offsetPoints;
}
@@ -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":
@@ -216,10 +210,13 @@ const renderBindingHighlightForBindableElement = (
case "diamond": case "diamond":
drawHighlightForDiamondWithRotation(context, padding, element); drawHighlightForDiamondWithRotation(context, padding, element);
break; break;
case "ellipse": case "ellipse": {
context.lineWidth = const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
maxBindingGap(element, element.width, element.height, zoom) - const width = x2 - x1;
FIXED_BINDING_DISTANCE; const height = y2 - y1;
context.strokeStyle = "rgba(0,0,0,.05)";
context.lineWidth = padding - FIXED_BINDING_DISTANCE;
strokeEllipseWithRotation( strokeEllipseWithRotation(
context, context,
@@ -230,6 +227,7 @@ const renderBindingHighlightForBindableElement = (
element.angle, element.angle,
); );
break; break;
}
} }
}; };
-15
View File
@@ -1,5 +1,4 @@
import { isElementInViewport } from "@excalidraw/element"; import { isElementInViewport } from "@excalidraw/element";
import { isImageElement } from "@excalidraw/element";
import { memoize, toBrandedType } from "@excalidraw/common"; import { memoize, toBrandedType } from "@excalidraw/common";
@@ -72,25 +71,14 @@ export class Renderer {
elements, elements,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
}: { }: {
elements: readonly NonDeletedExcalidrawElement[]; elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"]; editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined; newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
}) => { }) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map()); const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
for (const element of elements) { for (const element of elements) {
if (isImageElement(element)) {
if (
// => not placed on canvas yet (but in elements array)
pendingImageElementId === element.id
) {
continue;
}
}
if (newElementId === element.id) { if (newElementId === element.id) {
continue; continue;
} }
@@ -119,7 +107,6 @@ export class Renderer {
width, width,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
// cache-invalidation nonce // cache-invalidation nonce
sceneNonce: _sceneNonce, sceneNonce: _sceneNonce,
}: { }: {
@@ -134,7 +121,6 @@ export class Renderer {
/** note: first render of newElement will always bust the cache /** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */ * (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined; newElementId: ExcalidrawElement["id"] | undefined;
pendingImageElementId: AppState["pendingImageElementId"];
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>; sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => { }) => {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
@@ -143,7 +129,6 @@ export class Renderer {
elements, elements,
editingTextElement, editingTextElement,
newElementId, newElementId,
pendingImageElementId,
}); });
const visibleElements = getVisibleCanvasElements({ const visibleElements = getVisibleCanvasElements({
@@ -102,14 +102,14 @@ describe("Test <MermaidToExcalidraw/>", () => {
expect(dialog).not.toBeNull(); expect(dialog).not.toBeNull();
const selector = ".ttd-dialog-input"; const selector = ".ttd-dialog-input";
let editor = await getTextEditor(selector, true); let editor = await getTextEditor({ selector, waitForEditor: true });
expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull(); expect(dialog.querySelector('[data-testid="mermaid-error"]')).toBeNull();
expect(editor.textContent).toMatchSnapshot(); expect(editor.textContent).toMatchSnapshot();
updateTextEditor(editor, "flowchart TD1"); updateTextEditor(editor, "flowchart TD1");
editor = await getTextEditor(selector, false); editor = await getTextEditor({ selector, waitForEditor: false });
expect(editor.textContent).toBe("flowchart TD1"); expect(editor.textContent).toBe("flowchart TD1");
expect( expect(
File diff suppressed because it is too large Load Diff
@@ -71,9 +71,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -107,9 +105,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"locked": false, "locked": false,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": 1278240551, "seed": 1278240551,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
@@ -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",
+10 -10
View File
@@ -110,8 +110,8 @@ describe("contextMenu element", () => {
it("shows context menu for element", () => { it("shows context menu for element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -304,8 +304,8 @@ describe("contextMenu element", () => {
it("selecting 'Copy styles' in context menu copies styles", () => { it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -389,8 +389,8 @@ describe("contextMenu element", () => {
it("selecting 'Delete' in context menu deletes element", () => { it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -405,8 +405,8 @@ describe("contextMenu element", () => {
it("selecting 'Add to library' in context menu adds element to library", async () => { it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -424,8 +424,8 @@ describe("contextMenu element", () => {
it("selecting 'Duplicate' in context menu duplicates element", () => { it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle"); UI.clickTool("rectangle");
mouse.down(10, 10); mouse.down(0, 0);
mouse.up(20, 20); mouse.up(10, 10);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, { fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2, button: 2,
@@ -31,9 +31,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
], ],
], ],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 2,
},
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
@@ -193,9 +191,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
], ],
"pressures": [], "pressures": [],
"roughness": 1, "roughness": 1,
"roundness": { "roundness": null,
"type": 3,
},
"seed": Any<Number>, "seed": Any<Number>,
"simulatePressure": true, "simulatePressure": true,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
@@ -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);
+4 -7
View File
@@ -1,4 +1,3 @@
import React from "react";
import { vi } from "vitest"; import { vi } from "vitest";
import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common"; import { ROUNDNESS, KEYS, arrayToMap, cloneJSON } from "@excalidraw/common";
@@ -37,6 +36,8 @@ import {
waitFor, waitFor,
} from "./test-utils"; } from "./test-utils";
import { getTextEditor } from "./queries/dom";
import type { NormalizedZoomValue } from "../types"; import type { NormalizedZoomValue } from "../types";
const { h } = window; const { h } = window;
@@ -846,9 +847,7 @@ describe("mutliple elements", () => {
}); });
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
let editor = document.querySelector<HTMLTextAreaElement>( let editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "arrow" } }); fireEvent.input(editor, { target: { value: "arrow" } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
@@ -860,9 +859,7 @@ describe("mutliple elements", () => {
}); });
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
editor = document.querySelector<HTMLTextAreaElement>( editor = await getTextEditor();
".excalidraw-textEditorContainer > textarea",
)!;
fireEvent.input(editor, { target: { value: "rect\ntext" } }); fireEvent.input(editor, { target: { value: "rect\ntext" } });
Keyboard.exitTextEditor(editor); Keyboard.exitTextEditor(editor);
+9 -1
View File
@@ -499,13 +499,21 @@ export class API {
value: { value: {
files, files,
getData: (type: string) => { getData: (type: string) => {
if (type === blob.type) { if (type === blob.type || type === "text") {
return text; return text;
} }
return ""; return "";
}, },
types: [blob.type],
}, },
}); });
Object.defineProperty(fileDropEvent, "clientX", {
value: 0,
});
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent); await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
}; };
@@ -31,3 +31,30 @@ export const mockMermaidToExcalidraw = (opts: {
}); });
} }
}; };
// Mock for HTMLImageElement (use with `vi.unstubAllGlobals()`)
// as jsdom.resources: "usable" throws an error on image load
export const mockHTMLImageElement = (
naturalWidth: number,
naturalHeight: number,
) => {
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
Object.defineProperty(this, "naturalWidth", {
value: naturalWidth,
});
Object.defineProperty(this, "naturalHeight", {
value: naturalHeight,
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};
+25 -8
View File
@@ -32,10 +32,11 @@ import type {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import { createTestHook } from "../../components/App"; import { createTestHook } from "../../components/App";
import { getTextEditor } from "../queries/dom"; import { getTextEditor, TEXT_EDITOR_SELECTOR } from "../queries/dom";
import { act, fireEvent, GlobalTestState, screen } from "../test-utils"; import { act, fireEvent, GlobalTestState, screen } from "../test-utils";
import { API } from "./api"; import { API } from "./api";
@@ -146,6 +147,7 @@ export class Keyboard {
const getElementPointForSelection = ( const getElementPointForSelection = (
element: ExcalidrawElement, element: ExcalidrawElement,
elementsMap: ElementsMap,
): GlobalPoint => { ): GlobalPoint => {
const { x, y, width, angle } = element; const { x, y, width, angle } = element;
const target = pointFrom<GlobalPoint>( const target = pointFrom<GlobalPoint>(
@@ -162,7 +164,7 @@ const getElementPointForSelection = (
(bounds[1] + bounds[3]) / 2, (bounds[1] + bounds[3]) / 2,
); );
} else { } else {
center = elementCenterPoint(element); center = elementCenterPoint(element, elementsMap);
} }
if (isTextElement(element)) { if (isTextElement(element)) {
@@ -299,7 +301,12 @@ export class Pointer {
elements = Array.isArray(elements) ? elements : [elements]; elements = Array.isArray(elements) ? elements : [elements];
elements.forEach((element) => { elements.forEach((element) => {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
}); });
}); });
@@ -308,13 +315,23 @@ export class Pointer {
clickOn(element: ExcalidrawElement) { clickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.click(...getElementPointForSelection(element)); this.click(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
doubleClickOn(element: ExcalidrawElement) { doubleClickOn(element: ExcalidrawElement) {
this.reset(); this.reset();
this.doubleClick(...getElementPointForSelection(element)); this.doubleClick(
...getElementPointForSelection(
element,
h.app.scene.getElementsMapIncludingDeleted(),
),
);
this.reset(); this.reset();
} }
} }
@@ -532,16 +549,15 @@ export class UI {
static async editText< static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer, T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) { >(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor = const openedEditor =
document.querySelector<HTMLTextAreaElement>(textEditorSelector); document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
if (!openedEditor) { if (!openedEditor) {
mouse.select(element); mouse.select(element);
Keyboard.keyPress(KEYS.ENTER); Keyboard.keyPress(KEYS.ENTER);
} }
const editor = await getTextEditor(textEditorSelector); const editor = await getTextEditor();
if (!editor) { if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom"); throw new Error("Can't find wysiwyg text editor in the dom");
} }
@@ -598,6 +614,7 @@ export class UI {
const mutations = cropElement( const mutations = cropElement(
element, element,
h.scene.getNonDeletedElementsMap(),
handle, handle,
naturalWidth, naturalWidth,
naturalHeight, naturalHeight,
+244 -1
View File
@@ -19,6 +19,7 @@ import {
COLOR_PALETTE, COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX, DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
DEFAULT_ELEMENT_STROKE_COLOR_INDEX, DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
reseed,
} from "@excalidraw/common"; } from "@excalidraw/common";
import "@excalidraw/utils/test-utils"; import "@excalidraw/utils/test-utils";
@@ -35,6 +36,7 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FileId,
FixedPointBinding, FixedPointBinding,
FractionalIndex, FractionalIndex,
SceneElementsMap, SceneElementsMap,
@@ -49,12 +51,16 @@ import {
} from "../actions"; } from "../actions";
import { createUndoAction, createRedoAction } from "../actions/actionHistory"; import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import * as StaticScene from "../renderer/staticScene";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import * as StaticScene from "../renderer/staticScene"; import { createPasteEvent } from "../clipboard";
import * as blobModule from "../data/blob";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui"; import { Keyboard, Pointer, UI } from "./helpers/ui";
import { mockHTMLImageElement } from "./helpers/mocks";
import { import {
GlobalTestState, GlobalTestState,
act, act,
@@ -63,6 +69,7 @@ import {
togglePopover, togglePopover,
getCloneByOrigId, getCloneByOrigId,
checkpointHistory, checkpointHistory,
unmountComponent,
} from "./test-utils"; } from "./test-utils";
import type { AppState } from "../types"; import type { AppState } from "../types";
@@ -106,7 +113,22 @@ const violet = COLOR_PALETTE.violet[DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX];
describe("history", () => { describe("history", () => {
beforeEach(() => { beforeEach(() => {
unmountComponent();
renderStaticScene.mockClear(); renderStaticScene.mockClear();
vi.clearAllMocks();
vi.unstubAllGlobals();
reseed(7);
const generateIdSpy = vi.spyOn(blobModule, "generateIdFromFile");
const resizeFileSpy = vi.spyOn(blobModule, "resizeImageFile");
generateIdSpy.mockImplementation(() => Promise.resolve("fileId" as FileId));
resizeFileSpy.mockImplementation((file: File) => Promise.resolve(file));
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
}); });
afterEach(() => { afterEach(() => {
@@ -559,6 +581,227 @@ describe("history", () => {
]); ]);
}); });
it("should create new history entry on image drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const deerImageDimensions = {
width: 318,
height: 335,
};
mockHTMLImageElement(
deerImageDimensions.width,
deerImageDimensions.height,
);
await API.drop(await API.loadFile("./fixtures/deer.png"));
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...deerImageDimensions,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...deerImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...deerImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link drag&drop", async () => {
await render(<Excalidraw handleKeyboardGlobally={true} />);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
await API.drop(
new Blob([link], {
type: MIME_TYPES.text,
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should create new history entry on image paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
const smileyImageDimensions = {
width: 56,
height: 77,
};
mockHTMLImageElement(
smileyImageDimensions.width,
smileyImageDimensions.height,
);
document.dispatchEvent(
createPasteEvent({
files: [await API.loadFile("./fixtures/smiley_embedded_v2.png")],
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
...smileyImageDimensions,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: true,
...smileyImageDimensions,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
isDeleted: false,
...smileyImageDimensions,
}),
]);
});
it("should create new history entry on embeddable link paste", async () => {
await render(
<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />,
);
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
document.dispatchEvent(
createPasteEvent({
types: {
"text/plain": link,
},
}),
);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
}),
]);
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: true,
}),
]);
Keyboard.redo();
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "embeddable",
link,
isDeleted: false,
}),
]);
});
it("should support appstate name or viewBackgroundColor change", async () => { it("should support appstate name or viewBackgroundColor change", async () => {
await render( await render(
<Excalidraw <Excalidraw
+1
View File
@@ -70,6 +70,7 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => {
?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ?? ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ??
[], [],
elements: h.elements, elements: h.elements,
elementsMap: h.scene.getNonDeletedElementsMap(),
elementsSegments, elementsSegments,
intersectedElements: new Set(), intersectedElements: new Set(),
enclosedElements: new Set(), enclosedElements: new Set(),
+2 -2
View File
@@ -124,8 +124,8 @@ describe("move element", () => {
expect(h.state.selectedElementIds[rectB.id]).toBeTruthy(); expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
expect([rectA.x, rectA.y]).toEqual([0, 0]); expect([rectA.x, rectA.y]).toEqual([0, 0]);
expect([rectB.x, rectB.y]).toEqual([201, 2]); expect([rectB.x, rectB.y]).toEqual([201, 2]);
expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]); expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]); expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
h.elements.forEach((element) => expect(element).toMatchSnapshot()); h.elements.forEach((element) => expect(element).toMatchSnapshot());
}); });
+23 -5
View File
@@ -1,13 +1,31 @@
import { waitFor } from "@testing-library/dom"; import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (selector: string, waitForEditor = true) => { import {
const query = () => document.querySelector(selector) as HTMLTextAreaElement; stripIgnoredNodesFromErrorMessage,
if (waitForEditor) { trimErrorStack,
await waitFor(() => expect(query()).not.toBe(null)); } from "../test-utils";
export const TEXT_EDITOR_SELECTOR =
".excalidraw-textEditorContainer > textarea";
export const getTextEditor = async ({
selector = TEXT_EDITOR_SELECTOR,
waitForEditor = true,
}: { selector?: string; waitForEditor?: boolean } = {}) => {
const error = trimErrorStack(new Error());
try {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));
return query();
}
return query(); return query();
} catch (err: any) {
stripIgnoredNodesFromErrorMessage(err);
err.stack = error.stack;
throw err;
} }
return query();
}; };
export const updateTextEditor = ( export const updateTextEditor = (
+1 -1
View File
@@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
expect(arrow.x).toBeCloseTo(-80); expect(arrow.x).toBeCloseTo(-80);
expect(arrow.y).toBeCloseTo(50); expect(arrow.y).toBeCloseTo(50);
expect(arrow.width).toBeCloseTo(116.7, 1); expect(arrow.width).toBeCloseTo(110.7, 1);
expect(arrow.height).toBeCloseTo(0); expect(arrow.height).toBeCloseTo(0);
}); });
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -426,7 +426,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
@@ -470,7 +470,7 @@ describe("select single element on the scene", () => {
fireEvent.pointerUp(canvas); fireEvent.pointerUp(canvas);
expect(renderInteractiveScene).toHaveBeenCalledTimes(8); expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
expect(renderStaticScene).toHaveBeenCalledTimes(7); expect(renderStaticScene).toHaveBeenCalledTimes(6);
expect(h.state.selectionElement).toBeNull(); expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(1); expect(h.elements.length).toEqual(1);
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy(); expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
+33 -14
View File
@@ -421,11 +421,7 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
.join(", ")}]\n`, .join(", ")}]\n`,
)}`; )}`;
const error = new Error(errStr); throw trimErrorStack(new Error(errStr), 1);
const stack = err.stack.split("\n");
stack.splice(1, 1);
error.stack = stack.join("\n");
throw error;
} }
expect(mappedActualElements).toEqual( expect(mappedActualElements).toEqual(
@@ -435,12 +431,17 @@ export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
expect(h.state.selectedElementIds).toEqual(selectedElementIds); expect(h.state.selectedElementIds).toEqual(selectedElementIds);
}; };
const stripSeed = (deltas: Record<string, { deleted: any; inserted: any }>) => const stripProps = (
deltas: Record<string, { deleted: any; inserted: any }>,
props: string[],
) =>
Object.entries(deltas).reduce((acc, curr) => { Object.entries(deltas).reduce((acc, curr) => {
const { inserted, deleted, ...rest } = curr[1]; const { inserted, deleted, ...rest } = curr[1];
delete inserted.seed; for (const prop of props) {
delete deleted.seed; delete inserted[prop];
delete deleted[prop];
}
acc[curr[0]] = { acc[curr[0]] = {
inserted, inserted,
@@ -457,9 +458,9 @@ export const checkpointHistory = (history: History, name: string) => {
...x, ...x,
elements: { elements: {
...x.elements, ...x.elements,
added: stripSeed(x.elements.added), added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripSeed(x.elements.removed), removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripSeed(x.elements.updated), updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
}, },
})), })),
).toMatchSnapshot(`[${name}] undo stack`); ).toMatchSnapshot(`[${name}] undo stack`);
@@ -469,10 +470,28 @@ export const checkpointHistory = (history: History, name: string) => {
...x, ...x,
elements: { elements: {
...x.elements, ...x.elements,
added: stripSeed(x.elements.added), added: stripProps(x.elements.added, ["seed", "versionNonce"]),
removed: stripSeed(x.elements.removed), removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
updated: stripSeed(x.elements.updated), updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
}, },
})), })),
).toMatchSnapshot(`[${name}] redo stack`); ).toMatchSnapshot(`[${name}] redo stack`);
}; };
/**
* removes one or more leading stack trace lines (leading to files) from the
* error stack trace
*/
export const trimErrorStack = (error: Error, range = 1) => {
const stack = error.stack?.split("\n");
if (stack) {
stack.splice(1, range);
error.stack = stack.join("\n");
}
return error;
};
export const stripIgnoredNodesFromErrorMessage = (error: Error) => {
error.message = error.message.replace(/\s+Ignored nodes:[\s\S]+/, "");
return error;
};
+3 -4
View File
@@ -24,7 +24,6 @@ import type {
ChartType, ChartType,
FontFamilyValues, FontFamilyValues,
FileId, FileId,
ExcalidrawImageElement,
Theme, Theme,
StrokeRoundness, StrokeRoundness,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
@@ -191,7 +190,6 @@ type _CommonCanvasAppState = {
offsetLeft: AppState["offsetLeft"]; offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"]; offsetTop: AppState["offsetTop"];
theme: AppState["theme"]; theme: AppState["theme"];
pendingImageElementId: AppState["pendingImageElementId"];
}; };
export type StaticCanvasAppState = Readonly< export type StaticCanvasAppState = Readonly<
@@ -416,8 +414,6 @@ export interface AppState {
shown: true; shown: true;
data: Spreadsheet; data: Spreadsheet;
}; };
/** imageElement waiting to be placed on canvas */
pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor"; showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null; selectedLinearElement: LinearElementEditor | null;
snapLines: readonly SnapLine[]; snapLines: readonly SnapLine[];
@@ -813,6 +809,9 @@ export interface ExcalidrawImperativeAPI {
getSceneElementsIncludingDeleted: InstanceType< getSceneElementsIncludingDeleted: InstanceType<
typeof App typeof App
>["getSceneElementsIncludingDeleted"]; >["getSceneElementsIncludingDeleted"];
getSceneElementsMapIncludingDeleted: InstanceType<
typeof App
>["getSceneElementsMapIncludingDeleted"];
history: { history: {
clear: InstanceType<typeof App>["resetHistory"]; clear: InstanceType<typeof App>["resetHistory"];
}; };
@@ -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!");
+36 -10
View File
@@ -8,16 +8,10 @@ import type {
Radians, Radians,
} from "./types"; } from "./types";
// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI export const normalizeRadians = (angle: Radians): Radians =>
export const normalizeRadians = (angle: Radians): Radians => { angle < 0
if (angle < 0) { ? (((angle % (2 * Math.PI)) + 2 * Math.PI) as Radians)
return (angle + 2 * Math.PI) as Radians; : ((angle % (2 * Math.PI)) as Radians);
}
if (angle >= 2 * Math.PI) {
return (angle - 2 * Math.PI) as Radians;
}
return angle;
};
/** /**
* Return the polar coordinates for the given cartesian point represented by * Return the polar coordinates for the given cartesian point represented by
@@ -49,3 +43,35 @@ export function radiansToDegrees(degrees: Radians): Degrees {
export function isRightAngleRads(rads: Radians): boolean { export function isRightAngleRads(rads: Radians): boolean {
return Math.abs(Math.sin(2 * rads)) < PRECISION; return Math.abs(Math.sin(2 * rads)) < PRECISION;
} }
export function radiansBetweenAngles(
a: Radians,
min: Radians,
max: Radians,
): boolean {
a = normalizeRadians(a);
min = normalizeRadians(min);
max = normalizeRadians(max);
if (min < max) {
return a >= min && a <= max;
}
// The range wraps around the 0 angle
return a >= min || a <= max;
}
export function radiansDifference(a: Radians, b: Radians): Radians {
a = normalizeRadians(a);
b = normalizeRadians(b);
let diff = a - b;
if (diff < -Math.PI) {
diff = (diff + 2 * Math.PI) as Radians;
} else if (diff > Math.PI) {
diff = (diff - 2 * Math.PI) as Radians;
}
return Math.abs(diff) as Radians;
}
+117 -14
View File
@@ -1,8 +1,7 @@
import type { Bounds } from "@excalidraw/element"; import { doBoundsIntersect, type Bounds } from "@excalidraw/element";
import { isPoint, pointDistance, pointFrom } from "./point"; import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
import { vector } from "./vector";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
@@ -105,16 +104,15 @@ 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 // Optimize by doing a cheap bounding box check first
const bounds = curveBounds(c); const b1 = curveBounds(c);
if ( const b2 = [
rectangleIntersectLineSegment( Math.min(l[0][0], l[1][0]),
rectangle( Math.min(l[0][1], l[1][1]),
pointFrom(bounds[0], bounds[1]), Math.max(l[0][0], l[1][0]),
pointFrom(bounds[2], bounds[3]), Math.max(l[0][1], l[1][1]),
), ] as Bounds;
l,
).length === 0 if (!doBoundsIntersect(b1, b2)) {
) {
return []; return [];
} }
@@ -303,3 +301,108 @@ function curveBounds<Point extends GlobalPoint | LocalPoint>(
const y = [P0[1], P1[1], P2[1], P3[1]]; const y = [P0[1], P1[1], P2[1], P3[1]];
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)]; return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
} }
export function curveCatmullRomQuadraticApproxPoints(
points: GlobalPoint[],
tension = 0.5,
) {
if (points.length < 2) {
return;
}
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;
}
+1
View File
@@ -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";
+12 -2
View File
@@ -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;
} }
/** /**
-135
View File
@@ -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,
-90
View File
@@ -1,90 +0,0 @@
import {
curve,
degreesToRadians,
lineSegment,
lineSegmentRotate,
pointFrom,
pointRotateDegs,
} from "@excalidraw/math";
import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
import { pointOnCurve, pointOnPolyline } from "../src/collision";
import type { Polyline } from "../src/shape";
describe("point and curve", () => {
const c: Curve<GlobalPoint> = curve(
pointFrom(1.4, 1.65),
pointFrom(1.9, 7.9),
pointFrom(5.9, 1.65),
pointFrom(6.44, 4.84),
);
it("point on curve", () => {
expect(pointOnCurve(c[0], c, 10e-5)).toBe(true);
expect(pointOnCurve(c[3], c, 10e-5)).toBe(true);
expect(pointOnCurve(pointFrom(2, 4), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(4, 4.4), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(5.6, 3.85), c, 0.1)).toBe(true);
expect(pointOnCurve(pointFrom(5.6, 4), c, 0.1)).toBe(false);
expect(pointOnCurve(c[1], c, 0.1)).toBe(false);
expect(pointOnCurve(c[2], c, 0.1)).toBe(false);
});
});
describe("point and polylines", () => {
const polyline: Polyline<GlobalPoint> = [
lineSegment(pointFrom(1, 0), pointFrom(1, 2)),
lineSegment(pointFrom(1, 2), pointFrom(2, 2)),
lineSegment(pointFrom(2, 2), pointFrom(2, 1)),
lineSegment(pointFrom(2, 1), pointFrom(3, 1)),
];
it("point on the line", () => {
expect(pointOnPolyline(pointFrom(1, 0), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(1, 2), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 2), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(3, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(1, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2, 1.5), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(2.5, 1), polyline)).toBe(true);
expect(pointOnPolyline(pointFrom(0, 1), polyline)).toBe(false);
expect(pointOnPolyline(pointFrom(2.1, 1.5), polyline)).toBe(false);
});
it("point on the line with rotation", () => {
const truePoints = [
pointFrom(1, 0),
pointFrom(1, 2),
pointFrom(2, 2),
pointFrom(2, 1),
pointFrom(3, 1),
];
truePoints.forEach((p) => {
const rotation = (Math.random() * 360) as Degrees;
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
const rotatedPolyline = polyline.map((line) =>
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
);
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
});
const falsePoints = [pointFrom(0, 1), pointFrom(2.1, 1.5)];
falsePoints.forEach((p) => {
const rotation = (Math.random() * 360) as Degrees;
const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
const rotatedPolyline = polyline.map((line) =>
lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
);
expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
});
});
});