Merge branch 'master' into ryan-di/line-snapping
This commit is contained in:
@@ -17,6 +17,12 @@
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./visualdebug": {
|
||||
"types": "./dist/types/element/src/visualdebug.d.ts",
|
||||
"development": "./dist/dev/visualdebug.js",
|
||||
"production": "./dist/prod/visualdebug.js",
|
||||
"default": "./dist/prod/visualdebug.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -164,9 +164,14 @@ export class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements);
|
||||
this.replaceAllElements(elements, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,12 +268,19 @@ export class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
@@ -426,6 +438,8 @@ export class Scene {
|
||||
options: {
|
||||
informMutation: boolean;
|
||||
isDragging: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
} = {
|
||||
informMutation: true,
|
||||
isDragging: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,968 @@
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import {
|
||||
convertToExcalidrawElements,
|
||||
type ExcalidrawElementSkeleton,
|
||||
} from "../transform";
|
||||
|
||||
import type { ExcalidrawArrowElement } from "../types";
|
||||
|
||||
const opts = { regenerateIds: false };
|
||||
|
||||
describe("Test Transform", () => {
|
||||
it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
},
|
||||
];
|
||||
let data = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
);
|
||||
expect(data.length).toBe(1);
|
||||
expect(data[0].id).toBe("id0");
|
||||
|
||||
data = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
expect(data[0].id).toBe("rect-1");
|
||||
});
|
||||
|
||||
it("should transform regular shapes", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 100,
|
||||
y: 250,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#c0eb75",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 300,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#ffc9c9",
|
||||
strokeStyle: "dotted",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 300,
|
||||
y: 400,
|
||||
width: 200,
|
||||
height: 100,
|
||||
backgroundColor: "#a5d8ff",
|
||||
strokeColor: "#1971c2",
|
||||
strokeStyle: "dashed",
|
||||
fillStyle: "cross-hatch",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
];
|
||||
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform text element", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 100,
|
||||
text: "HELLO WORLD!",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
x: 100,
|
||||
y: 150,
|
||||
text: "STYLED HELLO WORLD!",
|
||||
fontSize: 20,
|
||||
strokeColor: "#5f3dc4",
|
||||
},
|
||||
];
|
||||
convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
).forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform linear elements", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 20,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 450,
|
||||
y: 20,
|
||||
startArrowhead: "dot",
|
||||
endArrowhead: "triangle",
|
||||
strokeColor: "#1971c2",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 100,
|
||||
y: 60,
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
x: 450,
|
||||
y: 60,
|
||||
strokeColor: "#2f9e44",
|
||||
strokeWidth: 2,
|
||||
strokeStyle: "dotted",
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(4);
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to text containers when label provided", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "RECTANGLE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 100,
|
||||
width: 200,
|
||||
label: {
|
||||
text: "ELLIPSE TEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 150,
|
||||
width: 280,
|
||||
label: {
|
||||
text: "DIAMOND\nTEXT CONTAINER",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 100,
|
||||
y: 400,
|
||||
width: 300,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "STYLED DIAMOND TEXT CONTAINER",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 500,
|
||||
y: 300,
|
||||
width: 200,
|
||||
strokeColor: "#c2255c",
|
||||
label: {
|
||||
text: "TOP LEFT ALIGNED RECTANGLE TEXT CONTAINER",
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
x: 500,
|
||||
y: 500,
|
||||
strokeColor: "#f08c00",
|
||||
backgroundColor: "#ffec99",
|
||||
width: 200,
|
||||
label: {
|
||||
text: "STYLED ELLIPSE TEXT CONTAINER",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(12);
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform to labelled arrows when label provided for arrows", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 100,
|
||||
label: {
|
||||
text: "LABELED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 200,
|
||||
label: {
|
||||
text: "STYLED LABELED ARROW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 300,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 100,
|
||||
y: 400,
|
||||
strokeColor: "#1098ad",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "ANOTHER STYLED LABELLED ARROW",
|
||||
strokeColor: "#099268",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(8);
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test Frames", () => {
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 10,
|
||||
y: 10,
|
||||
strokeWidth: 2,
|
||||
id: "1",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
x: 120,
|
||||
y: 20,
|
||||
backgroundColor: "#fff3bf",
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
text: "HELLO EXCALIDRAW",
|
||||
strokeColor: "#099268",
|
||||
fontSize: 30,
|
||||
},
|
||||
id: "2",
|
||||
},
|
||||
];
|
||||
|
||||
it("should transform frames and update frame ids when regenerated", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
expect(excalidrawElements.length).toBe(4);
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchObject({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should consider user defined frame dimensions over calculated when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
width: 800,
|
||||
height: 100,
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.width).toBe(800);
|
||||
expect(frame.height).toBe(100);
|
||||
});
|
||||
|
||||
it("should consider user defined frame coordinates calculated when provided", () => {
|
||||
const elementsSkeleton: ExcalidrawElementSkeleton[] = [
|
||||
...elements,
|
||||
{
|
||||
type: "frame",
|
||||
children: ["1", "2"],
|
||||
name: "My frame",
|
||||
x: 100,
|
||||
y: 300,
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elementsSkeleton,
|
||||
opts,
|
||||
);
|
||||
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
|
||||
expect(frame.x).toBe(100);
|
||||
expect(frame.y).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test arrow bindings", () => {
|
||||
it("should bind arrows to shapes when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "rectangle",
|
||||
},
|
||||
end: {
|
||||
type: "ellipse",
|
||||
},
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(4);
|
||||
const [arrow, text, rectangle, ellipse] = excalidrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255.5,
|
||||
y: 239,
|
||||
boundElements: [{ id: text.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: rectangle.id,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: ellipse.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text).toMatchObject({
|
||||
x: 240,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(rectangle).toMatchObject({
|
||||
x: 155,
|
||||
y: 189,
|
||||
type: "rectangle",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(ellipse).toMatchObject({
|
||||
x: 355,
|
||||
y: 189,
|
||||
type: "ellipse",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to text when start / end provided without ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
},
|
||||
end: {
|
||||
type: "text",
|
||||
text: "WHATS UP ?",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(4);
|
||||
const [arrow, text1, text2, text3] = excalidrawElements;
|
||||
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255.5,
|
||||
y: 239,
|
||||
boundElements: [{ id: text1.id, type: "text" }],
|
||||
startBinding: {
|
||||
elementId: text2.id,
|
||||
},
|
||||
endBinding: {
|
||||
elementId: text3.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(text1).toMatchObject({
|
||||
x: 240,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
text: "HELLO WORLD!!",
|
||||
containerId: arrow.id,
|
||||
});
|
||||
|
||||
expect(text2).toMatchObject({
|
||||
x: 185,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(text3).toMatchObject({
|
||||
x: 355,
|
||||
y: 226.5,
|
||||
type: "text",
|
||||
boundElements: [
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing shapes when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "ellipse",
|
||||
id: "ellipse-1",
|
||||
strokeColor: "#66a80f",
|
||||
x: 630,
|
||||
y: 316,
|
||||
width: 300,
|
||||
height: 300,
|
||||
backgroundColor: "#d8f5a2",
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
id: "diamond-1",
|
||||
strokeColor: "#9c36b5",
|
||||
width: 140,
|
||||
x: 96,
|
||||
y: 400,
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 247,
|
||||
y: 420,
|
||||
width: 395,
|
||||
height: 35,
|
||||
strokeColor: "#1864ab",
|
||||
start: {
|
||||
type: "rectangle",
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 227,
|
||||
y: 450,
|
||||
width: 400,
|
||||
strokeColor: "#e67700",
|
||||
start: {
|
||||
id: "diamond-1",
|
||||
},
|
||||
end: {
|
||||
id: "ellipse-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(5);
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing text elements when start / end provided with ids", () => {
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
id: "text-2",
|
||||
x: 560,
|
||||
y: 239,
|
||||
text: "Whats up ?",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-1",
|
||||
},
|
||||
end: {
|
||||
id: "text-2",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(4);
|
||||
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should bind arrows to existing elements if ids are correct", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
x: 100,
|
||||
y: 239,
|
||||
type: "text",
|
||||
text: "HEYYYYY",
|
||||
id: "text-1",
|
||||
strokeColor: "#c2255c",
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
label: {
|
||||
text: "HELLO WORLD!!",
|
||||
},
|
||||
start: {
|
||||
id: "text-13",
|
||||
},
|
||||
end: {
|
||||
id: "rect-11",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(4);
|
||||
const [, , arrow, text] = excalidrawElements;
|
||||
expect(arrow).toMatchObject({
|
||||
type: "arrow",
|
||||
x: 255.5,
|
||||
y: 239,
|
||||
boundElements: [
|
||||
{
|
||||
id: text.id,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"No element for start binding with id text-13 found",
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"No element for end binding with id rect-11 found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should bind when ids referenced before the element data", () => {
|
||||
const elements = [
|
||||
{
|
||||
type: "arrow",
|
||||
x: 255,
|
||||
y: 239,
|
||||
end: {
|
||||
id: "rect-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 560,
|
||||
y: 139,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
backgroundColor: "#bac8ff",
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
expect(excalidrawElements.length).toBe(2);
|
||||
const [arrow, rect] = excalidrawElements;
|
||||
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
|
||||
elementId: "rect-1",
|
||||
fixedPoint: [-2.05, 0.5001],
|
||||
mode: "orbit",
|
||||
});
|
||||
expect(rect.boundElements).toStrictEqual([
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not allow duplicate ids", () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementationOnce(() => void 0);
|
||||
const elements = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 300,
|
||||
y: 100,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 200,
|
||||
id: "rect-1",
|
||||
width: 100,
|
||||
height: 200,
|
||||
},
|
||||
];
|
||||
const excalidrawElements = convertToExcalidrawElements(
|
||||
elements as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
|
||||
expect(excalidrawElements.length).toBe(1);
|
||||
expect(excalidrawElements[0]).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
});
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Duplicate id found for rect-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should contains customData if provided", () => {
|
||||
const rawData = [
|
||||
{
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
customData: { createdBy: "user01" },
|
||||
},
|
||||
];
|
||||
const convertedElements = convertToExcalidrawElements(
|
||||
rawData as ExcalidrawElementSkeleton[],
|
||||
opts,
|
||||
);
|
||||
expect(convertedElements[0].customData).toStrictEqual({
|
||||
createdBy: "user01",
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform the elements correctly when linear elements have single point", () => {
|
||||
const elements: ExcalidrawElementSkeleton[] = [
|
||||
{
|
||||
id: "B",
|
||||
type: "rectangle",
|
||||
groupIds: ["subgraph_group_B"],
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 166.03125,
|
||||
height: 163,
|
||||
label: {
|
||||
groupIds: ["subgraph_group_B"],
|
||||
text: "B",
|
||||
fontSize: 20,
|
||||
verticalAlign: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "A",
|
||||
type: "rectangle",
|
||||
groupIds: ["subgraph_group_A"],
|
||||
x: 364.546875,
|
||||
y: 0,
|
||||
width: 120.265625,
|
||||
height: 114,
|
||||
label: {
|
||||
groupIds: ["subgraph_group_A"],
|
||||
text: "A",
|
||||
fontSize: 20,
|
||||
verticalAlign: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Alice",
|
||||
type: "rectangle",
|
||||
groupIds: ["subgraph_group_A"],
|
||||
x: 389.546875,
|
||||
y: 35,
|
||||
width: 70.265625,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: ["subgraph_group_A"],
|
||||
text: "Alice",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Bob",
|
||||
type: "rectangle",
|
||||
groupIds: ["subgraph_group_B"],
|
||||
x: 54.76953125,
|
||||
y: 35,
|
||||
width: 56.4921875,
|
||||
height: 44,
|
||||
strokeWidth: 2,
|
||||
label: {
|
||||
groupIds: ["subgraph_group_B"],
|
||||
text: "Bob",
|
||||
fontSize: 20,
|
||||
},
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
id: "Bob_Alice",
|
||||
type: "arrow",
|
||||
groupIds: [],
|
||||
x: 111.262,
|
||||
y: 57,
|
||||
strokeWidth: 2,
|
||||
points: [pointFrom(0, 0), pointFrom(272.985, 0)],
|
||||
label: {
|
||||
text: "How are you?",
|
||||
fontSize: 20,
|
||||
groupIds: [],
|
||||
},
|
||||
roundness: {
|
||||
type: 2,
|
||||
},
|
||||
start: {
|
||||
id: "Bob",
|
||||
},
|
||||
end: {
|
||||
id: "Alice",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Bob_B",
|
||||
type: "arrow",
|
||||
groupIds: [],
|
||||
x: 77.017,
|
||||
y: 79,
|
||||
strokeWidth: 2,
|
||||
points: [pointFrom(0, 0)],
|
||||
label: {
|
||||
text: "Friendship",
|
||||
fontSize: 20,
|
||||
groupIds: [],
|
||||
},
|
||||
roundness: {
|
||||
type: 2,
|
||||
},
|
||||
start: {
|
||||
id: "Bob",
|
||||
},
|
||||
end: {
|
||||
id: "B",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const excalidrawElements = convertToExcalidrawElements(elements, opts);
|
||||
expect(excalidrawElements.length).toBe(12);
|
||||
excalidrawElements.forEach((ele) => {
|
||||
expect(ele).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
versionNonce: expect.any(Number),
|
||||
id: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Arrowhead, AnyArrowhead } from "./types";
|
||||
|
||||
export const normalizeArrowhead = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
switch (arrowhead) {
|
||||
case undefined:
|
||||
case null:
|
||||
return null;
|
||||
case "dot":
|
||||
return "circle";
|
||||
case "crowfoot_one":
|
||||
return "cardinality_one";
|
||||
case "crowfoot_many":
|
||||
return "cardinality_many";
|
||||
case "crowfoot_one_or_many":
|
||||
return "cardinality_one_or_many";
|
||||
default:
|
||||
return arrowhead;
|
||||
}
|
||||
};
|
||||
|
||||
export const getArrowheadForPicker = (
|
||||
arrowhead: AnyArrowhead | null | undefined,
|
||||
): Arrowhead | null => {
|
||||
const normalizedArrowhead = normalizeArrowhead(arrowhead);
|
||||
if (normalizedArrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedArrowhead;
|
||||
};
|
||||
@@ -0,0 +1,558 @@
|
||||
import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math";
|
||||
import { invariant } from "@excalidraw/common";
|
||||
|
||||
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindBindingElement,
|
||||
calculateFixedPointForNonElbowArrowBinding,
|
||||
FOCUS_POINT_SIZE,
|
||||
getBindingGap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
isBindingEnabled,
|
||||
maxBindingDistance_simple,
|
||||
unbindBindingElement,
|
||||
updateBoundPoint,
|
||||
} from "../binding";
|
||||
import {
|
||||
isBindableElement,
|
||||
isBindingElement,
|
||||
isElbowArrow,
|
||||
} from "../typeChecks";
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
import { getHoveredElementForFocusPoint, hitElementItself } from "../collision";
|
||||
import { moveArrowAboveBindable } from "../zindex";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
PointsPositionUpdates,
|
||||
} from "../types";
|
||||
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export const isFocusPointVisible = (
|
||||
focusPoint: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: {
|
||||
isBindingEnabled: AppState["isBindingEnabled"];
|
||||
zoom: AppState["zoom"];
|
||||
},
|
||||
startOrEnd: "start" | "end",
|
||||
ignoreOverlap = false,
|
||||
): boolean => {
|
||||
// No focus point management for elbow arrows, because elbow arrows
|
||||
// always have their focus point at the arrow point itself
|
||||
if (
|
||||
isElbowArrow(arrow) ||
|
||||
!isBindingEnabled(appState) ||
|
||||
arrow.points.length !== 2
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Avoid showing the focus point indicator if the focus point is essentially
|
||||
// on top of the arrow point it belongs to itself, if not ignoring specifically
|
||||
if (!ignoreOverlap) {
|
||||
const associatedPointIdx =
|
||||
arrow.startBinding?.elementId === bindableElement.id
|
||||
? 0
|
||||
: arrow.points.length - 1;
|
||||
const associatedArrowPoint =
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
associatedPointIdx,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (
|
||||
pointDistance(focusPoint, associatedArrowPoint) <
|
||||
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "end" ? arrow.points.length - 1 : 0,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Check if the focus point is within the element's shape bounds
|
||||
// Endpoint dragging takes precedence
|
||||
return (
|
||||
pointDistance(focusPoint, arrowPoint) >=
|
||||
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
|
||||
hitElementItself({
|
||||
element: bindableElement,
|
||||
elementsMap,
|
||||
point: focusPoint,
|
||||
threshold: getBindingGap(bindableElement, arrow),
|
||||
overrideShouldTestInside: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Updates the arrow endpoints in "orbit" configuration
|
||||
const focusPointUpdate = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
bindableElement: ExcalidrawBindableElement | null,
|
||||
isStartBinding: boolean,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
switchToInsideBinding: boolean,
|
||||
) => {
|
||||
const pointUpdates = new Map();
|
||||
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding";
|
||||
let currentBinding = arrow[bindingField];
|
||||
let adjacentBinding = arrow[adjacentBindingField];
|
||||
|
||||
// Update the dragged focus point related end
|
||||
if (currentBinding && bindableElement) {
|
||||
// Update the targeted bindings
|
||||
const boundToSameElement =
|
||||
bindableElement &&
|
||||
adjacentBinding &&
|
||||
currentBinding.elementId === adjacentBinding.elementId;
|
||||
if (switchToInsideBinding || boundToSameElement) {
|
||||
currentBinding = {
|
||||
...currentBinding,
|
||||
mode: "inside",
|
||||
};
|
||||
} else {
|
||||
currentBinding = {
|
||||
...currentBinding,
|
||||
mode: "orbit",
|
||||
};
|
||||
}
|
||||
|
||||
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
|
||||
const newPoint = updateBoundPoint(
|
||||
arrow,
|
||||
bindingField as "startBinding" | "endBinding",
|
||||
currentBinding,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
|
||||
if (newPoint) {
|
||||
pointUpdates.set(pointIndex, { point: newPoint });
|
||||
}
|
||||
}
|
||||
|
||||
// Also update the adjacent end if it has a binding
|
||||
if (adjacentBinding && adjacentBinding.mode === "orbit") {
|
||||
const adjacentBindableElement = elementsMap.get(
|
||||
adjacentBinding.elementId,
|
||||
) as ExcalidrawBindableElement;
|
||||
|
||||
if (
|
||||
adjacentBindableElement &&
|
||||
isBindableElement(adjacentBindableElement) &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
// Same shape bound on both ends
|
||||
const boundToSameElementAfterUpdate =
|
||||
bindableElement && adjacentBinding.elementId === bindableElement.id;
|
||||
if (switchToInsideBinding || boundToSameElementAfterUpdate) {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "inside",
|
||||
};
|
||||
} else {
|
||||
adjacentBinding = {
|
||||
...adjacentBinding,
|
||||
mode: "orbit",
|
||||
};
|
||||
}
|
||||
|
||||
const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0;
|
||||
const adjacentNewPoint = updateBoundPoint(
|
||||
arrow,
|
||||
adjacentBindingField,
|
||||
adjacentBinding,
|
||||
adjacentBindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
if (adjacentNewPoint) {
|
||||
pointUpdates.set(adjacentPointIndex, {
|
||||
point: adjacentNewPoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pointUpdates.size > 0) {
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates, {
|
||||
[bindingField]: currentBinding,
|
||||
[adjacentBindingField]: adjacentBinding,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointDrag = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
pointerCoords: { x: number; y: number },
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
gridSize: NullableGridSize,
|
||||
switchToInsideBinding: boolean,
|
||||
) => {
|
||||
const arrow = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
elementsMap,
|
||||
) as any;
|
||||
|
||||
// Sanity checks
|
||||
if (
|
||||
!arrow ||
|
||||
!isBindingElement(arrow) ||
|
||||
isElbowArrow(arrow) ||
|
||||
!linearElementEditor.hoveredFocusPointBinding ||
|
||||
!linearElementEditor.draggedFocusPointBinding
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isStartBinding =
|
||||
linearElementEditor.draggedFocusPointBinding === "start";
|
||||
const binding = isStartBinding ? arrow.startBinding : arrow.endBinding;
|
||||
const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset;
|
||||
const point = pointFrom<GlobalPoint>(
|
||||
pointerCoords.x - offsetX,
|
||||
pointerCoords.y - offsetY,
|
||||
);
|
||||
const bindingField = isStartBinding ? "startBinding" : "endBinding";
|
||||
const hit = getHoveredElementForFocusPoint(
|
||||
point,
|
||||
arrow,
|
||||
scene.getNonDeletedElements(),
|
||||
elementsMap,
|
||||
maxBindingDistance_simple(appState.zoom),
|
||||
);
|
||||
|
||||
// Hovering a bindable element
|
||||
if (hit && isBindingEnabled(appState)) {
|
||||
// Break existing binding if bound to another shape or if binding is disabled
|
||||
if (arrow[bindingField] && hit.id !== binding?.elementId) {
|
||||
unbindBindingElement(
|
||||
arrow,
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle binding mode switch
|
||||
const newMode =
|
||||
switchToInsideBinding && arrow[bindingField]?.mode === "orbit"
|
||||
? "inside"
|
||||
: !switchToInsideBinding && arrow[bindingField]?.mode === "inside"
|
||||
? "orbit"
|
||||
: null;
|
||||
|
||||
// If no existing binding, create it
|
||||
if (!arrow[bindingField] || newMode) {
|
||||
// Create a new binding if none exists
|
||||
bindBindingElement(
|
||||
arrow,
|
||||
hit,
|
||||
newMode || "orbit",
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
scene,
|
||||
point,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the binding's fixed point
|
||||
scene.mutateElement(arrow, {
|
||||
[bindingField]: {
|
||||
...arrow[bindingField],
|
||||
elementId: hit.id,
|
||||
mode: newMode || arrow[bindingField]?.mode || "orbit",
|
||||
...calculateFixedPointForNonElbowArrowBinding(
|
||||
arrow,
|
||||
hit,
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
elementsMap,
|
||||
point,
|
||||
),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Not hovering any bindable element, move the arrow endpoint
|
||||
const pointUpdates: PointsPositionUpdates = new Map();
|
||||
const pointIndex = isStartBinding ? 0 : arrow.points.length - 1;
|
||||
pointUpdates.set(pointIndex, {
|
||||
point: LinearElementEditor.createPointAt(
|
||||
arrow,
|
||||
elementsMap,
|
||||
point[0],
|
||||
point[1],
|
||||
gridSize,
|
||||
),
|
||||
});
|
||||
LinearElementEditor.movePoints(arrow, scene, pointUpdates);
|
||||
if (arrow[bindingField]) {
|
||||
unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the arrow endpoints
|
||||
focusPointUpdate(
|
||||
arrow,
|
||||
hit,
|
||||
isStartBinding,
|
||||
elementsMap,
|
||||
scene,
|
||||
appState,
|
||||
switchToInsideBinding,
|
||||
);
|
||||
|
||||
if (hit && isBindingEnabled(appState)) {
|
||||
moveArrowAboveBindable(
|
||||
point,
|
||||
arrow,
|
||||
scene.getElementsIncludingDeleted(),
|
||||
elementsMap,
|
||||
scene,
|
||||
hit,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointPointerDown = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
pointerDownState: { origin: { x: number; y: number } },
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
appState: AppState,
|
||||
): {
|
||||
hitFocusPoint: "start" | "end" | null;
|
||||
pointerOffset: { x: number; y: number };
|
||||
} => {
|
||||
const pointerPos = pointFrom(
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
);
|
||||
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
|
||||
|
||||
// Check start binding focus point
|
||||
if (arrow.startBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"start",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return {
|
||||
hitFocusPoint: "start",
|
||||
pointerOffset: {
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check end binding focus point (only if start not already hit)
|
||||
if (arrow.endBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"end",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return {
|
||||
hitFocusPoint: "end",
|
||||
pointerOffset: {
|
||||
x: pointerPos[0] - focusPoint[0],
|
||||
y: pointerPos[1] - focusPoint[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hitFocusPoint: null,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
};
|
||||
};
|
||||
|
||||
export const handleFocusPointPointerUp = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scene: Scene,
|
||||
) => {
|
||||
invariant(
|
||||
linearElementEditor.draggedFocusPointBinding,
|
||||
"Must have a dragged focus point at pointer release",
|
||||
);
|
||||
|
||||
const arrow = LinearElementEditor.getElement<ExcalidrawArrowElement>(
|
||||
linearElementEditor.elementId,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
invariant(arrow, "Arrow must be in the scene");
|
||||
|
||||
// Clean up
|
||||
const bindingKey =
|
||||
linearElementEditor.draggedFocusPointBinding === "start"
|
||||
? "startBinding"
|
||||
: "endBinding";
|
||||
const otherBindingKey =
|
||||
linearElementEditor.draggedFocusPointBinding === "start"
|
||||
? "endBinding"
|
||||
: "startBinding";
|
||||
const boundElementId = arrow[bindingKey]?.elementId;
|
||||
const otherBoundElementId = arrow[otherBindingKey]?.elementId;
|
||||
const oldBoundElement =
|
||||
boundElementId &&
|
||||
scene
|
||||
.getNonDeletedElements()
|
||||
.find(
|
||||
(element) =>
|
||||
element.id !== boundElementId &&
|
||||
element.id !== otherBoundElementId &&
|
||||
isBindableElement(element) &&
|
||||
element.boundElements?.find(({ id }) => id === arrow.id),
|
||||
);
|
||||
if (oldBoundElement) {
|
||||
scene.mutateElement(oldBoundElement, {
|
||||
boundElements: oldBoundElement.boundElements?.filter(
|
||||
({ id }) => id !== arrow.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Record the new bound element
|
||||
const boundElement =
|
||||
boundElementId && scene.getNonDeletedElementsMap().get(boundElementId);
|
||||
if (boundElement) {
|
||||
scene.mutateElement(boundElement, {
|
||||
boundElements: [
|
||||
...(boundElement.boundElements || [])?.filter(
|
||||
({ id }) => id !== arrow.id,
|
||||
),
|
||||
{
|
||||
id: arrow.id,
|
||||
type: "arrow",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handleFocusPointHover = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
scene: Scene,
|
||||
appState: AppState,
|
||||
): "start" | "end" | null => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const pointerPos = pointFrom(scenePointerX, scenePointerY);
|
||||
const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value;
|
||||
|
||||
// Check start binding focus point
|
||||
if (arrow.startBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.startBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.startBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"start",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return "start";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check end binding focus point (only if start not already hovered)
|
||||
if (arrow.endBinding?.elementId) {
|
||||
const bindableElement = elementsMap.get(arrow.endBinding.elementId);
|
||||
if (
|
||||
bindableElement &&
|
||||
isBindableElement(bindableElement) &&
|
||||
!bindableElement.isDeleted
|
||||
) {
|
||||
const focusPoint = getGlobalFixedPointForBindableElement(
|
||||
arrow.endBinding.fixedPoint,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
isFocusPointVisible(
|
||||
focusPoint,
|
||||
arrow,
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
appState,
|
||||
"end",
|
||||
) &&
|
||||
pointDistance(pointerPos, focusPoint) <= hitThreshold
|
||||
) {
|
||||
return "end";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { App } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { LinearElementEditor } from "../linearElementEditor";
|
||||
|
||||
import { handleFocusPointDrag } from "./focus";
|
||||
|
||||
export const maybeHandleArrowPointlikeDrag = ({
|
||||
app,
|
||||
event,
|
||||
}: {
|
||||
app: App;
|
||||
event: KeyboardEvent | React.KeyboardEvent<Element> | PointerEvent;
|
||||
}): boolean => {
|
||||
const appState = app.state;
|
||||
if (appState.selectedLinearElement && app.lastPointerMoveCoords) {
|
||||
// Update focus point status if the binding mode is changing
|
||||
if (appState.selectedLinearElement.draggedFocusPointBinding) {
|
||||
handleFocusPointDrag(
|
||||
appState.selectedLinearElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
app.lastPointerMoveCoords,
|
||||
app.scene,
|
||||
appState,
|
||||
app.getEffectiveGridSize(),
|
||||
event.altKey,
|
||||
);
|
||||
return true;
|
||||
} else if (
|
||||
appState.selectedLinearElement.hoverPointIndex !== null &&
|
||||
app.lastPointerMoveEvent &&
|
||||
appState.selectedLinearElement.initialState.lastClickedPoint >= 0 &&
|
||||
appState.selectedLinearElement.isDragging
|
||||
) {
|
||||
LinearElementEditor.handlePointDragging(
|
||||
app.lastPointerMoveEvent,
|
||||
app,
|
||||
app.lastPointerMoveCoords.x,
|
||||
app.lastPointerMoveCoords.y,
|
||||
appState.selectedLinearElement,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
+1914
-1253
File diff suppressed because it is too large
Load Diff
+111
-53
@@ -2,6 +2,7 @@ import rough from "roughjs/bin/rough";
|
||||
|
||||
import {
|
||||
arrayToMap,
|
||||
type Bounds,
|
||||
invariant,
|
||||
rescalePoints,
|
||||
sizeOf,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
@@ -77,16 +79,6 @@ export type RectangleBox = {
|
||||
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
/**
|
||||
* x and y position of top left corner, x and y position of bottom right corner
|
||||
*/
|
||||
export type Bounds = readonly [
|
||||
minX: number,
|
||||
minY: number,
|
||||
maxX: number,
|
||||
maxY: number,
|
||||
];
|
||||
|
||||
export type SceneBounds = readonly [
|
||||
sceneX: number,
|
||||
sceneY: number,
|
||||
@@ -321,19 +313,42 @@ export const getElementLineSegments = (
|
||||
|
||||
if (shape.type === "polycurve") {
|
||||
const curves = shape.data;
|
||||
const points = curves
|
||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
||||
.flat();
|
||||
let i = 0;
|
||||
const pointsOnCurves = curves.map((curve) =>
|
||||
pointsOnBezierCurves(curve, 10),
|
||||
);
|
||||
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
|
||||
if (
|
||||
(isLineElement(element) && !element.polygon) ||
|
||||
isArrowElement(element)
|
||||
) {
|
||||
for (const points of pointsOnCurves) {
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const points = pointsOnCurves.flat();
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
@@ -665,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
export const getBoundsFromPoints = (
|
||||
points: ExcalidrawFreeDrawElement["points"],
|
||||
export const getBoundsFromPoints = <P extends GlobalPoint | LocalPoint>(
|
||||
points: readonly P[],
|
||||
padding: number = 0,
|
||||
): Bounds => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
@@ -680,7 +696,7 @@ export const getBoundsFromPoints = (
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
|
||||
return [minX, minY, maxX, maxY];
|
||||
return [minX - padding, minY - padding, maxX + padding, maxY + padding];
|
||||
};
|
||||
|
||||
const getFreeDrawElementAbsoluteCoords = (
|
||||
@@ -694,6 +710,9 @@ const getFreeDrawElementAbsoluteCoords = (
|
||||
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
|
||||
};
|
||||
|
||||
const CARDINALITY_MARKER_SIZE = 20;
|
||||
const CROWFOOT_ARROWHEAD_SIZE = 15;
|
||||
|
||||
/** @returns number in pixels */
|
||||
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
switch (arrowhead) {
|
||||
@@ -702,10 +721,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||
case "diamond":
|
||||
case "diamond_outline":
|
||||
return 12;
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one":
|
||||
case "crowfoot_one_or_many":
|
||||
return 20;
|
||||
case "cardinality_many":
|
||||
case "cardinality_one_or_many":
|
||||
case "cardinality_zero_or_many":
|
||||
return CROWFOOT_ARROWHEAD_SIZE;
|
||||
case "cardinality_one":
|
||||
case "cardinality_exactly_one":
|
||||
case "cardinality_zero_or_one":
|
||||
return CARDINALITY_MARKER_SIZE;
|
||||
default:
|
||||
return 15;
|
||||
}
|
||||
@@ -728,7 +751,12 @@ export const getArrowheadPoints = (
|
||||
shape: Drawable[],
|
||||
position: "start" | "end",
|
||||
arrowhead: Arrowhead,
|
||||
offsetMultiplier = 0,
|
||||
) => {
|
||||
if (arrowhead === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shape.length < 1) {
|
||||
return null;
|
||||
}
|
||||
@@ -809,29 +837,30 @@ export const getArrowheadPoints = (
|
||||
const lengthMultiplier =
|
||||
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
|
||||
const minSize = Math.min(size, length * lengthMultiplier);
|
||||
const xs = x2 - nx * minSize;
|
||||
const ys = y2 - ny * minSize;
|
||||
const tx = x2 - nx * minSize * offsetMultiplier;
|
||||
const ty = y2 - ny * minSize * offsetMultiplier;
|
||||
const xs = tx - nx * minSize;
|
||||
const ys = ty - ny * minSize;
|
||||
|
||||
if (
|
||||
arrowhead === "dot" ||
|
||||
arrowhead === "circle" ||
|
||||
arrowhead === "circle_outline"
|
||||
) {
|
||||
const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
|
||||
return [x2, y2, diameter];
|
||||
if (arrowhead === "circle" || arrowhead === "circle_outline") {
|
||||
const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
|
||||
return [tx, ty, diameter];
|
||||
}
|
||||
|
||||
const angle = getArrowheadAngle(arrowhead);
|
||||
|
||||
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
|
||||
if (
|
||||
arrowhead === "cardinality_many" ||
|
||||
arrowhead === "cardinality_one_or_many"
|
||||
) {
|
||||
// swap (xs, ys) with (x2, y2)
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(-angle as Degrees),
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
pointFrom(xs, ys),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
@@ -841,12 +870,12 @@ export const getArrowheadPoints = (
|
||||
// Return points
|
||||
const [x3, y3] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
((-angle * Math.PI) / 180) as Radians,
|
||||
);
|
||||
const [x4, y4] = pointRotateRads(
|
||||
pointFrom(xs, ys),
|
||||
pointFrom(x2, y2),
|
||||
pointFrom(tx, ty),
|
||||
degreesToRadians(angle),
|
||||
);
|
||||
|
||||
@@ -859,9 +888,9 @@ export const getArrowheadPoints = (
|
||||
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 + minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(py - y2, px - x2) as Radians,
|
||||
pointFrom(tx + minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(py - ty, px - tx) as Radians,
|
||||
);
|
||||
} else {
|
||||
const [px, py] =
|
||||
@@ -870,18 +899,19 @@ export const getArrowheadPoints = (
|
||||
: [0, 0];
|
||||
|
||||
[ox, oy] = pointRotateRads(
|
||||
pointFrom(x2 - minSize * 2, y2),
|
||||
pointFrom(x2, y2),
|
||||
Math.atan2(y2 - py, x2 - px) as Radians,
|
||||
pointFrom(tx - minSize * 2, ty),
|
||||
pointFrom(tx, ty),
|
||||
Math.atan2(ty - py, tx - px) as Radians,
|
||||
);
|
||||
}
|
||||
|
||||
return [x2, y2, x3, y3, ox, oy, x4, y4];
|
||||
return [tx, ty, x3, y3, ox, oy, x4, y4];
|
||||
}
|
||||
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
return [tx, ty, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
// TODO reuse shape.ts
|
||||
const generateLinearElementShape = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Drawable => {
|
||||
@@ -939,7 +969,7 @@ const getLinearElementRotatedBounds = (
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const cachedShape = ShapeCache.get(element, null)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
@@ -1126,7 +1156,9 @@ export interface BoundingBox {
|
||||
}
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements:
|
||||
| readonly ExcalidrawElement[]
|
||||
| readonly NonDeleted<ExcalidrawElement>[],
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
@@ -1230,6 +1262,17 @@ export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
): boolean =>
|
||||
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
|
||||
|
||||
// TODO make pointInsideBounds inclusive and remove this function once we
|
||||
// test nothing is breaking
|
||||
export const pointInsideBoundsInclusive = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
bounds: Bounds,
|
||||
): boolean =>
|
||||
p[0] >= bounds[0] &&
|
||||
p[0] <= bounds[2] &&
|
||||
p[1] >= bounds[1] &&
|
||||
p[1] <= bounds[3];
|
||||
|
||||
export const doBoundsIntersect = (
|
||||
bounds1: Bounds | null,
|
||||
bounds2: Bounds | null,
|
||||
@@ -1244,12 +1287,27 @@ export const doBoundsIntersect = (
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
};
|
||||
|
||||
export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
|
||||
[
|
||||
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[1]),
|
||||
pointFrom<GlobalPoint>(innerBounds[0], innerBounds[3]),
|
||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[1]),
|
||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
||||
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
||||
|
||||
export const elementCenterPoint = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
xOffset: number = 0,
|
||||
yOffset: number = 0,
|
||||
) => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const [x, y] = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
}
|
||||
|
||||
const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
|
||||
|
||||
return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTransparent } from "@excalidraw/common";
|
||||
import { invariant, isTransparent, type Bounds } from "@excalidraw/common";
|
||||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
@@ -29,15 +29,18 @@ import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { isPathALoop } from "./utils";
|
||||
import {
|
||||
type Bounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getCenterForBounds,
|
||||
getCubicBezierCurveBound,
|
||||
getDiamondPoints,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isBindableElement,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
@@ -56,14 +59,22 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { distanceToElement } from "./distance";
|
||||
|
||||
import { getBindingGap } from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
@@ -94,15 +105,41 @@ export type HitTestArgs = {
|
||||
threshold: number;
|
||||
elementsMap: ElementsMap;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
overrideShouldTestInside?: boolean;
|
||||
};
|
||||
|
||||
let cachedPoint: GlobalPoint | null = null;
|
||||
let cachedElement: WeakRef<ExcalidrawElement> | null = null;
|
||||
let cachedThreshold: number = Infinity;
|
||||
let cachedHit: boolean = false;
|
||||
let cachedOverrideShouldTestInside = false;
|
||||
|
||||
export const hitElementItself = ({
|
||||
point,
|
||||
element,
|
||||
threshold,
|
||||
elementsMap,
|
||||
frameNameBound = null,
|
||||
overrideShouldTestInside = false,
|
||||
}: HitTestArgs) => {
|
||||
// Return cached result if the same point and element version is tested again
|
||||
if (
|
||||
cachedPoint &&
|
||||
pointsEqual(point, cachedPoint) &&
|
||||
cachedThreshold <= threshold &&
|
||||
overrideShouldTestInside === cachedOverrideShouldTestInside
|
||||
) {
|
||||
const derefElement = cachedElement?.deref();
|
||||
if (
|
||||
derefElement &&
|
||||
derefElement.id === element.id &&
|
||||
derefElement.version === element.version &&
|
||||
derefElement.versionNonce === element.versionNonce
|
||||
) {
|
||||
return cachedHit;
|
||||
}
|
||||
}
|
||||
|
||||
// Hit test against a frame's name
|
||||
const hitFrameName = frameNameBound
|
||||
? isPointWithinBounds(
|
||||
@@ -117,14 +154,11 @@ export const hitElementItself = ({
|
||||
|
||||
// Hit test against the extended, rotated bounding box of the element first
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
const hitBounds = isPointWithinBounds(
|
||||
pointFrom(bounds[0] - threshold, bounds[1] - threshold),
|
||||
pointRotateRads(
|
||||
point,
|
||||
getCenterForBounds(bounds),
|
||||
-element.angle as Radians,
|
||||
),
|
||||
pointFrom(bounds[2] + threshold, bounds[3] + threshold),
|
||||
const hitBounds = isPointInRotatedBounds(
|
||||
point,
|
||||
bounds,
|
||||
element.angle,
|
||||
threshold,
|
||||
);
|
||||
|
||||
// PERF: Bail out early if the point is not even in the
|
||||
@@ -134,14 +168,43 @@ export const hitElementItself = ({
|
||||
}
|
||||
|
||||
// Do the precise (and relatively costly) hit test
|
||||
const hitElement = shouldTestInside(element)
|
||||
const hitElement = (
|
||||
overrideShouldTestInside ? true : 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;
|
||||
const result = hitElement || hitFrameName;
|
||||
|
||||
// Cache end result
|
||||
cachedPoint = point;
|
||||
cachedElement = new WeakRef(element);
|
||||
cachedThreshold = threshold;
|
||||
cachedOverrideShouldTestInside = overrideShouldTestInside;
|
||||
cachedHit = result;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const isPointInRotatedBounds = (
|
||||
point: GlobalPoint,
|
||||
bounds: Bounds,
|
||||
angle: Radians,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
const adjustedPoint =
|
||||
angle === 0
|
||||
? point
|
||||
: pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
|
||||
|
||||
return isPointWithinBounds(
|
||||
pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
|
||||
adjustedPoint,
|
||||
pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
@@ -150,12 +213,8 @@ export const hitElementBoundingBox = (
|
||||
elementsMap: ElementsMap,
|
||||
tolerance = 0,
|
||||
) => {
|
||||
let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
x1 -= tolerance;
|
||||
y1 -= tolerance;
|
||||
x2 += tolerance;
|
||||
y2 += tolerance;
|
||||
return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
|
||||
const bounds = getElementBounds(element, elementsMap, true);
|
||||
return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = (
|
||||
@@ -193,6 +252,166 @@ export const hitElementBoundText = (
|
||||
return isPointInElement(point, boundTextElement, elementsMap);
|
||||
};
|
||||
|
||||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
[x, y]: Readonly<GlobalPoint>,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance: number = 0,
|
||||
): boolean => {
|
||||
const p = pointFrom<GlobalPoint>(x, y);
|
||||
const shouldTestInside =
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
!isFrameLikeElement(element);
|
||||
|
||||
// PERF: Run a cheap test to see if the binding element
|
||||
// is even close to the element
|
||||
const t = Math.max(1, tolerance);
|
||||
const bounds = [x - t, y - t, x + t, y + t] as Bounds;
|
||||
const elementBounds = getElementBounds(element, elementsMap);
|
||||
if (!doBoundsIntersect(bounds, elementBounds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the element is inside a frame, we should clip the element
|
||||
if (element.frameId) {
|
||||
const enclosingFrame = elementsMap.get(element.frameId);
|
||||
if (enclosingFrame && isFrameLikeElement(enclosingFrame)) {
|
||||
const enclosingFrameBounds = getElementBounds(
|
||||
enclosingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
if (!pointInsideBounds(p, enclosingFrameBounds)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Do the intersection test against the element since it's close enough
|
||||
const intersections = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(elementCenterPoint(element, elementsMap), p),
|
||||
);
|
||||
const distance = distanceToElement(element, elementsMap, p);
|
||||
|
||||
return shouldTestInside
|
||||
? intersections.length === 0 || distance <= tolerance
|
||||
: intersections.length > 0 && distance <= t;
|
||||
};
|
||||
|
||||
export const getAllHoveredElementAtPoint = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement>[] => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
|
||||
if (!isTransparent(element.backgroundColor)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidateElements;
|
||||
};
|
||||
|
||||
export const getHoveredElementForBinding = (
|
||||
point: Readonly<GlobalPoint>,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const candidateElements = getAllHoveredElementAtPoint(
|
||||
point,
|
||||
elements,
|
||||
elementsMap,
|
||||
tolerance,
|
||||
);
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
// Prefer smaller shapes
|
||||
return candidateElements
|
||||
.sort(
|
||||
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
|
||||
)
|
||||
.pop() as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
export const getHoveredElementForFocusPoint = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
tolerance?: number,
|
||||
): ExcalidrawBindableElement | null => {
|
||||
const candidateElements: NonDeleted<ExcalidrawBindableElement>[] = [];
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
// because array is ordered from lower z-index to highest and we want element z-index
|
||||
// with higher z-index
|
||||
for (let index = elements.length - 1; index >= 0; --index) {
|
||||
const element = elements[index];
|
||||
|
||||
invariant(
|
||||
!element.isDeleted,
|
||||
"Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements",
|
||||
);
|
||||
|
||||
if (
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, point, elementsMap, tolerance)
|
||||
) {
|
||||
candidateElements.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidateElements || candidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (candidateElements.length === 1) {
|
||||
return candidateElements[0];
|
||||
}
|
||||
|
||||
const distanceFilteredCandidateElements = candidateElements
|
||||
// Resolve by distance
|
||||
.filter(
|
||||
(el) =>
|
||||
distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) ||
|
||||
isPointInElement(point, el, elementsMap),
|
||||
);
|
||||
|
||||
if (distanceFilteredCandidateElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return distanceFilteredCandidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersect a line with an element for binding test
|
||||
*
|
||||
@@ -257,7 +476,12 @@ export const intersectElementWithLineSegment = (
|
||||
case "line":
|
||||
case "freedraw":
|
||||
case "arrow":
|
||||
return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
|
||||
return intersectLinearOrFreeDrawWithLineSegment(
|
||||
element,
|
||||
line,
|
||||
elementsMap,
|
||||
onlyFirst,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -324,11 +548,15 @@ const lineIntersections = (
|
||||
const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
segment: LineSegment<GlobalPoint>,
|
||||
elementsMap: ElementsMap,
|
||||
onlyFirst = false,
|
||||
): GlobalPoint[] => {
|
||||
// NOTE: This is the only one which return the decomposed elements
|
||||
// rotated! This is due to taking advantage of roughjs definitions.
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const intersections: GlobalPoint[] = [];
|
||||
|
||||
for (const l of lines) {
|
||||
@@ -356,7 +584,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
|
||||
continue;
|
||||
}
|
||||
|
||||
const hits = curveIntersectLineSegment(c, segment);
|
||||
const hits = curveIntersectLineSegment(c, segment, {
|
||||
iterLimit: 10,
|
||||
});
|
||||
|
||||
if (hits.length > 0) {
|
||||
intersections.push(...hits);
|
||||
@@ -554,3 +784,61 @@ export const isPointInElement = (
|
||||
|
||||
return intersections.length % 2 === 1;
|
||||
};
|
||||
|
||||
export const isBindableElementInsideOtherBindable = (
|
||||
innerElement: ExcalidrawBindableElement,
|
||||
outerElement: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
// Get corner points of the inner element based on its type
|
||||
const getCornerPoints = (
|
||||
element: ExcalidrawElement,
|
||||
offset: number,
|
||||
): GlobalPoint[] => {
|
||||
const { x, y, width, height, angle } = element;
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
// Diamond has 4 corner points at the middle of each side
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x + topX, y + topY - offset), // top
|
||||
pointFrom(x + rightX + offset, y + rightY), // right
|
||||
pointFrom(x + bottomX, y + bottomY + offset), // bottom
|
||||
pointFrom(x + leftX - offset, y + leftY), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
if (element.type === "ellipse") {
|
||||
// For ellipse, test points at the extremes (top, right, bottom, left)
|
||||
const cx = x + width / 2;
|
||||
const cy = y + height / 2;
|
||||
const rx = width / 2;
|
||||
const ry = height / 2;
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(cx, cy - ry - offset), // top
|
||||
pointFrom(cx + rx + offset, cy), // right
|
||||
pointFrom(cx, cy + ry + offset), // bottom
|
||||
pointFrom(cx - rx - offset, cy), // left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
}
|
||||
// Rectangle and other rectangular shapes (image, text, etc.)
|
||||
const corners: GlobalPoint[] = [
|
||||
pointFrom(x - offset, y - offset), // top-left
|
||||
pointFrom(x + width + offset, y - offset), // top-right
|
||||
pointFrom(x + width + offset, y + height + offset), // bottom-right
|
||||
pointFrom(x - offset, y + height + offset), // bottom-left
|
||||
];
|
||||
return corners.map((corner) => pointRotateRads(corner, center, angle));
|
||||
};
|
||||
|
||||
const offset = (-1 * Math.max(innerElement.width, innerElement.height)) / 20; // 5% offset
|
||||
const innerCorners = getCornerPoints(innerElement, offset);
|
||||
|
||||
// Check if all corner points of the inner element are inside the outer element
|
||||
return innerCorners.every((corner) =>
|
||||
isPointInElement(corner, outerElement, elementsMap),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,14 @@ export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
type === "rectangle" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "text" ||
|
||||
type === "embeddable";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
|
||||
+367
-157
@@ -2,7 +2,6 @@ import {
|
||||
arrayToMap,
|
||||
arrayToObject,
|
||||
assertNever,
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isShallowEqual,
|
||||
isTestEnv,
|
||||
@@ -56,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@@ -151,13 +150,27 @@ export class Delta<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two deltas into a new one.
|
||||
*/
|
||||
public static merge<T>(
|
||||
delta1: Delta<T>,
|
||||
delta2: Delta<T>,
|
||||
delta3: Delta<T> = Delta.empty(),
|
||||
) {
|
||||
return Delta.create(
|
||||
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
||||
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges deleted and inserted object partials.
|
||||
*/
|
||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||
prev: T,
|
||||
added: T,
|
||||
removed: T,
|
||||
removed: T = {} as T,
|
||||
) {
|
||||
const cloned = { ...prev };
|
||||
|
||||
@@ -497,6 +510,11 @@ export interface DeltaContainer<T> {
|
||||
*/
|
||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||
|
||||
/**
|
||||
* Squashes the current delta with the given one.
|
||||
*/
|
||||
squash(delta: DeltaContainer<T>): this;
|
||||
|
||||
/**
|
||||
* Checks whether all `Delta`s are empty.
|
||||
*/
|
||||
@@ -504,7 +522,11 @@ export interface DeltaContainer<T> {
|
||||
}
|
||||
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
@@ -535,76 +557,137 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return new AppStateDelta(inversedDelta);
|
||||
}
|
||||
|
||||
public squash(delta: AppStateDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedElementIds ?? {},
|
||||
delta.delta.deleted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedElementIds ?? {},
|
||||
delta.delta.inserted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedGroupIds ?? {},
|
||||
delta.delta.deleted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedGroupIds ?? {},
|
||||
delta.delta.inserted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.deleted.lockedMultiSelections ?? {},
|
||||
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.inserted.lockedMultiSelections ?? {},
|
||||
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInserted: Partial<ObservedAppState> = {};
|
||||
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||
Object.keys(mergedInsertedSelectedElementIds).length
|
||||
) {
|
||||
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||
) {
|
||||
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||
) {
|
||||
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||
mergedInserted.lockedMultiSelections =
|
||||
mergedInsertedLockedMultiSelections;
|
||||
}
|
||||
|
||||
this.delta = Delta.merge(
|
||||
this.delta,
|
||||
delta.delta,
|
||||
Delta.create(mergedDeleted, mergedInserted),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
appState: AppState,
|
||||
nextElements: SceneElementsMap,
|
||||
): [AppState, boolean] {
|
||||
try {
|
||||
const {
|
||||
selectedElementIds: removedSelectedElementIds = {},
|
||||
selectedGroupIds: removedSelectedGroupIds = {},
|
||||
selectedElementIds: deletedSelectedElementIds = {},
|
||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||
} = this.delta.deleted;
|
||||
|
||||
const {
|
||||
selectedElementIds: addedSelectedElementIds = {},
|
||||
selectedGroupIds: addedSelectedGroupIds = {},
|
||||
selectedLinearElementId,
|
||||
selectedLinearElementIsEditing,
|
||||
selectedElementIds: insertedSelectedElementIds = {},
|
||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||
selectedLinearElement: insertedSelectedLinearElement,
|
||||
...directlyApplicablePartial
|
||||
} = this.delta.inserted;
|
||||
|
||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||
appState.selectedElementIds,
|
||||
addedSelectedElementIds,
|
||||
removedSelectedElementIds,
|
||||
insertedSelectedElementIds,
|
||||
deletedSelectedElementIds,
|
||||
);
|
||||
|
||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||
appState.selectedGroupIds,
|
||||
addedSelectedGroupIds,
|
||||
removedSelectedGroupIds,
|
||||
insertedSelectedGroupIds,
|
||||
deletedSelectedGroupIds,
|
||||
);
|
||||
|
||||
let selectedLinearElement = appState.selectedLinearElement;
|
||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||
appState.lockedMultiSelections,
|
||||
insertedLockedMultiSelections,
|
||||
deletedLockedMultiSelections,
|
||||
);
|
||||
|
||||
if (selectedLinearElementId === null) {
|
||||
// Unselect linear element (visible change)
|
||||
selectedLinearElement = null;
|
||||
} else if (
|
||||
selectedLinearElementId &&
|
||||
nextElements.has(selectedLinearElementId)
|
||||
) {
|
||||
selectedLinearElement = new LinearElementEditor(
|
||||
nextElements.get(
|
||||
selectedLinearElementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
// Value being 'null' is equivaluent to unknown in this case because it only gets set
|
||||
// to null when 'selectedLinearElementId' is set to null
|
||||
selectedLinearElementIsEditing != null
|
||||
) {
|
||||
invariant(
|
||||
selectedLinearElement,
|
||||
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
|
||||
);
|
||||
|
||||
selectedLinearElement = {
|
||||
...selectedLinearElement,
|
||||
isEditing: selectedLinearElementIsEditing,
|
||||
};
|
||||
}
|
||||
const selectedLinearElement =
|
||||
insertedSelectedLinearElement &&
|
||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
insertedSelectedLinearElement.elementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
insertedSelectedLinearElement.isEditing,
|
||||
)
|
||||
: null;
|
||||
|
||||
const nextAppState = {
|
||||
...appState,
|
||||
...directlyApplicablePartial,
|
||||
selectedElementIds: mergedSelectedElementIds,
|
||||
selectedGroupIds: mergedSelectedGroupIds,
|
||||
selectedLinearElement,
|
||||
lockedMultiSelections: mergedLockedMultiSelections,
|
||||
selectedLinearElement:
|
||||
typeof insertedSelectedLinearElement !== "undefined"
|
||||
? selectedLinearElement
|
||||
: appState.selectedLinearElement,
|
||||
};
|
||||
|
||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||
@@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "selectedLinearElementId": {
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
case "selectedLinearElement":
|
||||
const nextLinearElement = nextAppState[key];
|
||||
|
||||
if (!linearElement) {
|
||||
if (!nextLinearElement) {
|
||||
// previously there was a linear element (assuming visible), now there is none
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(linearElement.elementId);
|
||||
const element = nextElements.get(nextLinearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// previously there wasn't a linear element, now there is one which is visible
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
// there was assigned a linear element now, but it's deleted
|
||||
nextAppState[appStateKey] = null;
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "selectedLinearElementIsEditing": {
|
||||
// Changes in editing state are always visible
|
||||
const prevIsEditing =
|
||||
prevAppState.selectedLinearElement?.isEditing ?? false;
|
||||
const nextIsEditing =
|
||||
nextAppState.selectedLinearElement?.isEditing ?? false;
|
||||
|
||||
if (prevIsEditing !== nextIsEditing) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lockedMultiSelections": {
|
||||
case "lockedMultiSelections":
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
case "activeLockedId":
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
assertNever(
|
||||
key,
|
||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return visibleDifferenceFlag.value;
|
||||
}
|
||||
|
||||
private static convertToAppStateKey(
|
||||
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
|
||||
): keyof Pick<AppState, "selectedLinearElement"> {
|
||||
switch (key) {
|
||||
case "selectedLinearElementId":
|
||||
return "selectedLinearElement";
|
||||
}
|
||||
}
|
||||
|
||||
private static filterSelectedElements(
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
elements: SceneElementsMap,
|
||||
@@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElementIsEditing,
|
||||
selectedLinearElement,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
@@ -926,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// 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 appstate change deltas.`);
|
||||
@@ -960,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
excludedProperties?: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
applyDirection: "forward" | "backward" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1054,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
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
|
||||
(
|
||||
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 satisfiesUniqueInvariants = (
|
||||
elementsDelta: ElementsDelta,
|
||||
id: string,
|
||||
) => {
|
||||
const { added, removed, updated } = elementsDelta;
|
||||
// it's required that there is only one unique delta type per element
|
||||
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||
};
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
@@ -1074,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
console.error(
|
||||
@@ -1110,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
const deleted = { ...prevElement } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
@@ -1124,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
removed[prevElement.id] = delta;
|
||||
if (!prevElement.isDeleted) {
|
||||
removed[prevElement.id] = delta;
|
||||
} else {
|
||||
updated[prevElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
} as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
@@ -1149,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
} else {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1178,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// making sure there are at least some changes
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,8 +1268,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@@ -1316,9 +1388,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@@ -1326,22 +1396,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const flags: ApplyToFlags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
applyDirection: undefined,
|
||||
};
|
||||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
elements,
|
||||
nextElements,
|
||||
snapshot,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
const affectedElements = this.resolveConflicts(
|
||||
elements,
|
||||
nextElements,
|
||||
flags.applyDirection,
|
||||
);
|
||||
|
||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||
changedElements = new Map([
|
||||
@@ -1365,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@@ -1395,12 +1464,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const { added, removed, updated } = delta;
|
||||
|
||||
const mergeBoundElements = (
|
||||
prevDelta: Delta<ElementPartial>,
|
||||
nextDelta: Delta<ElementPartial>,
|
||||
) => {
|
||||
const mergedDeletedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.deleted.boundElements ?? [],
|
||||
nextDelta.deleted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
const mergedInsertedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.inserted.boundElements ?? [],
|
||||
nextDelta.inserted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
if (
|
||||
!mergedDeletedBoundElements.length &&
|
||||
!mergedInsertedBoundElements.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Delta.create(
|
||||
{
|
||||
boundElements: mergedDeletedBoundElements,
|
||||
},
|
||||
{
|
||||
boundElements: mergedInsertedBoundElements,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(added)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.added[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.removed[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.removed[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.added[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.updated[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
|
||||
if (prevDelta === this.added[id]) {
|
||||
this.added[id] = updatedDelta;
|
||||
} else if (prevDelta === this.removed[id]) {
|
||||
this.removed[id] = updatedDelta;
|
||||
} else {
|
||||
this.updated[id] = updatedDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
||||
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
||||
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static createApplier =
|
||||
(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
@@ -1413,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
const nextElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
nextElements.set(nextElement.id, nextElement);
|
||||
acc.set(nextElement.id, nextElement);
|
||||
|
||||
if (!flags.applyDirection) {
|
||||
const prevElement = prevElements.get(id);
|
||||
|
||||
if (prevElement) {
|
||||
flags.applyDirection =
|
||||
prevElement.version > nextElement.version
|
||||
? "backward"
|
||||
: "forward";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -1466,8 +1647,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
@@ -1481,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.excludedProperties.has(key)) {
|
||||
if (options?.excludedProperties?.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1521,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
}
|
||||
|
||||
return newElementWith(element, directlyApplicablePartial);
|
||||
return newElementWith(element, directlyApplicablePartial, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1561,6 +1742,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private resolveConflicts(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
applyDirection: "forward" | "backward" = "forward",
|
||||
) {
|
||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
const updater = (
|
||||
@@ -1572,21 +1754,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevElement = prevElements.get(element.id);
|
||||
const nextVersion =
|
||||
applyDirection === "forward"
|
||||
? nextElement.version + 1
|
||||
: nextElement.version - 1;
|
||||
|
||||
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||
|
||||
let affectedElement: OrderedExcalidrawElement;
|
||||
|
||||
if (prevElements.get(element.id) === nextElement) {
|
||||
if (prevElement === nextElement) {
|
||||
// create the new element instance in case we didn't modify the element yet
|
||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||
affectedElement = newElementWith(
|
||||
nextElement,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
{
|
||||
...elementUpdates,
|
||||
version: nextVersion,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
affectedElement = mutateElement(
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
affectedElement = mutateElement(nextElement, nextElements, {
|
||||
...elementUpdates,
|
||||
// don't modify the version further, if it's already different
|
||||
version:
|
||||
prevElement?.version !== nextElement.version
|
||||
? nextElement.version
|
||||
: nextVersion,
|
||||
});
|
||||
}
|
||||
|
||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||
@@ -1624,25 +1821,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||
);
|
||||
|
||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||
// technically we could do better here if perf. would become an issue
|
||||
const { added, removed, updated } = ElementsDelta.calculate(
|
||||
prevAffectedElements,
|
||||
nextAffectedElements,
|
||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||
this.squash(
|
||||
// technically we could do better here if perf. would become an issue
|
||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
}
|
||||
|
||||
@@ -1704,6 +1888,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||
}
|
||||
|
||||
public static redrawElements(
|
||||
nextElements: SceneElementsMap,
|
||||
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
try {
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
|
||||
// needs ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(`Couldn't redraw elements`, e);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return nextElements;
|
||||
}
|
||||
}
|
||||
|
||||
private static redrawTextBoundingBoxes(
|
||||
scene: Scene,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
@@ -1758,6 +1967,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||
updateBoundElements(element, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export const distanceToElement = (
|
||||
case "line":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
return distanceToLinearOrFreeDraElement(element, p);
|
||||
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
|
||||
|
||||
const distanceToLinearOrFreeDraElement = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
|
||||
const [lines, curves] = deconstructLinearOrFreeDrawElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
return Math.min(
|
||||
...lines.map((s) => distanceToLineSegment(p, s)),
|
||||
...curves.map((a) => curvePointDistance(a, p)),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export interface Distribution {
|
||||
@@ -17,6 +19,7 @@ export const distributeElements = (
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
scene: Scene,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -66,12 +69,16 @@ export const distributeElements = (
|
||||
translation[distribution.axis] = pos - box[mid];
|
||||
}
|
||||
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
const updatedElement = scene.mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedElement;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,11 +97,15 @@ export const distributeElements = (
|
||||
pos += step;
|
||||
pos += box[extent];
|
||||
|
||||
return group.map((element) =>
|
||||
newElementWith(element, {
|
||||
return group.map((element) => {
|
||||
const updatedElement = scene.mutateElement(element, {
|
||||
x: element.x + translation.x,
|
||||
y: element.y + translation.y,
|
||||
}),
|
||||
);
|
||||
});
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: group,
|
||||
});
|
||||
return updatedElement;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
type Bounds,
|
||||
TEXT_AUTOWRAP_THRESHOLD,
|
||||
getGridPoint,
|
||||
getFontString,
|
||||
DRAGGING_THRESHOLD,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -13,7 +15,7 @@ import type {
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { unbindBindingElement, updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
@@ -28,7 +30,6 @@ import {
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
@@ -102,9 +103,26 @@ export const dragSelectedElements = (
|
||||
gridSize,
|
||||
);
|
||||
|
||||
const elementsToUpdateIds = new Set(
|
||||
Array.from(elementsToUpdate, (el) => el.id),
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
const isArrow = !isArrowElement(element);
|
||||
const isStartBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.startBinding
|
||||
? elementsToUpdateIds.has(element.startBinding.elementId)
|
||||
: false);
|
||||
const isEndBoundElementSelected =
|
||||
isArrow ||
|
||||
(element.endBinding
|
||||
? elementsToUpdateIds.has(element.endBinding.elementId)
|
||||
: false);
|
||||
|
||||
if (!isArrowElement(element)) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
// skip arrow labels since we calculate its position during render
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
@@ -121,6 +139,33 @@ export const dragSelectedElements = (
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
} else if (
|
||||
// NOTE: Add a little initial drag to the arrow dragging when the arrow
|
||||
// is the single element being dragged to avoid accidentally unbinding
|
||||
// the arrow when the user just wants to select it.
|
||||
|
||||
elementsToUpdate.size > 1 ||
|
||||
Math.max(Math.abs(adjustedOffset.x), Math.abs(adjustedOffset.y)) >
|
||||
DRAGGING_THRESHOLD ||
|
||||
(!element.startBinding && !element.endBinding)
|
||||
) {
|
||||
updateElementCoords(pointerDownState, element, scene, adjustedOffset);
|
||||
|
||||
const shouldUnbindStart =
|
||||
element.startBinding && !isStartBoundElementSelected;
|
||||
const shouldUnbindEnd = element.endBinding && !isEndBoundElementSelected;
|
||||
if (shouldUnbindStart || shouldUnbindEnd) {
|
||||
// NOTE: Moving the bound arrow should unbind it, otherwise we would
|
||||
// have weird situations, like 0 lenght arrow when the user moves
|
||||
// the arrow outside a filled shape suddenly forcing the arrow start
|
||||
// and end point to jump "outside" the shape.
|
||||
if (shouldUnbindStart) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
if (shouldUnbindEnd) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
type Bounds,
|
||||
BinaryHeap,
|
||||
invariant,
|
||||
isAnyTrue,
|
||||
tupleToCoors,
|
||||
getSizeFromPoints,
|
||||
isDevEnv,
|
||||
arrayToMap,
|
||||
@@ -27,10 +27,11 @@ import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import {
|
||||
bindPointToSnapToElementOutline,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
getHeadingForElbowArrowSnap,
|
||||
getGlobalFixedPointForBindableElement,
|
||||
getHoveredElementForBinding,
|
||||
getBindingGap,
|
||||
maxBindingDistance_simple,
|
||||
BASE_BINDING_GAP_ELBOW,
|
||||
} from "./binding";
|
||||
import { distanceToElement } from "./distance";
|
||||
import {
|
||||
@@ -51,10 +52,9 @@ import {
|
||||
type ExcalidrawElbowArrowElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import { aabbForElement, pointInsideBounds } from "./bounds";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { Heading } from "./heading";
|
||||
import type {
|
||||
Arrowhead,
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
FixedPointBinding,
|
||||
FixedSegment,
|
||||
NonDeletedExcalidrawElement,
|
||||
Ordered,
|
||||
} from "./types";
|
||||
|
||||
type GridAddress = [number, number] & { _brand: "gridaddress" };
|
||||
@@ -359,6 +360,12 @@ const handleSegmentRelease = (
|
||||
null,
|
||||
);
|
||||
|
||||
if (!restoredPoints || restoredPoints.length < 2) {
|
||||
throw new Error(
|
||||
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||
);
|
||||
}
|
||||
|
||||
const nextPoints: GlobalPoint[] = [];
|
||||
|
||||
// First part of the arrow are the old points
|
||||
@@ -706,7 +713,7 @@ const handleEndpointDrag = (
|
||||
endGlobalPoint: GlobalPoint,
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||
) => {
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||
@@ -741,8 +748,15 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second point connection and add the start point
|
||||
{
|
||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
||||
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||
|
||||
if (!secondPoint || !thirdPoint) {
|
||||
throw new Error(
|
||||
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||
);
|
||||
}
|
||||
|
||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||
const secondIsHorizontal = headingIsHorizontal(
|
||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||
@@ -801,10 +815,19 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second to last point connection
|
||||
{
|
||||
const secondToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||
const thirdToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
||||
const secondToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||
);
|
||||
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||
);
|
||||
|
||||
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||
throw new Error(
|
||||
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||
);
|
||||
}
|
||||
|
||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||
thirdToLastPoint,
|
||||
@@ -892,6 +915,8 @@ export const updateElbowArrowPoints = (
|
||||
},
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
if (arrow.points.length < 2) {
|
||||
@@ -1179,6 +1204,8 @@ const getElbowArrowData = (
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
zoom?: AppState["zoom"];
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
const origStartGlobalPoint: GlobalPoint = pointTranslate<
|
||||
@@ -1192,7 +1219,7 @@ const getElbowArrowData = (
|
||||
|
||||
let hoveredStartElement = null;
|
||||
let hoveredEndElement = null;
|
||||
if (options?.isDragging) {
|
||||
if (options?.isDragging && options?.isBindingEnabled !== false) {
|
||||
const elements = Array.from(elementsMap.values());
|
||||
hoveredStartElement =
|
||||
getHoveredElement(
|
||||
@@ -1221,6 +1248,7 @@ const getElbowArrowData = (
|
||||
const startGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
angle: 0,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
@@ -1231,10 +1259,13 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
options?.isBindingEnabled,
|
||||
options?.isMidpointSnappingEnabled,
|
||||
);
|
||||
const endGlobalPoint = getGlobalPoint(
|
||||
{
|
||||
...arrow,
|
||||
angle: 0,
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
points: nextPoints,
|
||||
@@ -1245,6 +1276,8 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
elementsMap,
|
||||
options?.isDragging,
|
||||
options?.isBindingEnabled,
|
||||
options?.isMidpointSnappingEnabled,
|
||||
);
|
||||
const startHeading = getBindPointHeading(
|
||||
startGlobalPoint,
|
||||
@@ -1252,6 +1285,7 @@ const getElbowArrowData = (
|
||||
hoveredStartElement,
|
||||
origStartGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
);
|
||||
const endHeading = getBindPointHeading(
|
||||
endGlobalPoint,
|
||||
@@ -1259,6 +1293,7 @@ const getElbowArrowData = (
|
||||
hoveredEndElement,
|
||||
origEndGlobalPoint,
|
||||
elementsMap,
|
||||
options?.zoom,
|
||||
);
|
||||
const startPointBounds = [
|
||||
startGlobalPoint[0] - 2,
|
||||
@@ -1279,8 +1314,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
startHeading,
|
||||
arrow.startArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
? getBindingGap(hoveredStartElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredStartElement, { elbowed: true }) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1292,8 +1327,8 @@ const getElbowArrowData = (
|
||||
offsetFromHeading(
|
||||
endHeading,
|
||||
arrow.endArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2,
|
||||
? getBindingGap(hoveredEndElement, { elbowed: true }) * 6
|
||||
: getBindingGap(hoveredEndElement, { elbowed: true }) * 2,
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -1340,8 +1375,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.startArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap
|
||||
@@ -1356,8 +1391,8 @@ const getElbowArrowData = (
|
||||
? 0
|
||||
: BASE_PADDING -
|
||||
(arrow.endArrowhead
|
||||
? FIXED_BINDING_DISTANCE * 6
|
||||
: FIXED_BINDING_DISTANCE * 2),
|
||||
? BASE_BINDING_GAP_ELBOW * 6
|
||||
: BASE_BINDING_GAP_ELBOW * 2),
|
||||
BASE_PADDING,
|
||||
),
|
||||
boundsOverlap,
|
||||
@@ -2071,16 +2106,7 @@ const normalizeArrowElementUpdate = (
|
||||
nextFixedSegments: readonly FixedSegment[] | null,
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
} => {
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
const offsetX = global[0][0];
|
||||
const offsetY = global[0][1];
|
||||
let points = global.map((p) =>
|
||||
@@ -2098,8 +2124,8 @@ const normalizeArrowElementUpdate = (
|
||||
offsetY < -MAX_POS ||
|
||||
offsetY > MAX_POS ||
|
||||
offsetX + points[points.length - 1][0] < -MAX_POS ||
|
||||
offsetY + points[points.length - 1][0] > MAX_POS ||
|
||||
offsetX + points[points.length - 1][1] < -MAX_POS ||
|
||||
offsetX + points[points.length - 1][0] > MAX_POS ||
|
||||
offsetY + points[points.length - 1][1] < -MAX_POS ||
|
||||
offsetY + points[points.length - 1][1] > MAX_POS
|
||||
) {
|
||||
console.error(
|
||||
@@ -2195,14 +2221,18 @@ const getGlobalPoint = (
|
||||
element?: ExcalidrawBindableElement | null,
|
||||
elementsMap?: ElementsMap,
|
||||
isDragging?: boolean,
|
||||
isBindingEnabled = true,
|
||||
isMidpointSnappingEnabled = true,
|
||||
): GlobalPoint => {
|
||||
if (isDragging) {
|
||||
if (element && elementsMap) {
|
||||
if (isBindingEnabled && element && elementsMap) {
|
||||
return bindPointToSnapToElementOutline(
|
||||
arrow,
|
||||
element,
|
||||
startOrEnd,
|
||||
elementsMap,
|
||||
undefined,
|
||||
isMidpointSnappingEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2226,6 +2256,7 @@ const getBindPointHeading = (
|
||||
hoveredElement: ExcalidrawBindableElement | null | undefined,
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: ElementsMap,
|
||||
zoom?: AppState["zoom"],
|
||||
): Heading =>
|
||||
getHeadingForElbowArrowSnap(
|
||||
p,
|
||||
@@ -2244,21 +2275,20 @@ const getBindPointHeading = (
|
||||
),
|
||||
origPoint,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
|
||||
const getHoveredElement = (
|
||||
origPoint: GlobalPoint,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
zoom?: AppState["zoom"],
|
||||
) => {
|
||||
return getHoveredElementForBinding(
|
||||
tupleToCoors(origPoint),
|
||||
origPoint,
|
||||
elements,
|
||||
elementsMap,
|
||||
zoom,
|
||||
true,
|
||||
true,
|
||||
maxBindingDistance_simple(zoom),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const parseYouTubeTimestamp = (url: string): number => {
|
||||
const parseYouTubeLikeTimestamp = (url: string): number => {
|
||||
let timeParam: string | null | undefined;
|
||||
|
||||
try {
|
||||
@@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => {
|
||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||
};
|
||||
|
||||
const parseGoogleDriveVideoLink = (
|
||||
url: string,
|
||||
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||
const hostname = urlObj.hostname.replace(/^www\./, "");
|
||||
if (hostname !== "drive.google.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let fileId: string | null = null;
|
||||
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
|
||||
if (pathMatch?.[1]) {
|
||||
fileId = pathMatch[1];
|
||||
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
|
||||
// Shared Drive links can be emitted as:
|
||||
// - /open?id=<fileId> (common "open in Drive" format)
|
||||
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
|
||||
fileId = urlObj.searchParams.get("id");
|
||||
}
|
||||
|
||||
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Some Drive share links include `resourcekey` for access to link-shared
|
||||
// files; preserve it in the preview URL so embeds keep working.
|
||||
const resourceKey = urlObj.searchParams.get("resourcekey");
|
||||
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
|
||||
|
||||
return {
|
||||
fileId,
|
||||
resourceKey:
|
||||
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
|
||||
? resourceKey
|
||||
: undefined,
|
||||
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
|
||||
// normalize to seconds for a stable preview URL.
|
||||
timestamp: timestamp > 0 ? timestamp : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"drive.google.com",
|
||||
"figma.com",
|
||||
"link.excalidraw.com",
|
||||
"gist.github.com",
|
||||
@@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"drive.google.com",
|
||||
"figma.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
@@ -142,7 +189,7 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const startTime = parseYouTubeTimestamp(originalLink);
|
||||
const startTime = parseYouTubeLikeTimestamp(originalLink);
|
||||
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
@@ -201,6 +248,36 @@ export const getEmbedLink = (
|
||||
};
|
||||
}
|
||||
|
||||
const googleDriveVideo = parseGoogleDriveVideoLink(link);
|
||||
if (googleDriveVideo) {
|
||||
type = "video";
|
||||
const searchParams = new URLSearchParams();
|
||||
if (googleDriveVideo.resourceKey) {
|
||||
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
|
||||
}
|
||||
if (googleDriveVideo.timestamp) {
|
||||
searchParams.set("t", `${googleDriveVideo.timestamp}`);
|
||||
}
|
||||
|
||||
const search = searchParams.toString();
|
||||
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
|
||||
search ? `?${search}` : ""
|
||||
}`;
|
||||
aspectRatio = { w: 560, h: 315 };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
if (figmaLink) {
|
||||
type = "generic";
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PendingExcalidrawElements,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { bindBindingElement } from "./binding";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
@@ -446,8 +446,14 @@ const createBindingArrow = (
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
bindLinearElement(bindingArrow, startBindingElement, "start", scene);
|
||||
bindLinearElement(bindingArrow, endBindingElement, "end", scene);
|
||||
bindBindingElement(
|
||||
bindingArrow,
|
||||
startBindingElement,
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
);
|
||||
bindBindingElement(bindingArrow, endBindingElement, "orbit", "end", scene);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
||||
import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
|
||||
|
||||
import type {
|
||||
AppClassProperties,
|
||||
@@ -18,6 +17,8 @@ import {
|
||||
getElementLineSegments,
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
doBoundsIntersect,
|
||||
getElementBounds,
|
||||
} from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
@@ -920,16 +921,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
|
||||
export const getElementsOverlappingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
elementsOverlappingBBox({
|
||||
elements,
|
||||
bounds: frame,
|
||||
type: "overlap",
|
||||
})
|
||||
// removes elements who are overlapping, but are in a different frame,
|
||||
return elements.filter(
|
||||
(el) =>
|
||||
// exclude elements which are overlapping, but are in a different frame,
|
||||
// and thus invisible in target frame
|
||||
.filter((el) => !el.frameId || el.frameId === frame.id)
|
||||
(!el.frameId || el.frameId === frame.id) &&
|
||||
doBoundsIntersect(
|
||||
getElementBounds(el, elementsMap),
|
||||
getElementBounds(frame, elementsMap),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { invariant, isDevEnv, isTestEnv } from "@excalidraw/common";
|
||||
import {
|
||||
invariant,
|
||||
isDevEnv,
|
||||
isTestEnv,
|
||||
type Bounds,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
pointFrom,
|
||||
@@ -19,7 +24,7 @@ import type {
|
||||
Vector,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
import { getCenterForBounds } from "./bounds";
|
||||
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { toIterable } from "@excalidraw/common";
|
||||
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
@@ -29,6 +28,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||
|
||||
// string hash function (using djb2). Not cryptographically secure, use only
|
||||
// for versioning and such.
|
||||
// note: hashes individual code units (not code points),
|
||||
// but for hashing purposes this is fine as it iterates through every code unit
|
||||
// (as such, no need to encode to byte string first)
|
||||
export const hashString = (s: string): number => {
|
||||
let hash: number = 5381;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
@@ -52,27 +54,6 @@ export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
||||
const _clearElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ExcalidrawElement[] =>
|
||||
getNonDeletedElements(elements).map((element) =>
|
||||
isLinearElementType(element.type)
|
||||
? { ...element, lastCommittedPoint: null }
|
||||
: element,
|
||||
);
|
||||
|
||||
export const clearElementsForDatabase = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForExport = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export const clearElementsForLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => _clearElements(elements);
|
||||
|
||||
export * from "./align";
|
||||
export * from "./binding";
|
||||
export * from "./bounds";
|
||||
@@ -89,6 +70,7 @@ export * from "./elbowArrow";
|
||||
export * from "./elementLink";
|
||||
export * from "./embeddable";
|
||||
export * from "./flowchart";
|
||||
export * from "./arrows/focus";
|
||||
export * from "./fractionalIndex";
|
||||
export * from "./frame";
|
||||
export * from "./groups";
|
||||
@@ -97,6 +79,7 @@ export * from "./image";
|
||||
export * from "./linearElementEditor";
|
||||
export * from "./mutateElement";
|
||||
export * from "./newElement";
|
||||
export * from "./positionElementsOnGrid";
|
||||
export * from "./renderElement";
|
||||
export * from "./resizeElements";
|
||||
export * from "./resizeTest";
|
||||
@@ -111,7 +94,10 @@ export * from "./store";
|
||||
export * from "./textElement";
|
||||
export * from "./textMeasurements";
|
||||
export * from "./textWrapping";
|
||||
export * from "./transform";
|
||||
export * from "./transformHandles";
|
||||
export * from "./typeChecks";
|
||||
export * from "./utils";
|
||||
export * from "./zindex";
|
||||
export * from "./arrows/helpers";
|
||||
export * from "./arrowheads";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,22 +40,21 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
updates: ElementUpdate<TElement>,
|
||||
options?: {
|
||||
isDragging?: boolean;
|
||||
isBindingEnabled?: boolean;
|
||||
isMidpointSnappingEnabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fixedSegments, startBinding, endBinding, fileId } =
|
||||
updates as any;
|
||||
const { points, fixedSegments, fileId } = updates as any;
|
||||
|
||||
if (
|
||||
isElbowArrow(element) &&
|
||||
(Object.keys(updates).length === 0 || // normalization case
|
||||
typeof points !== "undefined" || // repositioning
|
||||
typeof fixedSegments !== "undefined" || // segment fixing
|
||||
typeof startBinding !== "undefined" ||
|
||||
typeof endBinding !== "undefined") // manual binding to element
|
||||
typeof fixedSegments !== "undefined") // segment fixing
|
||||
) {
|
||||
updates = {
|
||||
...updates,
|
||||
|
||||
@@ -452,7 +452,6 @@ export const newFreeDrawElement = (
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -466,7 +465,7 @@ export const newLinearElement = (
|
||||
const element = {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: null,
|
||||
@@ -501,7 +500,6 @@ export const newArrowElement = <T extends boolean>(
|
||||
return {
|
||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
@@ -516,7 +514,6 @@ export const newArrowElement = <T extends boolean>(
|
||||
return {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { type ElementUpdate, newElementWith } from "./mutateElement";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
// TODO rewrite (mostly vibe-coded)
|
||||
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
|
||||
elements: TElement[] | TElement[][],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
padding = 50,
|
||||
): TElement[] => {
|
||||
// Ensure there are elements to position
|
||||
if (!elements || elements.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res: TElement[] = [];
|
||||
// Normalize input to work with atomic units (groups of elements)
|
||||
// If elements is a flat array, treat each element as its own atomic unit
|
||||
const atomicUnits: TElement[][] = Array.isArray(elements[0])
|
||||
? (elements as TElement[][])
|
||||
: (elements as TElement[]).map((element) => [element]);
|
||||
|
||||
// Determine the number of columns for atomic units
|
||||
// A common approach for a "grid-like" layout without specific column constraints
|
||||
// is to aim for a roughly square arrangement.
|
||||
const numUnits = atomicUnits.length;
|
||||
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
|
||||
|
||||
// Group atomic units into rows based on the calculated number of columns
|
||||
const rows: TElement[][][] = [];
|
||||
for (let i = 0; i < numUnits; i += numColumns) {
|
||||
rows.push(atomicUnits.slice(i, i + numColumns));
|
||||
}
|
||||
|
||||
// Calculate properties for each row (total width, max height)
|
||||
// and the total actual height of all row content.
|
||||
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
|
||||
const rowProperties = rows.map((rowUnits) => {
|
||||
let rowWidth = 0;
|
||||
let maxUnitHeightInRow = 0;
|
||||
|
||||
const unitBounds = rowUnits.map((unit) => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
|
||||
return {
|
||||
elements: unit,
|
||||
bounds: [minX, minY, maxX, maxY] as const,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
});
|
||||
|
||||
unitBounds.forEach((unitBound, index) => {
|
||||
rowWidth += unitBound.width;
|
||||
// Add padding between units in the same row, but not after the last one
|
||||
if (index < unitBounds.length - 1) {
|
||||
rowWidth += padding;
|
||||
}
|
||||
if (unitBound.height > maxUnitHeightInRow) {
|
||||
maxUnitHeightInRow = unitBound.height;
|
||||
}
|
||||
});
|
||||
|
||||
totalGridActualHeight += maxUnitHeightInRow;
|
||||
return {
|
||||
unitBounds,
|
||||
width: rowWidth,
|
||||
maxHeight: maxUnitHeightInRow,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate the total height of the grid including padding between rows
|
||||
const totalGridHeightWithPadding =
|
||||
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
|
||||
|
||||
// Calculate the starting Y position to center the entire grid vertically around centerY
|
||||
let currentY = centerY - totalGridHeightWithPadding / 2;
|
||||
|
||||
// Position atomic units row by row
|
||||
rowProperties.forEach((rowProp) => {
|
||||
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
|
||||
|
||||
// Calculate the starting X for the current row to center it horizontally around centerX
|
||||
let currentX = centerX - rowWidth / 2;
|
||||
|
||||
unitBounds.forEach((unitBound) => {
|
||||
// Calculate the offset needed to position this atomic unit
|
||||
const [originalMinX, originalMinY] = unitBound.bounds;
|
||||
const offsetX = currentX - originalMinX;
|
||||
const offsetY = currentY - originalMinY;
|
||||
|
||||
// Apply the offset to all elements in this atomic unit
|
||||
unitBound.elements.forEach((element) => {
|
||||
res.push(
|
||||
newElementWith(element, {
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
} as ElementUpdate<TElement>),
|
||||
);
|
||||
});
|
||||
|
||||
// Move X for the next unit in the row
|
||||
currentX += unitBound.width + padding;
|
||||
});
|
||||
|
||||
// Move Y to the starting position for the next row
|
||||
// This accounts for the tallest unit in the current row and the inter-row padding
|
||||
currentY += rowMaxHeight + padding;
|
||||
});
|
||||
return res;
|
||||
};
|
||||
@@ -1,19 +1,29 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import { isRightAngleRads } from "@excalidraw/math";
|
||||
import {
|
||||
type GlobalPoint,
|
||||
isRightAngleRads,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_REDUCED_GLOBAL_ALPHA,
|
||||
ELEMENT_READY_TO_ERASE_OPACITY,
|
||||
FRAME_STYLE,
|
||||
DARK_THEME_FILTER,
|
||||
MIME_TYPES,
|
||||
THEME,
|
||||
distance,
|
||||
getFontString,
|
||||
isRTL,
|
||||
getVerticalOffset,
|
||||
invariant,
|
||||
applyDarkModeFilter,
|
||||
isSafari,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -32,7 +42,7 @@ import type {
|
||||
InteractiveCanvasRenderConfig,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import { getUncroppedImageElement } from "./cropElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import {
|
||||
@@ -70,16 +80,8 @@ import type {
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
import type { StrokeOptions } from "perfect-freehand";
|
||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
// color scheme (it's still not quite there and the colors look slightly
|
||||
// desatured, alas...)
|
||||
export const IMAGE_INVERT_FILTER =
|
||||
"invert(100%) hue-rotate(180deg) saturate(1.25)";
|
||||
|
||||
const isPendingImageElement = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
@@ -87,19 +89,6 @@ const isPendingImageElement = (
|
||||
isInitializedImageElement(element) &&
|
||||
!renderConfig.imageCache.has(element.fileId);
|
||||
|
||||
const shouldResetImageFilter = (
|
||||
element: ExcalidrawElement,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
return (
|
||||
appState.theme === THEME.DARK &&
|
||||
isInitializedImageElement(element) &&
|
||||
!isPendingImageElement(element, renderConfig) &&
|
||||
renderConfig.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
|
||||
);
|
||||
};
|
||||
|
||||
const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
switch (element.type) {
|
||||
case "freedraw":
|
||||
@@ -217,7 +206,7 @@ const generateElementCanvas = (
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
zoom: Zoom,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
): ExcalidrawElementWithCanvas | null => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
@@ -264,12 +253,7 @@ const generateElementCanvas = (
|
||||
|
||||
const rc = rough.canvas(canvas);
|
||||
|
||||
// in dark theme, revert the image color filter
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = IMAGE_INVERT_FILTER;
|
||||
}
|
||||
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
|
||||
context.restore();
|
||||
|
||||
@@ -377,8 +361,9 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
|
||||
const drawImagePlaceholder = (
|
||||
element: ExcalidrawImageElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
theme: StaticCanvasRenderConfig["theme"],
|
||||
) => {
|
||||
context.fillStyle = "#E7E7E7";
|
||||
context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
|
||||
const imageMinWidthOrHeight = Math.min(element.width, element.height);
|
||||
@@ -404,7 +389,6 @@ const drawElementOnCanvas = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
) => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
@@ -414,7 +398,8 @@ const drawElementOnCanvas = (
|
||||
case "ellipse": {
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
rc.draw(ShapeCache.get(element)!);
|
||||
|
||||
rc.draw(ShapeCache.generateElementShape(element, renderConfig));
|
||||
break;
|
||||
}
|
||||
case "arrow":
|
||||
@@ -422,33 +407,44 @@ const drawElementOnCanvas = (
|
||||
context.lineJoin = "round";
|
||||
context.lineCap = "round";
|
||||
|
||||
ShapeCache.get(element)!.forEach((shape) => {
|
||||
rc.draw(shape);
|
||||
});
|
||||
ShapeCache.generateElementShape(element, renderConfig).forEach(
|
||||
(shape) => {
|
||||
rc.draw(shape);
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
// Draw directly to canvas
|
||||
context.save();
|
||||
context.fillStyle = element.strokeColor;
|
||||
|
||||
const path = getFreeDrawPath2D(element) as Path2D;
|
||||
const fillShape = ShapeCache.get(element);
|
||||
const shapes = ShapeCache.generateElementShape(element, renderConfig);
|
||||
|
||||
if (fillShape) {
|
||||
rc.draw(fillShape);
|
||||
for (const shape of shapes) {
|
||||
if (typeof shape === "string") {
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.fill(new Path2D(shape));
|
||||
} else {
|
||||
rc.draw(shape);
|
||||
}
|
||||
}
|
||||
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fill(path);
|
||||
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
context.save();
|
||||
const cacheEntry =
|
||||
element.fileId !== null
|
||||
? renderConfig.imageCache.get(element.fileId)
|
||||
: null;
|
||||
const img = isInitializedImageElement(element)
|
||||
? renderConfig.imageCache.get(element.fileId)?.image
|
||||
? cacheEntry?.image
|
||||
: undefined;
|
||||
|
||||
if (img != null && !(img instanceof Promise)) {
|
||||
if (element.roundness && context.roundRect) {
|
||||
context.beginPath();
|
||||
@@ -471,20 +467,80 @@ const drawElementOnCanvas = (
|
||||
height: img.naturalHeight,
|
||||
};
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0 /* hardcoded for the selection box*/,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
const shouldInvertImage =
|
||||
renderConfig.theme === THEME.DARK &&
|
||||
cacheEntry?.mimeType === MIME_TYPES.svg;
|
||||
|
||||
if (shouldInvertImage && isSafari) {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = element.width * devicePixelRatio;
|
||||
tempCanvas.height = element.height * devicePixelRatio;
|
||||
const tempContext = tempCanvas.getContext("2d");
|
||||
|
||||
if (tempContext) {
|
||||
tempContext.scale(devicePixelRatio, devicePixelRatio);
|
||||
tempContext.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
|
||||
const imageData = tempContext.getImageData(
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
);
|
||||
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 255 - data[i];
|
||||
data[i + 1] = 255 - data[i + 1];
|
||||
data[i + 2] = 255 - data[i + 2];
|
||||
}
|
||||
|
||||
tempContext.putImageData(imageData, 0, 0);
|
||||
context.drawImage(
|
||||
tempCanvas,
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height,
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (shouldInvertImage) {
|
||||
context.filter = DARK_THEME_FILTER;
|
||||
}
|
||||
|
||||
context.drawImage(
|
||||
img,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0 /* hardcoded for the selection box*/,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
drawImagePlaceholder(element, context);
|
||||
drawImagePlaceholder(element, context, renderConfig.theme);
|
||||
}
|
||||
context.restore();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -499,7 +555,10 @@ const drawElementOnCanvas = (
|
||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||
context.save();
|
||||
context.font = getFontString(element);
|
||||
context.fillStyle = element.strokeColor;
|
||||
context.fillStyle =
|
||||
renderConfig.theme === THEME.DARK
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||
|
||||
// Canvas does not support multiline text by default
|
||||
@@ -550,7 +609,7 @@ const generateElementWithCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
const zoom: Zoom = renderConfig
|
||||
? appState.zoom
|
||||
@@ -607,7 +666,7 @@ const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
allElementsMap: NonDeletedSceneElementsMap,
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
@@ -725,7 +784,7 @@ export const renderElement = (
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
appState: StaticCanvasAppState,
|
||||
appState: StaticCanvasAppState | InteractiveCanvasAppState,
|
||||
) => {
|
||||
const reduceAlphaForSelection =
|
||||
appState.openDialog?.name === "elementLinkSelector" &&
|
||||
@@ -752,12 +811,17 @@ export const renderElement = (
|
||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||
|
||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.DARK
|
||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
||||
: FRAME_STYLE.strokeColor;
|
||||
|
||||
// TODO change later to only affect AI frames
|
||||
if (isMagicFrameElement(element)) {
|
||||
context.strokeStyle =
|
||||
appState.theme === THEME.LIGHT ? "#7affd7" : "#1d8264";
|
||||
appState.theme === THEME.LIGHT
|
||||
? "#7affd7"
|
||||
: applyDarkModeFilter("#1d8264");
|
||||
}
|
||||
|
||||
if (FRAME_STYLE.radius && context.roundRect) {
|
||||
@@ -780,11 +844,6 @@ export const renderElement = (
|
||||
break;
|
||||
}
|
||||
case "freedraw": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, null);
|
||||
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
@@ -795,7 +854,7 @@ export const renderElement = (
|
||||
context.translate(cx, cy);
|
||||
context.rotate(element.angle);
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
context.restore();
|
||||
} else {
|
||||
const elementWithCanvas = generateElementWithCanvas(
|
||||
@@ -828,10 +887,6 @@ export const renderElement = (
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
// TODO investigate if we can do this in situ. Right now we need to call
|
||||
// beforehand because math helpers (such as getElementAbsoluteCoords)
|
||||
// rely on existing shapes
|
||||
ShapeCache.generateElementShape(element, renderConfig);
|
||||
if (renderConfig.isExporting) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const cx = (x1 + x2) / 2 + appState.scrollX;
|
||||
@@ -854,9 +909,6 @@ export const renderElement = (
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
|
||||
if (shouldResetImageFilter(element, renderConfig, appState)) {
|
||||
context.filter = "none";
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (isArrowElement(element) && boundTextElement) {
|
||||
@@ -888,13 +940,7 @@ export const renderElement = (
|
||||
|
||||
tempCanvasContext.translate(-shiftX, -shiftY);
|
||||
|
||||
drawElementOnCanvas(
|
||||
element,
|
||||
tempRc,
|
||||
tempCanvasContext,
|
||||
renderConfig,
|
||||
appState,
|
||||
);
|
||||
drawElementOnCanvas(element, tempRc, tempCanvasContext, renderConfig);
|
||||
|
||||
tempCanvasContext.translate(shiftX, shiftY);
|
||||
|
||||
@@ -933,7 +979,7 @@ export const renderElement = (
|
||||
}
|
||||
|
||||
context.translate(-shiftX, -shiftY);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig, appState);
|
||||
drawElementOnCanvas(element, rc, context, renderConfig);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
@@ -1025,69 +1071,58 @@ export const renderElement = (
|
||||
context.globalAlpha = 1;
|
||||
};
|
||||
|
||||
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
|
||||
export function getFreedrawOutlineAsSegments(
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
points: [number, number][],
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
const bounds = getElementBounds(
|
||||
{
|
||||
...element,
|
||||
angle: 0 as Radians,
|
||||
},
|
||||
elementsMap,
|
||||
);
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
(bounds[0] + bounds[2]) / 2,
|
||||
(bounds[1] + bounds[3]) / 2,
|
||||
);
|
||||
|
||||
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
|
||||
const svgPathData = getFreeDrawSvgPath(element);
|
||||
const path = new Path2D(svgPathData);
|
||||
pathsCache.set(element, path);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
|
||||
return pathsCache.get(element);
|
||||
}
|
||||
|
||||
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
// Consider changing the options for simulated pressure vs real pressure
|
||||
const options: StrokeOptions = {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
|
||||
};
|
||||
|
||||
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
|
||||
}
|
||||
|
||||
function med(A: number[], B: number[]) {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
}
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
function getSvgPathFromStroke(points: number[][]): string {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
|
||||
|
||||
return points.slice(2).reduce(
|
||||
(acc, curr) => {
|
||||
acc.push(
|
||||
lineSegment<GlobalPoint>(
|
||||
acc[acc.length - 1][1],
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
[
|
||||
lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
points[0][0] + element.x,
|
||||
points[0][1] + element.y,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
points[1][0] + element.x,
|
||||
points[1][1] + element.y,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ import type { PointerDownState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getArrowLocalFixedPoints, updateBoundElements } from "./binding";
|
||||
import {
|
||||
getArrowLocalFixedPoints,
|
||||
unbindBindingElement,
|
||||
updateBoundElements,
|
||||
} from "./binding";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
getMinTextElementWidth,
|
||||
@@ -45,6 +50,7 @@ import {
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBindingElement,
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
@@ -73,7 +79,9 @@ import type {
|
||||
ExcalidrawImageElement,
|
||||
ElementsMap,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawArrowElement,
|
||||
} from "./types";
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
|
||||
// Returns true when transform (resizing/rotation) happened
|
||||
export const transformElements = (
|
||||
@@ -219,13 +227,40 @@ const rotateSingleElement = (
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
scene.mutateElement(element, { angle });
|
||||
let update: ElementUpdate<NonDeletedExcalidrawElement> = {
|
||||
angle,
|
||||
};
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
update = {
|
||||
...update,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
|
||||
if (element.startBinding) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
if (element.endBinding) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
scene.mutateElement(textElement, { angle });
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
textElement,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
scene.mutateElement(textElement, {
|
||||
angle,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -384,6 +419,11 @@ const rotateMultipleElements = (
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
|
||||
const rotatedElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
>(elements.map((element) => [element.id, element]));
|
||||
|
||||
for (const element of elements) {
|
||||
if (!isFrameLikeElement(element)) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
@@ -414,11 +454,30 @@ const rotateMultipleElements = (
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (!rotatedElementsMap.has(element.startBinding.elementId)) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
}
|
||||
if (element.endBinding) {
|
||||
if (!rotatedElementsMap.has(element.endBinding.elementId)) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundText,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
scene.mutateElement(boundText, {
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
x,
|
||||
y,
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
});
|
||||
}
|
||||
@@ -819,13 +878,32 @@ export const resizeSingleElement = (
|
||||
Number.isFinite(newOrigin.x) &&
|
||||
Number.isFinite(newOrigin.y)
|
||||
) {
|
||||
const updates = {
|
||||
let updates: ElementUpdate<ExcalidrawElement> = {
|
||||
...newOrigin,
|
||||
width: Math.abs(nextWidth),
|
||||
height: Math.abs(nextHeight),
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
if (isBindingElement(latestElement)) {
|
||||
if (latestElement.startBinding) {
|
||||
updates = {
|
||||
...updates,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
|
||||
if (latestElement.startBinding) {
|
||||
unbindBindingElement(latestElement, "start", scene);
|
||||
}
|
||||
}
|
||||
|
||||
if (latestElement.endBinding) {
|
||||
updates = {
|
||||
...updates,
|
||||
endBinding: null,
|
||||
} as ElementUpdate<ExcalidrawArrowElement>;
|
||||
}
|
||||
}
|
||||
|
||||
scene.mutateElement(latestElement, updates, {
|
||||
informMutation: shouldInformMutation,
|
||||
isDragging: false,
|
||||
@@ -843,10 +921,7 @@ export const resizeSingleElement = (
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
|
||||
updateBoundElements(latestElement, scene, {
|
||||
// TODO: confirm with MARK if this actually makes sense
|
||||
newSize: { width: nextWidth, height: nextHeight },
|
||||
});
|
||||
updateBoundElements(latestElement, scene);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1380,20 +1455,36 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
const resizedElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
>(elementsAndUpdates.map(({ element }) => [element.id, element]));
|
||||
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
const { angle } = update;
|
||||
|
||||
scene.mutateElement(element, update);
|
||||
|
||||
updateBoundElements(element, scene, {
|
||||
simultaneouslyUpdated: elementsToUpdate,
|
||||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (isBindingElement(element)) {
|
||||
if (element.startBinding) {
|
||||
if (!resizedElementsMap.has(element.startBinding.elementId)) {
|
||||
unbindBindingElement(element, "start", scene);
|
||||
}
|
||||
}
|
||||
if (element.endBinding) {
|
||||
if (!resizedElementsMap.has(element.endBinding.elementId)) {
|
||||
unbindBindingElement(element, "end", scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
scene.mutateElement(boundTextElement, {
|
||||
|
||||
@@ -5,22 +5,25 @@ import {
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { SIDE_RESIZING_THRESHOLD } from "@excalidraw/common";
|
||||
import {
|
||||
SIDE_RESIZING_THRESHOLD,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Device, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type { AppState, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
getOmitSidesForDevice,
|
||||
getOmitSidesForEditorInterface,
|
||||
canResizeFromSides,
|
||||
} from "./transformHandles";
|
||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
@@ -51,7 +54,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
y: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
editorInterface: EditorInterface,
|
||||
): MaybeTransformHandleType => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
@@ -63,7 +66,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
zoom,
|
||||
elementsMap,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
getOmitSidesForEditorInterface(editorInterface),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -86,7 +89,7 @@ export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
return filter[0] as TransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
@@ -132,7 +135,7 @@ export const getElementWithTransformHandleType = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
elementsMap: ElementsMap,
|
||||
device: Device,
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
return elements.reduce((result, element) => {
|
||||
if (result) {
|
||||
@@ -146,7 +149,7 @@ export const getElementWithTransformHandleType = (
|
||||
scenePointerY,
|
||||
zoom,
|
||||
pointerType,
|
||||
device,
|
||||
editorInterface,
|
||||
);
|
||||
return transformHandleType ? { element, transformHandleType } : null;
|
||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||
@@ -160,14 +163,14 @@ export const getTransformHandleTypeFromCoords = <
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
editorInterface: EditorInterface,
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0 as Radians,
|
||||
zoom,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
getOmitSidesForEditorInterface(editorInterface),
|
||||
);
|
||||
|
||||
const found = Object.keys(transformHandles).find((key) => {
|
||||
@@ -183,7 +186,7 @@ export const getTransformHandleTypeFromCoords = <
|
||||
return found as MaybeTransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
|
||||
@@ -1,33 +1,71 @@
|
||||
import { arrayToMap, isShallowEqual } from "@excalidraw/common";
|
||||
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
||||
import {
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
AppState,
|
||||
BoxSelectionMode,
|
||||
InteractiveCanvasAppState,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
import {
|
||||
boundsContainBounds,
|
||||
doBoundsIntersect,
|
||||
elementCenterPoint,
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
pointInsideBounds,
|
||||
} from "./bounds";
|
||||
import { intersectElementWithLineSegment } from "./collision";
|
||||
import { isElementInViewport } from "./sizeHelpers";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameLikeElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
elementOverlapsWithFrame,
|
||||
getContainingFrame,
|
||||
getFrameChildren,
|
||||
isElementIntersectingFrame,
|
||||
} from "./frame";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { selectGroupsForSelectedElements } from "./groups";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ElementsMapOrArray,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
const shouldIgnoreElementFromSelection = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
) => element.locked || isBoundToContainer(element);
|
||||
|
||||
const excludeElementsFromFrames = <T extends ExcalidrawElement>(
|
||||
selectedElements: readonly T[],
|
||||
framesInSelection: Set<ExcalidrawFrameLikeElement["id"]>,
|
||||
) => {
|
||||
return selectedElements.filter((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Frames and their containing elements are not to be selected at the same time.
|
||||
* Given an array of selected elements, if there are frames and their containing elements
|
||||
@@ -47,55 +85,243 @@ export const excludeElementsInFramesFromSelection = <
|
||||
}
|
||||
});
|
||||
|
||||
return selectedElements.filter((element) => {
|
||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return excludeElementsFromFrames(selectedElements, framesInSelection);
|
||||
};
|
||||
|
||||
export const getElementsWithinSelection = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selection: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
// TODO remove (this flag is effectively unused AFAIK)
|
||||
excludeElementsInFrames: boolean = true,
|
||||
) => {
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||
boxSelectionMode: BoxSelectionMode = "contain",
|
||||
): NonDeletedExcalidrawElement[] => {
|
||||
const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
|
||||
getElementAbsoluteCoords(selection, elementsMap);
|
||||
const selectionX1 = Math.min(selectionStartX, selectionEndX);
|
||||
const selectionY1 = Math.min(selectionStartY, selectionEndY);
|
||||
const selectionX2 = Math.max(selectionStartX, selectionEndX);
|
||||
const selectionY2 = Math.max(selectionStartY, selectionEndY);
|
||||
const selectionBounds = [
|
||||
selectionX1,
|
||||
selectionY1,
|
||||
selectionX2,
|
||||
selectionY2,
|
||||
] as Bounds;
|
||||
const selectionEdges = [
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY1),
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX2, selectionY2),
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
),
|
||||
lineSegment<GlobalPoint>(
|
||||
pointFrom(selectionX1, selectionY2),
|
||||
pointFrom(selectionX1, selectionY1),
|
||||
),
|
||||
];
|
||||
|
||||
let elementsInSelection = elements.filter((element) => {
|
||||
let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
const framesInSelection = excludeElementsInFrames
|
||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||
: null;
|
||||
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
|
||||
|
||||
const containingFrame = getContainingFrame(element, elementsMap);
|
||||
if (containingFrame) {
|
||||
const [fx1, fy1, fx2, fy2] = getElementBounds(
|
||||
containingFrame,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
elementX1 = Math.max(fx1, elementX1);
|
||||
elementY1 = Math.max(fy1, elementY1);
|
||||
elementX2 = Math.min(fx2, elementX2);
|
||||
elementY2 = Math.min(fy2, elementY2);
|
||||
for (const element of elements) {
|
||||
if (shouldIgnoreElementFromSelection(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (
|
||||
element.locked === false &&
|
||||
element.type !== "selection" &&
|
||||
!isBoundToContainer(element) &&
|
||||
selectionX1 <= elementX1 &&
|
||||
selectionY1 <= elementY1 &&
|
||||
selectionX2 >= elementX2 &&
|
||||
selectionY2 >= elementY2
|
||||
);
|
||||
});
|
||||
const strokeWidth = element.strokeWidth;
|
||||
let labelAABB: Bounds | null = null;
|
||||
let elementAABB = getElementBounds(element, elementsMap);
|
||||
|
||||
elementsInSelection = excludeElementsInFrames
|
||||
? excludeElementsInFramesFromSelection(elementsInSelection)
|
||||
elementAABB = [
|
||||
elementAABB[0] - strokeWidth / 2,
|
||||
elementAABB[1] - strokeWidth / 2,
|
||||
elementAABB[2] + strokeWidth / 2,
|
||||
elementAABB[3] + strokeWidth / 2,
|
||||
] as Bounds;
|
||||
|
||||
// Whether the element bounds should include the bound text element bounds
|
||||
const boundTextElement =
|
||||
isArrowElement(element) && getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
labelAABB = [
|
||||
x,
|
||||
y,
|
||||
x + boundTextElement.width,
|
||||
y + boundTextElement.height,
|
||||
] as Bounds;
|
||||
}
|
||||
|
||||
// Clip element bounds by its containing frame (if any), since only the
|
||||
// visible (frame-clipped) portion of the element is relevant for selection.
|
||||
const associatedFrame = getContainingFrame(element, elementsMap);
|
||||
if (
|
||||
associatedFrame &&
|
||||
isElementIntersectingFrame(element, associatedFrame, elementsMap)
|
||||
) {
|
||||
const frameAABB = getElementBounds(associatedFrame, elementsMap);
|
||||
elementAABB = [
|
||||
Math.max(elementAABB[0], frameAABB[0]),
|
||||
Math.max(elementAABB[1], frameAABB[1]),
|
||||
Math.min(elementAABB[2], frameAABB[2]),
|
||||
Math.min(elementAABB[3], frameAABB[3]),
|
||||
] as Bounds;
|
||||
|
||||
labelAABB = labelAABB
|
||||
? ([
|
||||
Math.max(labelAABB[0], frameAABB[0]),
|
||||
Math.max(labelAABB[1], frameAABB[1]),
|
||||
Math.min(labelAABB[2], frameAABB[2]),
|
||||
Math.min(labelAABB[3], frameAABB[3]),
|
||||
] as Bounds)
|
||||
: null;
|
||||
}
|
||||
|
||||
const commonAABB = labelAABB
|
||||
? ([
|
||||
Math.min(labelAABB[0], elementAABB[0]),
|
||||
Math.min(labelAABB[1], elementAABB[1]),
|
||||
Math.max(labelAABB[2], elementAABB[2]),
|
||||
Math.max(labelAABB[3], elementAABB[3]),
|
||||
] as Bounds)
|
||||
: elementAABB;
|
||||
|
||||
// ============== Evaluation ==============
|
||||
|
||||
// 1. If the selection box WRAPs the element's AABB, then add it to the
|
||||
// selection and move on, regardless of the selection mode.
|
||||
//
|
||||
// PERF: This trick only works with axis-aligned box selection and the
|
||||
// current convex element shapes!
|
||||
if (boundsContainBounds(selectionBounds, commonAABB)) {
|
||||
if (framesInSelection && isFrameLikeElement(element)) {
|
||||
framesInSelection.add(element.id);
|
||||
} else {
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle the case where the label is overlapped by the selection box
|
||||
if (
|
||||
boxSelectionMode === "overlap" &&
|
||||
labelAABB &&
|
||||
doBoundsIntersect(selectionBounds, labelAABB)
|
||||
) {
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Handle the case where the selection is not wrapping the element, but
|
||||
// it does intersect the element's outline (non-AABB).
|
||||
if (
|
||||
boxSelectionMode === "overlap" &&
|
||||
doBoundsIntersect(selectionBounds, elementAABB)
|
||||
) {
|
||||
let hasIntersection = false;
|
||||
|
||||
// Preliminary check potential intersection imprecision
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
hasIntersection = element.points.some((point) => {
|
||||
const rotatedPoint = pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
return pointInsideBounds(rotatedPoint, selectionBounds);
|
||||
});
|
||||
} else {
|
||||
const nonRotatedElementBounds = getElementBounds(
|
||||
element,
|
||||
elementsMap,
|
||||
true,
|
||||
);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
hasIntersection = [
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||
nonRotatedElementBounds[1],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
nonRotatedElementBounds[2],
|
||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||
nonRotatedElementBounds[3],
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
nonRotatedElementBounds[0],
|
||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
].some((point) => {
|
||||
return pointInsideBounds(
|
||||
pointRotateRads(point, center, element.angle),
|
||||
selectionBounds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasIntersection) {
|
||||
hasIntersection = selectionEdges.some(
|
||||
(selectionEdge) =>
|
||||
intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
selectionEdge,
|
||||
strokeWidth / 2,
|
||||
true, // Stop at first hit for better performance
|
||||
).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasIntersection) {
|
||||
if (framesInSelection && isFrameLikeElement(element)) {
|
||||
framesInSelection.add(element.id);
|
||||
}
|
||||
|
||||
elementsInSelection.push(element);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. We don't need to handle when the selection is inside the element
|
||||
// as it is separately handled in App.
|
||||
}
|
||||
|
||||
elementsInSelection = framesInSelection
|
||||
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
|
||||
: elementsInSelection;
|
||||
|
||||
elementsInSelection = elementsInSelection.filter((element) => {
|
||||
@@ -288,3 +514,19 @@ export const getSelectionStateForElements = (
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns editing or single-selected text element, if any.
|
||||
*/
|
||||
export const getActiveTextElement = (
|
||||
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: Pick<AppState, "editingTextElement">,
|
||||
) => {
|
||||
const activeTextElement =
|
||||
appState.editingTextElement ||
|
||||
(selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
selectedElements[0]);
|
||||
|
||||
return activeTextElement || null;
|
||||
};
|
||||
|
||||
+369
-140
@@ -1,4 +1,5 @@
|
||||
import { simplify } from "points-on-curve";
|
||||
import { getStroke } from "perfect-freehand";
|
||||
|
||||
import {
|
||||
type GeometricShape,
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ROUGHNESS,
|
||||
THEME,
|
||||
isTransparent,
|
||||
assertNever,
|
||||
COLOR_PALETTE,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
applyDarkModeFilter,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
@@ -36,6 +39,7 @@ import type {
|
||||
import type {
|
||||
ElementShape,
|
||||
ElementShapes,
|
||||
SVGPathString,
|
||||
} from "@excalidraw/excalidraw/scene/types";
|
||||
|
||||
import { elementWithCanvasCache } from "./renderElement";
|
||||
@@ -52,10 +56,9 @@ import { getCornerRadius, isPathALoop } from "./utils";
|
||||
import { headingForPointIsHorizontal } from "./heading";
|
||||
|
||||
import { canChangeRoundness } from "./comparisons";
|
||||
import { generateFreeDrawShape } from "./renderElement";
|
||||
import {
|
||||
elementCenterPoint,
|
||||
getArrowheadPoints,
|
||||
getCenterForBounds,
|
||||
getDiamondPoints,
|
||||
getElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
@@ -66,10 +69,10 @@ import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ElementsMap,
|
||||
ExcalidrawLineElement,
|
||||
Arrowhead,
|
||||
} from "./types";
|
||||
|
||||
import type { Drawable, Options } from "roughjs/bin/core";
|
||||
@@ -77,29 +80,32 @@ import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
|
||||
export class ShapeCache {
|
||||
private static rg = new RoughGenerator();
|
||||
private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
|
||||
private static cache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{ shape: ElementShape; theme: AppState["theme"] }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Retrieves shape from cache if available. Use this only if shape
|
||||
* is optional and you have a fallback in case it's not cached.
|
||||
*/
|
||||
public static get = <T extends ExcalidrawElement>(element: T) => {
|
||||
return ShapeCache.cache.get(
|
||||
element,
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
public static get = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
theme: AppState["theme"] | null,
|
||||
) => {
|
||||
const cached = ShapeCache.cache.get(element);
|
||||
if (cached && (theme === null || cached.theme === theme)) {
|
||||
return cached.shape as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]] | undefined
|
||||
: ElementShape | undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
public static set = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
shape: T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable,
|
||||
) => ShapeCache.cache.set(element, shape);
|
||||
|
||||
public static delete = (element: ExcalidrawElement) =>
|
||||
public static delete = (element: ExcalidrawElement) => {
|
||||
ShapeCache.cache.delete(element);
|
||||
elementWithCanvasCache.delete(element);
|
||||
};
|
||||
|
||||
public static destroy = () => {
|
||||
ShapeCache.cache = new WeakMap();
|
||||
@@ -117,12 +123,13 @@ export class ShapeCache {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: AppState["viewBackgroundColor"];
|
||||
embedsValidationStatus: EmbedsValidationStatus;
|
||||
theme: AppState["theme"];
|
||||
} | null,
|
||||
) => {
|
||||
// when exporting, always regenerated to guarantee the latest shape
|
||||
const cachedShape = renderConfig?.isExporting
|
||||
? undefined
|
||||
: ShapeCache.get(element);
|
||||
: ShapeCache.get(element, renderConfig ? renderConfig.theme : null);
|
||||
|
||||
// `null` indicates no rc shape applicable for this element type,
|
||||
// but it's considered a valid cache value (= do not regenerate)
|
||||
@@ -132,19 +139,25 @@ export class ShapeCache {
|
||||
|
||||
elementWithCanvasCache.delete(element);
|
||||
|
||||
const shape = generateElementShape(
|
||||
const shape = _generateElementShape(
|
||||
element,
|
||||
ShapeCache.rg,
|
||||
renderConfig || {
|
||||
isExporting: false,
|
||||
canvasBackgroundColor: COLOR_PALETTE.white,
|
||||
embedsValidationStatus: null,
|
||||
theme: THEME.LIGHT,
|
||||
},
|
||||
) as T["type"] extends keyof ElementShapes
|
||||
? ElementShapes[T["type"]]
|
||||
: Drawable | null;
|
||||
|
||||
ShapeCache.cache.set(element, shape);
|
||||
if (!renderConfig?.isExporting) {
|
||||
ShapeCache.cache.set(element, {
|
||||
shape,
|
||||
theme: renderConfig?.theme || THEME.LIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
return shape;
|
||||
};
|
||||
@@ -180,6 +193,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
|
||||
export const generateRoughOptions = (
|
||||
element: ExcalidrawElement,
|
||||
continuousPath = false,
|
||||
isDarkMode: boolean = false,
|
||||
): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
@@ -204,7 +218,9 @@ export const generateRoughOptions = (
|
||||
fillWeight: element.strokeWidth / 2,
|
||||
hachureGap: element.strokeWidth * 4,
|
||||
roughness: adjustRoughness(element),
|
||||
stroke: element.strokeColor,
|
||||
stroke: isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor,
|
||||
preserveVertices:
|
||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||
};
|
||||
@@ -218,6 +234,8 @@ export const generateRoughOptions = (
|
||||
options.fillStyle = element.fillStyle;
|
||||
options.fill = isTransparent(element.backgroundColor)
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
if (element.type === "ellipse") {
|
||||
options.curveFitting = 1;
|
||||
@@ -231,6 +249,8 @@ export const generateRoughOptions = (
|
||||
options.fill =
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: isDarkMode
|
||||
? applyDarkModeFilter(element.backgroundColor)
|
||||
: element.backgroundColor;
|
||||
}
|
||||
return options;
|
||||
@@ -276,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
|
||||
return element;
|
||||
};
|
||||
|
||||
const generateArrowheadCardinalityOne = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, lineOptions)];
|
||||
};
|
||||
|
||||
const generateArrowheadLinesToTip = (
|
||||
generator: RoughGenerator,
|
||||
arrowheadPoints: number[] | null,
|
||||
lineOptions: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, lineOptions),
|
||||
generator.line(x4, y4, x2, y2, lineOptions),
|
||||
];
|
||||
};
|
||||
|
||||
const getArrowheadLineOptions = (
|
||||
element: ExcalidrawLinearElement,
|
||||
options: Options,
|
||||
) => {
|
||||
const lineOptions = { ...options };
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete lineOptions.strokeLineDash;
|
||||
}
|
||||
lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
|
||||
|
||||
return lineOptions;
|
||||
};
|
||||
|
||||
const generateArrowheadOutlineCircle = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
strokeColor: string,
|
||||
arrowheadPoints: number[] | null,
|
||||
fill: string,
|
||||
diameterScale = 1,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
const circleOptions = {
|
||||
...options,
|
||||
fill,
|
||||
fillStyle: "solid" as const,
|
||||
stroke: strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
};
|
||||
|
||||
delete circleOptions.strokeLineDash;
|
||||
|
||||
return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
|
||||
};
|
||||
|
||||
const getArrowheadShapes = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
@@ -284,60 +380,56 @@ const getArrowheadShapes = (
|
||||
generator: RoughGenerator,
|
||||
options: Options,
|
||||
canvasBackgroundColor: string,
|
||||
isDarkMode: boolean,
|
||||
) => {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
if (arrowhead === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const generateCrowfootOne = (
|
||||
arrowheadPoints: number[] | null,
|
||||
options: Options,
|
||||
) => {
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [, , x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
return [generator.line(x3, y3, x4, y4, options)];
|
||||
};
|
||||
const strokeColor = isDarkMode
|
||||
? applyDarkModeFilter(element.strokeColor)
|
||||
: element.strokeColor;
|
||||
const backgroundFillColor = isDarkMode
|
||||
? applyDarkModeFilter(canvasBackgroundColor)
|
||||
: canvasBackgroundColor;
|
||||
const cardinalityOneOrManyOffset = -0.25;
|
||||
const cardinalityZeroCircleScale = 0.8;
|
||||
|
||||
switch (arrowhead) {
|
||||
case "dot":
|
||||
case "circle":
|
||||
case "circle_outline": {
|
||||
const [x, y, diameter] = arrowheadPoints;
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.circle(x, y, diameter, {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "circle_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
|
||||
fillStyle: "solid",
|
||||
stroke: element.strokeColor,
|
||||
roughness: Math.min(0.5, options.roughness || 0),
|
||||
}),
|
||||
];
|
||||
return generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
|
||||
);
|
||||
}
|
||||
case "triangle":
|
||||
case "triangle_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
|
||||
const triangleOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
delete triangleOptions.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -347,24 +439,34 @@ const getArrowheadShapes = (
|
||||
[x3, y3],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "triangle_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
triangleOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "diamond":
|
||||
case "diamond_outline": {
|
||||
const arrowheadPoints = getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
arrowhead,
|
||||
);
|
||||
|
||||
if (arrowheadPoints === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
const diamondOptions = {
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
|
||||
fillStyle: "solid" as const,
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
};
|
||||
|
||||
// always use solid stroke for arrowhead
|
||||
delete options.strokeLineDash;
|
||||
delete diamondOptions.strokeLineDash;
|
||||
|
||||
return [
|
||||
generator.polygon(
|
||||
@@ -375,53 +477,117 @@ const getArrowheadShapes = (
|
||||
[x4, y4],
|
||||
[x, y],
|
||||
],
|
||||
{
|
||||
...options,
|
||||
fill:
|
||||
arrowhead === "diamond_outline"
|
||||
? canvasBackgroundColor
|
||||
: element.strokeColor,
|
||||
fillStyle: "solid",
|
||||
roughness: Math.min(1, options.roughness || 0),
|
||||
},
|
||||
diamondOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_one":
|
||||
return generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_many":
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
case "cardinality_one_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(
|
||||
element,
|
||||
shape,
|
||||
position,
|
||||
"cardinality_one",
|
||||
cardinalityOneOrManyOffset,
|
||||
),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_exactly_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one"),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_one": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
...generateArrowheadCardinalityOne(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
|
||||
lineOptions,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "cardinality_zero_or_many": {
|
||||
const lineOptions = getArrowheadLineOptions(element, options);
|
||||
|
||||
return [
|
||||
...generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, "cardinality_many"),
|
||||
lineOptions,
|
||||
),
|
||||
...generateArrowheadOutlineCircle(
|
||||
generator,
|
||||
options,
|
||||
strokeColor,
|
||||
getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
|
||||
backgroundFillColor,
|
||||
cardinalityZeroCircleScale,
|
||||
),
|
||||
];
|
||||
}
|
||||
case "crowfoot_one":
|
||||
return generateCrowfootOne(arrowheadPoints, options);
|
||||
case "bar":
|
||||
case "arrow":
|
||||
case "crowfoot_many":
|
||||
case "crowfoot_one_or_many":
|
||||
default: {
|
||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||
|
||||
if (element.strokeStyle === "dotted") {
|
||||
// for dotted arrows caps, reduce gap to make it more legible
|
||||
const dash = getDashArrayDotted(element.strokeWidth - 1);
|
||||
options.strokeLineDash = [dash[0], dash[1] - 1];
|
||||
} else {
|
||||
// for solid/dashed, keep solid arrow cap
|
||||
delete options.strokeLineDash;
|
||||
}
|
||||
options.roughness = Math.min(1, options.roughness || 0);
|
||||
return [
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
...(arrowhead === "crowfoot_one_or_many"
|
||||
? generateCrowfootOne(
|
||||
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||
options,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
return generateArrowheadLinesToTip(
|
||||
generator,
|
||||
getArrowheadPoints(element, shape, position, arrowhead),
|
||||
getArrowheadLineOptions(element, options),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const generateLinearCollisionShape = (
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
) => {
|
||||
elementsMap: ElementsMap,
|
||||
): {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[] => {
|
||||
const generator = new RoughGenerator();
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
@@ -430,20 +596,7 @@ export const generateLinearCollisionShape = (
|
||||
roughness: 0,
|
||||
preserveVertices: true,
|
||||
};
|
||||
const center = getCenterForBounds(
|
||||
// Need a non-rotated center point
|
||||
element.points.reduce(
|
||||
(acc, point) => {
|
||||
return [
|
||||
Math.min(element.x + point[0], acc[0]),
|
||||
Math.min(element.y + point[1], acc[1]),
|
||||
Math.max(element.x + point[0], acc[2]),
|
||||
Math.max(element.y + point[1], acc[3]),
|
||||
];
|
||||
},
|
||||
[Infinity, Infinity, -Infinity, -Infinity],
|
||||
),
|
||||
);
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
|
||||
switch (element.type) {
|
||||
case "line":
|
||||
@@ -602,19 +755,22 @@ export const generateLinearCollisionShape = (
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
const generateElementShape = (
|
||||
const _generateElementShape = (
|
||||
element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
generator: RoughGenerator,
|
||||
{
|
||||
isExporting,
|
||||
canvasBackgroundColor,
|
||||
embedsValidationStatus,
|
||||
theme,
|
||||
}: {
|
||||
isExporting: boolean;
|
||||
canvasBackgroundColor: string;
|
||||
embedsValidationStatus: EmbedsValidationStatus | null;
|
||||
theme?: AppState["theme"];
|
||||
},
|
||||
): Drawable | Drawable[] | null => {
|
||||
): ElementShape => {
|
||||
const isDarkMode = theme === THEME.DARK;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
@@ -640,6 +796,7 @@ const generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
true,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -655,6 +812,7 @@ const generateElementShape = (
|
||||
embedsValidationStatus,
|
||||
),
|
||||
false,
|
||||
isDarkMode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -692,7 +850,7 @@ const generateElementShape = (
|
||||
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
|
||||
topY + horizontalRadius
|
||||
}`,
|
||||
generateRoughOptions(element, true),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
);
|
||||
} else {
|
||||
shape = generator.polygon(
|
||||
@@ -702,7 +860,7 @@ const generateElementShape = (
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
generateRoughOptions(element),
|
||||
generateRoughOptions(element, false, isDarkMode),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
@@ -713,14 +871,14 @@ const generateElementShape = (
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
generateRoughOptions(element),
|
||||
generateRoughOptions(element, false, isDarkMode),
|
||||
);
|
||||
return shape;
|
||||
}
|
||||
case "line":
|
||||
case "arrow": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
const options = generateRoughOptions(element);
|
||||
const options = generateRoughOptions(element, false, isDarkMode);
|
||||
|
||||
// points array can be empty in the beginning, so it is important to add
|
||||
// initial position to it
|
||||
@@ -745,7 +903,7 @@ const generateElementShape = (
|
||||
shape = [
|
||||
generator.path(
|
||||
generateElbowArrowShape(points, 16),
|
||||
generateRoughOptions(element, true),
|
||||
generateRoughOptions(element, true, isDarkMode),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -778,6 +936,7 @@ const generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -795,6 +954,7 @@ const generateElementShape = (
|
||||
generator,
|
||||
options,
|
||||
canvasBackgroundColor,
|
||||
isDarkMode,
|
||||
);
|
||||
shape.push(...shapes);
|
||||
}
|
||||
@@ -802,23 +962,28 @@ const generateElementShape = (
|
||||
return shape;
|
||||
}
|
||||
case "freedraw": {
|
||||
let shape: ElementShapes[typeof element.type];
|
||||
generateFreeDrawShape(element);
|
||||
// oredered in terms of z-index [background, stroke]
|
||||
const shapes: ElementShapes[typeof element.type] = [];
|
||||
|
||||
// (1) background fill (rc shape), optional
|
||||
if (isPathALoop(element.points)) {
|
||||
// generate rough polygon to fill freedraw shape
|
||||
const simplifiedPoints = simplify(
|
||||
element.points as Mutable<LocalPoint[]>,
|
||||
0.75,
|
||||
);
|
||||
shape = generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element),
|
||||
stroke: "none",
|
||||
});
|
||||
} else {
|
||||
shape = null;
|
||||
shapes.push(
|
||||
generator.curve(simplifiedPoints as [number, number][], {
|
||||
...generateRoughOptions(element, false, isDarkMode),
|
||||
stroke: "none",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return shape;
|
||||
|
||||
// (2) stroke
|
||||
shapes.push(getFreeDrawSvgPath(element));
|
||||
|
||||
return shapes;
|
||||
}
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
@@ -925,9 +1090,7 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
return getPolygonShape(element);
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const roughShape = ShapeCache.generateElementShape(element, null)[0];
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
return shouldTestInside(element)
|
||||
@@ -1003,3 +1166,69 @@ export const toggleLinePolygonState = (
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// freedraw shape helper
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// NOTE not cached (-> for SVG export)
|
||||
const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => {
|
||||
return getSvgPathFromStroke(
|
||||
getFreedrawOutlinePoints(element),
|
||||
) as SVGPathString;
|
||||
};
|
||||
|
||||
export const getFreedrawOutlinePoints = (
|
||||
element: ExcalidrawFreeDrawElement,
|
||||
) => {
|
||||
// If input points are empty (should they ever be?) return a dot
|
||||
const inputPoints = element.simulatePressure
|
||||
? element.points
|
||||
: element.points.length
|
||||
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
|
||||
: [[0, 0, 0.5]];
|
||||
|
||||
return getStroke(inputPoints as number[][], {
|
||||
simulatePressure: element.simulatePressure,
|
||||
size: element.strokeWidth * 4.25,
|
||||
thinning: 0.6,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine
|
||||
last: true,
|
||||
}) as [number, number][];
|
||||
};
|
||||
|
||||
const med = (A: number[], B: number[]) => {
|
||||
return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2];
|
||||
};
|
||||
|
||||
// Trim SVG path data so number are each two decimal points. This
|
||||
// improves SVG exports, and prevents rendering errors on points
|
||||
// with long decimals.
|
||||
const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g;
|
||||
|
||||
const getSvgPathFromStroke = (points: number[][]): string => {
|
||||
if (!points.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const max = points.length - 1;
|
||||
|
||||
return points
|
||||
.reduce(
|
||||
(acc, point, i, arr) => {
|
||||
if (i === max) {
|
||||
acc.push(point, med(point, arr[0]), "L", arr[0], "Z");
|
||||
} else {
|
||||
acc.push(point, med(point, arr[i + 1]));
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
["M", points[0], "Q"],
|
||||
)
|
||||
.join(" ")
|
||||
.replace(TO_FIXED_PRECISION, "$1");
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
|
||||
import type { InclusiveRange } from "@excalidraw/math";
|
||||
|
||||
import type { Bounds } from "@excalidraw/element";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
import type { MaybeTransformHandleType } from "@excalidraw/element";
|
||||
import type {
|
||||
ElementsMap,
|
||||
@@ -177,8 +177,14 @@ export const isSnappingEnabled = ({
|
||||
selectedElements: NonDeletedExcalidrawElement[];
|
||||
}) => {
|
||||
if (event) {
|
||||
// Allow snapping for lasso tool when dragging selected elements
|
||||
// but not during lasso selection phase
|
||||
const isLassoDragging =
|
||||
app.state.activeTool.type === "lasso" &&
|
||||
app.state.selectedElementsAreBeingDragged;
|
||||
|
||||
return (
|
||||
app.state.activeTool.type !== "lasso" &&
|
||||
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
|
||||
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
||||
(!app.state.objectsSnapModeEnabled &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
|
||||
@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// internally used by history
|
||||
// for internal use by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@@ -239,7 +240,6 @@ export class Store {
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@@ -552,10 +552,26 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
return new this(id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash the passed deltas into the aggregated delta instance.
|
||||
*/
|
||||
public static squash(...deltas: StoreDelta[]) {
|
||||
const aggregatedDelta = StoreDelta.empty();
|
||||
|
||||
for (const delta of deltas) {
|
||||
aggregatedDelta.elements.squash(delta.elements);
|
||||
aggregatedDelta.appState.squash(delta.appState);
|
||||
}
|
||||
|
||||
return aggregatedDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,9 +588,7 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
@@ -613,6 +627,10 @@ export class StoreDelta {
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
selectedLinearElement: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
@@ -998,14 +1015,12 @@ export const getObservedAppState = (
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
selectedLinearElementIsEditing:
|
||||
(appState as AppState).selectedLinearElement?.isEditing ??
|
||||
(appState as ObservedAppState).selectedLinearElementIsEditing ??
|
||||
null,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
const angle = (container.angle ?? 0) as Radians;
|
||||
|
||||
if (angle !== 0) {
|
||||
const contentCenter = pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
const textCenter = pointFrom(
|
||||
x + boundTextElement.width / 2,
|
||||
y + boundTextElement.height / 2,
|
||||
);
|
||||
|
||||
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
|
||||
|
||||
return {
|
||||
x: rx - boundTextElement.width / 2,
|
||||
y: ry - boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
@@ -327,6 +347,7 @@ export const getContainerCenter = (
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
@@ -421,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
|
||||
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElementType;
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
}): element is ExcalidrawTextContainer =>
|
||||
VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
|
||||
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
/**
|
||||
* This module approximates browser-like soft wrapping for Excalidraw text.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
|
||||
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
|
||||
* records where each visual line came from in the source text.
|
||||
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
|
||||
* that is wider than the available width.
|
||||
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
|
||||
* whitespace so the rendered text stays consistent with what users see on canvas.
|
||||
*
|
||||
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
|
||||
* that need metadata such as mapping visual lines back to `originalText`
|
||||
* for caret placement or future editor features.
|
||||
*/
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
let cachedLineBreakRegex: RegExp | undefined;
|
||||
let cachedEmojiRegex: RegExp | undefined;
|
||||
@@ -358,6 +374,10 @@ const Break = {
|
||||
|
||||
/**
|
||||
* Breaks the line into the tokens based on the found line break opporutnities.
|
||||
*
|
||||
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
|
||||
* their composed variants for wrapping. Any code that needs exact source offsets should
|
||||
* keep in mind that this assumes the input text is already NFC-normalized.
|
||||
*/
|
||||
export const parseTokens = (line: string) => {
|
||||
const breakLineRegex = getLineBreakRegex();
|
||||
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
|
||||
|
||||
/**
|
||||
* Wraps the original text into the lines based on the given width.
|
||||
*
|
||||
* This is a convenience adapter over `getWrappedTextLines()` for call sites
|
||||
* that only need the rendered wrapped string and not the source offsets.
|
||||
*/
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
return getWrappedTextLines(text, font, maxWidth)
|
||||
.map((line) => line.text)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* A single rendered visual line produced from the original text.
|
||||
*
|
||||
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
|
||||
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
|
||||
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
|
||||
*/
|
||||
export type WrappedTextLine = {
|
||||
text: string;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits only on existing hard line breaks and preserves original offsets.
|
||||
*/
|
||||
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
|
||||
let offset = 0;
|
||||
|
||||
return text.split("\n").map((line) => {
|
||||
const start = offset;
|
||||
const end = start + line.length;
|
||||
|
||||
offset = end + 1;
|
||||
|
||||
return {
|
||||
text: line,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the rendered visual lines together with their source offsets.
|
||||
*
|
||||
* This is the source-of-truth wrapping pipeline for callers that need more than the
|
||||
* final wrapped string, for example caret placement or future editor/rich-text mapping.
|
||||
*/
|
||||
export const getWrappedTextLines = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): WrappedTextLine[] => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
|
||||
return text;
|
||||
return getHardLineBreaks(text);
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const lines: WrappedTextLine[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
for (const originalLine of text.split("\n")) {
|
||||
const originalLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
continue;
|
||||
if (originalLineWidth <= maxWidth) {
|
||||
lines.push({
|
||||
text: originalLine,
|
||||
start: offset,
|
||||
end: offset + originalLine.length,
|
||||
});
|
||||
} else {
|
||||
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
|
||||
}
|
||||
|
||||
const wrappedLine = wrapLine(originalLine, font, maxWidth);
|
||||
lines.push(...wrappedLine);
|
||||
offset += originalLine.length + 1;
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original line into the lines based on the given width.
|
||||
* Wraps a single hard line into one or more visual lines.
|
||||
*
|
||||
* The line-local offsets are tracked in original-text code units so
|
||||
* we can map the visual line back to the source.
|
||||
*/
|
||||
const wrapLine = (
|
||||
line: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string[] => {
|
||||
const lines: Array<string> = [];
|
||||
lineStart: number,
|
||||
): WrappedTextLine[] => {
|
||||
const lines: WrappedTextLine[] = [];
|
||||
const tokens = parseTokens(line);
|
||||
const tokenIterator = tokens[Symbol.iterator]();
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineStart = lineStart;
|
||||
let currentLineEnd = lineStart;
|
||||
let currentLineWidth = 0;
|
||||
// Tracks the next token's code-unit position in the original source string.
|
||||
let tokenOffset = lineStart;
|
||||
let tokenIndex = 0;
|
||||
|
||||
let iterator = tokenIterator.next();
|
||||
|
||||
while (!iterator.done) {
|
||||
const token = iterator.value;
|
||||
while (tokenIndex < tokens.length) {
|
||||
const token = tokens[tokenIndex];
|
||||
const tokenStart = tokenOffset;
|
||||
const tokenEnd = tokenStart + token.length;
|
||||
const testLine = currentLine + token;
|
||||
|
||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
@@ -429,37 +513,59 @@ const wrapLine = (
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
if (!currentLine) {
|
||||
currentLineStart = tokenStart;
|
||||
}
|
||||
currentLine = testLine;
|
||||
currentLineEnd = tokenEnd;
|
||||
currentLineWidth = testLineWidth;
|
||||
iterator = tokenIterator.next();
|
||||
tokenOffset = tokenEnd;
|
||||
tokenIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
|
||||
if (!currentLine) {
|
||||
const wrappedWord = wrapWord(token, font, maxWidth);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
|
||||
const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
|
||||
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
|
||||
text: "",
|
||||
start: tokenStart,
|
||||
end: tokenStart,
|
||||
};
|
||||
const precedingLines = wrappedWord.slice(0, -1);
|
||||
|
||||
lines.push(...precedingLines);
|
||||
|
||||
// trailing line of the wrapped word might still be joined with next token/s
|
||||
currentLine = trailingLine;
|
||||
currentLineWidth = getLineWidth(trailingLine, font);
|
||||
iterator = tokenIterator.next();
|
||||
currentLine = trailingLine.text;
|
||||
currentLineStart = trailingLine.start;
|
||||
currentLineEnd = trailingLine.end;
|
||||
currentLineWidth = getLineWidth(trailingLine.text, font);
|
||||
tokenOffset = tokenEnd;
|
||||
tokenIndex++;
|
||||
} else {
|
||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||
lines.push(currentLine.trimEnd());
|
||||
lines.push(
|
||||
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
|
||||
);
|
||||
|
||||
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
|
||||
currentLine = "";
|
||||
currentLineStart = tokenStart;
|
||||
currentLineEnd = tokenStart;
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// iterator done, push the trailing line if exists
|
||||
if (currentLine) {
|
||||
const trailingLine = trimLine(currentLine, font, maxWidth);
|
||||
const trailingLine = trimLine(
|
||||
currentLine,
|
||||
currentLineStart,
|
||||
currentLineEnd,
|
||||
font,
|
||||
maxWidth,
|
||||
);
|
||||
lines.push(trailingLine);
|
||||
}
|
||||
|
||||
@@ -467,59 +573,100 @@ const wrapLine = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the word into the lines based on the given width.
|
||||
* Wraps a single word that could not be placed on an empty line as-is.
|
||||
*/
|
||||
const wrapWord = (
|
||||
word: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): Array<string> => {
|
||||
wordStart: number,
|
||||
): WrappedTextLine[] => {
|
||||
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
||||
if (getEmojiRegex().test(word)) {
|
||||
return [word];
|
||||
return [
|
||||
{
|
||||
text: word,
|
||||
start: wordStart,
|
||||
end: wordStart + word.length,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
satisfiesWordInvariant(word);
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const lines: WrappedTextLine[] = [];
|
||||
const chars = Array.from(word);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineStart = wordStart;
|
||||
let currentLineEnd = wordStart;
|
||||
let currentLineWidth = 0;
|
||||
let offset = wordStart;
|
||||
|
||||
for (const char of chars) {
|
||||
const charStart = offset;
|
||||
const charEnd = charStart + char.length;
|
||||
const _charWidth = charWidth.calculate(char, font);
|
||||
const testLineWidth = currentLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth <= maxWidth) {
|
||||
if (!currentLine) {
|
||||
currentLineStart = charStart;
|
||||
}
|
||||
currentLine = currentLine + char;
|
||||
currentLineEnd = charEnd;
|
||||
currentLineWidth = testLineWidth;
|
||||
offset = charEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
lines.push({
|
||||
text: currentLine,
|
||||
start: currentLineStart,
|
||||
end: currentLineEnd,
|
||||
});
|
||||
}
|
||||
|
||||
currentLine = char;
|
||||
currentLineStart = charStart;
|
||||
currentLineEnd = charEnd;
|
||||
currentLineWidth = _charWidth;
|
||||
offset = charEnd;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
lines.push({
|
||||
text: currentLine,
|
||||
start: currentLineStart,
|
||||
end: currentLineEnd,
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||
* Trims trailing whitespace that is exceeding the `maxWidth`.
|
||||
*
|
||||
* Used for the trailing visual line of a hard line, where some trailing
|
||||
* whitespace may still be visible if it fits into the available width.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const trimLine = (
|
||||
line: string,
|
||||
start: number,
|
||||
end: number,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): WrappedTextLine => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
return {
|
||||
text: line,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
// defensively default to `trimeEnd` in case the regex does not match
|
||||
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
trimmedLineWidth = testLineWidth;
|
||||
}
|
||||
|
||||
return trimmedLine;
|
||||
return {
|
||||
text: trimmedLine,
|
||||
start,
|
||||
end: end - (line.length - trimmedLine.length),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Used for internal soft-wrap boundaries, where trailing whitespace should not
|
||||
* survive into the rendered line even though it still exists in the original
|
||||
* text.
|
||||
*/
|
||||
const trimLineEndAtSoftBreak = (
|
||||
line: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): WrappedTextLine => {
|
||||
const trimmedLine = line.trimEnd();
|
||||
|
||||
return {
|
||||
text: trimmedLine,
|
||||
start,
|
||||
end: end - (line.length - trimmedLine.length),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
import { pointFrom, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
getSizeFromPoints,
|
||||
randomId,
|
||||
arrayToMap,
|
||||
assertNever,
|
||||
cloneJSON,
|
||||
getFontString,
|
||||
isDevEnv,
|
||||
toBrandedType,
|
||||
getLineHeight,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { MarkOptional } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { bindBindingElement } from "./binding";
|
||||
import {
|
||||
newArrowElement,
|
||||
newElement,
|
||||
newFrameElement,
|
||||
newImageElement,
|
||||
newLinearElement,
|
||||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
type ElementConstructorOpts,
|
||||
} from "./newElement";
|
||||
import { measureText, normalizeText } from "./textMeasurements";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
|
||||
import { syncInvalidIndices } from "./fractionalIndex";
|
||||
|
||||
import { redrawTextBoundingBox } from "./textElement";
|
||||
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
|
||||
import { getCommonBounds } from "./bounds";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawGenericElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FileId,
|
||||
FontFamilyValues,
|
||||
NonDeletedSceneElementsMap,
|
||||
TextAlign,
|
||||
VerticalAlign,
|
||||
} from "./types";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
x: number;
|
||||
y: number;
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
end?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
start?:
|
||||
| (
|
||||
| (
|
||||
| {
|
||||
type: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
}
|
||||
| {
|
||||
id: ExcalidrawGenericElement["id"];
|
||||
type?: Exclude<
|
||||
ExcalidrawBindableElement["type"],
|
||||
| "image"
|
||||
| "text"
|
||||
| "frame"
|
||||
| "magicframe"
|
||||
| "embeddable"
|
||||
| "iframe"
|
||||
>;
|
||||
}
|
||||
)
|
||||
| ((
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type?: "text";
|
||||
id: ExcalidrawTextElement["id"];
|
||||
text: string;
|
||||
}
|
||||
) &
|
||||
Partial<ExcalidrawTextElement>)
|
||||
) &
|
||||
MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & Partial<ExcalidrawLinearElement>;
|
||||
|
||||
export type ValidContainer =
|
||||
| {
|
||||
type: Exclude<ExcalidrawGenericElement["type"], "selection">;
|
||||
id?: ExcalidrawGenericElement["id"];
|
||||
label?: {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
} & MarkOptional<ElementConstructorOpts, "x" | "y">;
|
||||
} & ElementConstructorOpts;
|
||||
|
||||
export type ExcalidrawElementSkeleton =
|
||||
| Extract<
|
||||
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
|
||||
>
|
||||
| ({
|
||||
type: Extract<ExcalidrawLinearElement["type"], "line">;
|
||||
x: number;
|
||||
y: number;
|
||||
} & Partial<ExcalidrawLinearElement>)
|
||||
| ValidContainer
|
||||
| ValidLinearElement
|
||||
| ({
|
||||
type: "text";
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
id?: ExcalidrawTextElement["id"];
|
||||
} & Partial<ExcalidrawTextElement>)
|
||||
| ({
|
||||
type: Extract<ExcalidrawImageElement["type"], "image">;
|
||||
x: number;
|
||||
y: number;
|
||||
fileId: FileId;
|
||||
} & Partial<ExcalidrawImageElement>)
|
||||
| ({
|
||||
type: "frame";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawFrameElement>)
|
||||
| ({
|
||||
type: "magicframe";
|
||||
children: readonly ExcalidrawElement["id"][];
|
||||
name?: string;
|
||||
} & Partial<ExcalidrawMagicFrameElement>);
|
||||
|
||||
const DEFAULT_LINEAR_ELEMENT_PROPS = {
|
||||
width: 100,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_DIMENSION = 100;
|
||||
|
||||
const bindTextToContainer = (
|
||||
container: ExcalidrawElement,
|
||||
textProps: { text: string } & MarkOptional<ElementConstructorOpts, "x" | "y">,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const textElement: ExcalidrawTextElement = newTextElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
...textProps,
|
||||
containerId: container.id,
|
||||
strokeColor: textProps.strokeColor || container.strokeColor,
|
||||
});
|
||||
|
||||
Object.assign(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: textElement.id,
|
||||
}),
|
||||
});
|
||||
|
||||
redrawTextBoundingBox(textElement, container, scene);
|
||||
|
||||
return [container, textElement] as const;
|
||||
};
|
||||
|
||||
const bindLinearElementToElement = (
|
||||
linearElement: ExcalidrawArrowElement,
|
||||
start: ValidLinearElement["start"],
|
||||
end: ValidLinearElement["end"],
|
||||
elementStore: ElementStore,
|
||||
scene: Scene,
|
||||
): {
|
||||
linearElement: ExcalidrawLinearElement;
|
||||
startBoundElement?: ExcalidrawElement;
|
||||
endBoundElement?: ExcalidrawElement;
|
||||
} => {
|
||||
let startBoundElement;
|
||||
let endBoundElement;
|
||||
|
||||
Object.assign(linearElement, {
|
||||
startBinding: linearElement?.startBinding || null,
|
||||
endBinding: linearElement.endBinding || null,
|
||||
});
|
||||
|
||||
if (start) {
|
||||
const width = start?.width ?? DEFAULT_DIMENSION;
|
||||
const height = start?.height ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (start.id) {
|
||||
existingElement = elementStore.getElement(start.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for start binding with id ${start.id} found`);
|
||||
}
|
||||
}
|
||||
|
||||
const startX = start.x || linearElement.x - width;
|
||||
const startY = start.y || linearElement.y - height / 2;
|
||||
const startType = existingElement ? existingElement.type : start.type;
|
||||
|
||||
if (startType) {
|
||||
if (startType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (start.type === "text") {
|
||||
text = start.text;
|
||||
}
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for start binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
startBoundElement = newTextElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...start,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(startBoundElement, {
|
||||
x: start.x || linearElement.x - startBoundElement.width,
|
||||
y: start.y || linearElement.y - startBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (startType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
startBoundElement = newElement({
|
||||
x: startX,
|
||||
y: startY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...start,
|
||||
type: startType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element start type "${start.type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindBindingElement(
|
||||
linearElement,
|
||||
startBoundElement as ExcalidrawBindableElement,
|
||||
"orbit",
|
||||
"start",
|
||||
scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (end) {
|
||||
const height = end?.height ?? DEFAULT_DIMENSION;
|
||||
const width = end?.width ?? DEFAULT_DIMENSION;
|
||||
|
||||
let existingElement;
|
||||
if (end.id) {
|
||||
existingElement = elementStore.getElement(end.id);
|
||||
if (!existingElement) {
|
||||
console.error(`No element for end binding with id ${end.id} found`);
|
||||
}
|
||||
}
|
||||
const endX = end.x || linearElement.x + linearElement.width;
|
||||
const endY = end.y || linearElement.y - height / 2;
|
||||
const endType = existingElement ? existingElement.type : end.type;
|
||||
|
||||
if (endType) {
|
||||
if (endType === "text") {
|
||||
let text = "";
|
||||
if (existingElement && existingElement.type === "text") {
|
||||
text = existingElement.text;
|
||||
} else if (end.type === "text") {
|
||||
text = end.text;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error(
|
||||
`No text found for end binding text element for ${linearElement.id}`,
|
||||
);
|
||||
}
|
||||
endBoundElement = newTextElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
type: "text",
|
||||
...existingElement,
|
||||
...end,
|
||||
text,
|
||||
});
|
||||
// to position the text correctly when coordinates not provided
|
||||
Object.assign(endBoundElement, {
|
||||
y: end.y || linearElement.y - endBoundElement.height / 2,
|
||||
});
|
||||
} else {
|
||||
switch (endType) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
endBoundElement = newElement({
|
||||
x: endX,
|
||||
y: endY,
|
||||
width,
|
||||
height,
|
||||
...existingElement,
|
||||
...end,
|
||||
type: endType,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(
|
||||
linearElement as never,
|
||||
`Unhandled element end type "${endType}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindBindingElement(
|
||||
linearElement,
|
||||
endBoundElement as ExcalidrawBindableElement,
|
||||
"orbit",
|
||||
"end",
|
||||
scene,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Safe check to early return for single point
|
||||
if (linearElement.points.length < 2) {
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
}
|
||||
|
||||
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
|
||||
const endPointIndex = linearElement.points.length - 1;
|
||||
const delta = 0.5;
|
||||
|
||||
const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
|
||||
|
||||
// left to right so shift the arrow towards right
|
||||
if (
|
||||
linearElement.points[endPointIndex][0] >
|
||||
linearElement.points[endPointIndex - 1][0]
|
||||
) {
|
||||
newPoints[0][0] = delta;
|
||||
newPoints[endPointIndex][0] -= delta;
|
||||
}
|
||||
|
||||
// right to left so shift the arrow towards left
|
||||
if (
|
||||
linearElement.points[endPointIndex][0] <
|
||||
linearElement.points[endPointIndex - 1][0]
|
||||
) {
|
||||
newPoints[0][0] = -delta;
|
||||
newPoints[endPointIndex][0] += delta;
|
||||
}
|
||||
// top to bottom so shift the arrow towards top
|
||||
if (
|
||||
linearElement.points[endPointIndex][1] >
|
||||
linearElement.points[endPointIndex - 1][1]
|
||||
) {
|
||||
newPoints[0][1] = delta;
|
||||
newPoints[endPointIndex][1] -= delta;
|
||||
}
|
||||
|
||||
// bottom to top so shift the arrow towards bottom
|
||||
if (
|
||||
linearElement.points[endPointIndex][1] <
|
||||
linearElement.points[endPointIndex - 1][1]
|
||||
) {
|
||||
newPoints[0][1] = -delta;
|
||||
newPoints[endPointIndex][1] += delta;
|
||||
}
|
||||
|
||||
Object.assign(
|
||||
linearElement,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords({
|
||||
...linearElement,
|
||||
points: newPoints,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
linearElement,
|
||||
startBoundElement,
|
||||
endBoundElement,
|
||||
};
|
||||
};
|
||||
|
||||
class ElementStore {
|
||||
excalidrawElements = new Map<string, ExcalidrawElement>();
|
||||
|
||||
add = (ele?: ExcalidrawElement) => {
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.excalidrawElements.set(ele.id, ele);
|
||||
};
|
||||
|
||||
getElements = () => {
|
||||
return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
|
||||
};
|
||||
|
||||
getElementsMap = () => {
|
||||
return toBrandedType<NonDeletedSceneElementsMap>(
|
||||
arrayToMap(this.getElements()),
|
||||
);
|
||||
};
|
||||
|
||||
getElement = (id: string) => {
|
||||
return this.excalidrawElements.get(id);
|
||||
};
|
||||
}
|
||||
|
||||
export const convertToExcalidrawElements = (
|
||||
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
|
||||
opts?: { regenerateIds: boolean },
|
||||
) => {
|
||||
if (!elementsSkeleton) {
|
||||
return [];
|
||||
}
|
||||
const elements = cloneJSON(elementsSkeleton);
|
||||
const elementStore = new ElementStore();
|
||||
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
|
||||
const oldToNewElementIdMap = new Map<string, string>();
|
||||
|
||||
// Create individual elements
|
||||
for (const element of elements) {
|
||||
let excalidrawElement: ExcalidrawElement;
|
||||
const originalId = element.id;
|
||||
if (opts?.regenerateIds !== false) {
|
||||
Object.assign(element, { id: randomId() });
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond": {
|
||||
const width =
|
||||
element?.label?.text && element.width === undefined
|
||||
? 0
|
||||
: element?.width || DEFAULT_DIMENSION;
|
||||
const height =
|
||||
element?.label?.text && element.height === undefined
|
||||
? 0
|
||||
: element?.height || DEFAULT_DIMENSION;
|
||||
excalidrawElement = newElement({
|
||||
...element,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newLinearElement({
|
||||
width,
|
||||
height,
|
||||
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
const width = element.width || DEFAULT_LINEAR_ELEMENT_PROPS.width;
|
||||
const height = element.height || DEFAULT_LINEAR_ELEMENT_PROPS.height;
|
||||
excalidrawElement = newArrowElement({
|
||||
width,
|
||||
height,
|
||||
endArrowhead: "arrow",
|
||||
points: [pointFrom(0, 0), pointFrom(width, height)],
|
||||
...element,
|
||||
type: "arrow",
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
excalidrawElement,
|
||||
getSizeFromPoints(excalidrawElement.points),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
normalizedText,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
|
||||
excalidrawElement = newTextElement({
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "image": {
|
||||
excalidrawElement = newImageElement({
|
||||
width: element?.width || DEFAULT_DIMENSION,
|
||||
height: element?.height || DEFAULT_DIMENSION,
|
||||
...element,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "frame": {
|
||||
excalidrawElement = newFrameElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "magicframe": {
|
||||
excalidrawElement = newMagicFrameElement({
|
||||
x: 0,
|
||||
y: 0,
|
||||
...element,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "freedraw":
|
||||
case "iframe":
|
||||
case "embeddable": {
|
||||
excalidrawElement = element;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
excalidrawElement = element;
|
||||
assertNever(
|
||||
element,
|
||||
`Unhandled element type "${(element as any).type}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingElement = elementStore.getElement(excalidrawElement.id);
|
||||
if (existingElement) {
|
||||
console.error(`Duplicate id found for ${excalidrawElement.id}`);
|
||||
} else {
|
||||
elementStore.add(excalidrawElement);
|
||||
elementsWithIds.set(excalidrawElement.id, element);
|
||||
if (originalId) {
|
||||
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elementsMap = elementStore.getElementsMap();
|
||||
// we don't have a real scene, so we just use a temp scene to query and mutate elements
|
||||
const scene = new Scene(elementsMap);
|
||||
|
||||
// Add labels and arrow bindings
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
const excalidrawElement = elementStore.getElement(id)!;
|
||||
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "ellipse":
|
||||
case "diamond":
|
||||
case "arrow": {
|
||||
if (element.label?.text) {
|
||||
let [container, text] = bindTextToContainer(
|
||||
excalidrawElement,
|
||||
element?.label,
|
||||
scene,
|
||||
);
|
||||
elementStore.add(container);
|
||||
elementStore.add(text);
|
||||
|
||||
if (isArrowElement(container)) {
|
||||
const originalStart =
|
||||
element.type === "arrow" ? element?.start : undefined;
|
||||
const originalEnd =
|
||||
element.type === "arrow" ? element?.end : undefined;
|
||||
if (originalStart && originalStart.id) {
|
||||
const newStartId = oldToNewElementIdMap.get(originalStart.id);
|
||||
if (newStartId) {
|
||||
Object.assign(originalStart, { id: newStartId });
|
||||
}
|
||||
}
|
||||
if (originalEnd && originalEnd.id) {
|
||||
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
|
||||
if (newEndId) {
|
||||
Object.assign(originalEnd, { id: newEndId });
|
||||
}
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
container,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
elementStore,
|
||||
scene,
|
||||
);
|
||||
container = linearElement;
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
}
|
||||
} else {
|
||||
switch (element.type) {
|
||||
case "arrow": {
|
||||
const { start, end } = element;
|
||||
if (start && start.id) {
|
||||
const newStartId = oldToNewElementIdMap.get(start.id);
|
||||
Object.assign(start, { id: newStartId });
|
||||
}
|
||||
if (end && end.id) {
|
||||
const newEndId = oldToNewElementIdMap.get(end.id);
|
||||
Object.assign(end, { id: newEndId });
|
||||
}
|
||||
const { linearElement, startBoundElement, endBoundElement } =
|
||||
bindLinearElementToElement(
|
||||
excalidrawElement as ExcalidrawArrowElement,
|
||||
start,
|
||||
end,
|
||||
elementStore,
|
||||
scene,
|
||||
);
|
||||
|
||||
elementStore.add(linearElement);
|
||||
elementStore.add(startBoundElement);
|
||||
elementStore.add(endBoundElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once all the excalidraw elements are created, we can add frames since we
|
||||
// need to calculate coordinates and dimensions of frame which is possible after all
|
||||
// frame children are processed.
|
||||
for (const [id, element] of elementsWithIds) {
|
||||
if (element.type !== "frame" && element.type !== "magicframe") {
|
||||
continue;
|
||||
}
|
||||
const frame = elementStore.getElement(id);
|
||||
|
||||
if (!frame) {
|
||||
throw new Error(`Excalidraw element with id ${id} doesn't exist`);
|
||||
}
|
||||
const childrenElements: ExcalidrawElement[] = [];
|
||||
|
||||
element.children.forEach((id) => {
|
||||
const newElementId = oldToNewElementIdMap.get(id);
|
||||
if (!newElementId) {
|
||||
throw new Error(`Element with ${id} wasn't mapped correctly`);
|
||||
}
|
||||
|
||||
const elementInFrame = elementStore.getElement(newElementId);
|
||||
if (!elementInFrame) {
|
||||
throw new Error(`Frame element with id ${newElementId} doesn't exist`);
|
||||
}
|
||||
Object.assign(elementInFrame, { frameId: frame.id });
|
||||
|
||||
elementInFrame?.boundElements?.forEach((boundElement) => {
|
||||
const ele = elementStore.getElement(boundElement.id);
|
||||
if (!ele) {
|
||||
throw new Error(
|
||||
`Bound element with id ${boundElement.id} doesn't exist`,
|
||||
);
|
||||
}
|
||||
Object.assign(ele, { frameId: frame.id });
|
||||
childrenElements.push(ele);
|
||||
});
|
||||
|
||||
childrenElements.push(elementInFrame);
|
||||
});
|
||||
|
||||
let [minX, minY, maxX, maxY] = getCommonBounds(childrenElements);
|
||||
|
||||
const PADDING = 10;
|
||||
minX = minX - PADDING;
|
||||
minY = minY - PADDING;
|
||||
maxX = maxX + PADDING;
|
||||
maxY = maxY + PADDING;
|
||||
|
||||
const frameX = frame?.x || minX;
|
||||
const frameY = frame?.y || minY;
|
||||
const frameWidth = frame?.width || maxX - minX;
|
||||
const frameHeight = frame?.height || maxY - minY;
|
||||
|
||||
Object.assign(frame, {
|
||||
x: frameX,
|
||||
y: frameY,
|
||||
width: frameWidth,
|
||||
height: frameHeight,
|
||||
});
|
||||
if (
|
||||
isDevEnv() &&
|
||||
element.children.length &&
|
||||
(frame?.x || frame?.y || frame?.width || frame?.height)
|
||||
) {
|
||||
console.info(
|
||||
"User provided frame attributes are being considered, if you find this inaccurate, please remove any of the attributes - x, y, width and height so frame coordinates and dimensions are calculated automatically",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elementStore.getElements();
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
type EditorInterface,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
@@ -9,10 +8,10 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
Device,
|
||||
InteractiveCanvasAppState,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { Bounds } from "@excalidraw/common";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
@@ -111,20 +109,21 @@ const generateTransformHandle = (
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
};
|
||||
|
||||
export const canResizeFromSides = (device: Device) => {
|
||||
if (device.viewport.isMobile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (device.isTouchScreen && (isAndroid || isIOS)) {
|
||||
export const canResizeFromSides = (editorInterface: EditorInterface) => {
|
||||
if (
|
||||
editorInterface.formFactor === "phone" &&
|
||||
editorInterface.userAgent.isMobileDevice
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getOmitSidesForDevice = (device: Device) => {
|
||||
if (canResizeFromSides(device)) {
|
||||
export const getOmitSidesForEditorInterface = (
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
if (canResizeFromSides(editorInterface)) {
|
||||
return DEFAULT_OMIT_SIDES;
|
||||
}
|
||||
|
||||
@@ -326,11 +325,15 @@ export const getTransformHandles = (
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldShowBoundingBox = (
|
||||
export const hasBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
editorInterface: EditorInterface,
|
||||
) => {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing ||
|
||||
appState.selectedLinearElement?.isDragging
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
@@ -345,5 +348,7 @@ export const shouldShowBoundingBox = (
|
||||
return true;
|
||||
}
|
||||
|
||||
return element.points.length > 2;
|
||||
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||
// (also prob best for simplicity's sake)
|
||||
return element.points.length > 2 && !editorInterface.userAgent.isMobileDevice;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -28,8 +27,6 @@ import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
ExcalidrawLinearElementSubType,
|
||||
} from "./types";
|
||||
@@ -163,7 +160,7 @@ export const isLinearElementType = (
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
@@ -358,24 +355,6 @@ export const getDefaultRoundnessTypeForElement = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isFixedPointBinding = (
|
||||
binding: PointBinding | FixedPointBinding,
|
||||
): binding is FixedPointBinding => {
|
||||
return (
|
||||
Object.hasOwn(binding, "fixedPoint") &&
|
||||
(binding as FixedPointBinding).fixedPoint != null
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Move this to @excalidraw/math
|
||||
export const isBounds = (box: unknown): box is Bounds =>
|
||||
Array.isArray(box) &&
|
||||
box.length === 4 &&
|
||||
typeof box[0] === "number" &&
|
||||
typeof box[1] === "number" &&
|
||||
typeof box[2] === "number" &&
|
||||
typeof box[3] === "number";
|
||||
|
||||
export const getLinearElementSubType = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): ExcalidrawLinearElementSubType => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
ValueOf,
|
||||
} from "@excalidraw/common/utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type ChartType = "bar" | "line" | "radar";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
|
||||
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
|
||||
@@ -279,23 +279,22 @@ export type ExcalidrawTextElementWithContainer = {
|
||||
|
||||
export type FixedPoint = [number, number];
|
||||
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
export type BindMode = "inside" | "orbit" | "skip";
|
||||
|
||||
export type FixedPointBinding = Merge<
|
||||
PointBinding,
|
||||
{
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
}
|
||||
>;
|
||||
export type FixedPointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
|
||||
// Determines whether the arrow remains outside the shape or is allowed to
|
||||
// go all the way inside the shape up to the exact fixed point.
|
||||
mode: BindMode;
|
||||
};
|
||||
|
||||
type Index = number;
|
||||
|
||||
@@ -304,27 +303,39 @@ export type PointsPositionUpdates = Map<
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
export type CardinalityArrowhead =
|
||||
| "cardinality_one"
|
||||
| "cardinality_many"
|
||||
| "cardinality_one_or_many"
|
||||
| "cardinality_exactly_one"
|
||||
| "cardinality_zero_or_one"
|
||||
| "cardinality_zero_or_many";
|
||||
|
||||
export type ArrowheadLegacy =
|
||||
| "dot"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
|
||||
export type Arrowhead =
|
||||
| "arrow"
|
||||
| "bar"
|
||||
| "dot" // legacy. Do not use for new elements.
|
||||
| "circle"
|
||||
| "circle_outline"
|
||||
| "triangle"
|
||||
| "triangle_outline"
|
||||
| "diamond"
|
||||
| "diamond_outline"
|
||||
| "crowfoot_one"
|
||||
| "crowfoot_many"
|
||||
| "crowfoot_one_or_many";
|
||||
| CardinalityArrowhead;
|
||||
|
||||
export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "arrow";
|
||||
points: readonly LocalPoint[];
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
@@ -351,9 +362,9 @@ export type ExcalidrawElbowArrowElement = Merge<
|
||||
ExcalidrawArrowElement,
|
||||
{
|
||||
elbowed: true;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startBinding: FixedPointBinding | null;
|
||||
endBinding: FixedPointBinding | null;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
/**
|
||||
* Marks that the 3rd point should be used as the 2nd point of the arrow in
|
||||
* order to temporarily hide the first segment of the arrow without losing
|
||||
@@ -379,7 +390,6 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
+298
-37
@@ -1,31 +1,57 @@
|
||||
import {
|
||||
DEFAULT_ADAPTIVE_RADIUS,
|
||||
DEFAULT_PROPORTIONAL_RADIUS,
|
||||
invariant,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
ROUNDNESS,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
bezierEquation,
|
||||
curve,
|
||||
curveCatmullRomCubicApproxPoints,
|
||||
curveOffsetPoints,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromArray,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
pointTranslate,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
|
||||
import type {
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
Zoom,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getDiamondPoints } from "./bounds";
|
||||
import { elementCenterPoint, getDiamondPoints } from "./bounds";
|
||||
|
||||
import { generateLinearCollisionShape } from "./shape";
|
||||
|
||||
import { hitElementItself, isPointInElement } from "./collision";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isRectangularElement } from "./typeChecks";
|
||||
import { maxBindingDistance_simple } from "./binding";
|
||||
|
||||
import {
|
||||
getGlobalFixedPointForBindableElement,
|
||||
normalizeFixedPoint,
|
||||
} from "./binding";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -98,6 +124,7 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
|
||||
*/
|
||||
export function deconstructLinearOrFreeDrawElement(
|
||||
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, 0);
|
||||
|
||||
@@ -105,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const ops = generateLinearCollisionShape(element) as {
|
||||
op: string;
|
||||
data: number[];
|
||||
}[];
|
||||
const ops = generateLinearCollisionShape(element, elementsMap);
|
||||
const lines = [];
|
||||
const curves = [];
|
||||
|
||||
@@ -315,24 +339,10 @@ export function deconstructRectanguloidElement(
|
||||
return shape;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
export function getDiamondBaseCorners(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
): Curve<GlobalPoint>[] {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = element.roundness
|
||||
@@ -349,7 +359,7 @@ export function deconstructDiamondElement(
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const baseCorners = [
|
||||
return [
|
||||
curve(
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
@@ -399,21 +409,32 @@ export function deconstructDiamondElement(
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
}
|
||||
|
||||
const corners =
|
||||
offset > 0
|
||||
? baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(
|
||||
curveOffsetPoints(corner, offset),
|
||||
)!,
|
||||
)
|
||||
: [
|
||||
[baseCorners[0]],
|
||||
[baseCorners[1]],
|
||||
[baseCorners[2]],
|
||||
[baseCorners[3]],
|
||||
];
|
||||
/**
|
||||
* Get the **unrotated** building components of a diamond element
|
||||
* in the form of line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line **unrotated** segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const cachedShape = getElementShapesCacheEntry(element, offset);
|
||||
|
||||
if (cachedShape) {
|
||||
return cachedShape;
|
||||
}
|
||||
|
||||
const baseCorners = getDiamondBaseCorners(element, offset);
|
||||
|
||||
const corners = baseCorners.map(
|
||||
(corner) =>
|
||||
curveCatmullRomCubicApproxPoints(curveOffsetPoints(corner, offset))!,
|
||||
);
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(
|
||||
@@ -481,3 +502,243 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getDiagonalsForBindableElement = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
// for rectangles, shrink the diagonals a bit because there's something
|
||||
// going on with the focus points around the corners. Ask Mark for details.
|
||||
const OFFSET_PX = element.type === "rectangle" ? 15 : 0;
|
||||
const shrinkSegment = (seg: LineSegment<GlobalPoint>) => {
|
||||
const v = vectorNormalize(vectorFromPoint(seg[1], seg[0]));
|
||||
const offset = vectorScale(v, OFFSET_PX);
|
||||
return lineSegment<GlobalPoint>(
|
||||
pointTranslate(seg[0], offset),
|
||||
pointTranslate(seg[1], vectorScale(offset, -1)),
|
||||
);
|
||||
};
|
||||
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const diagonalOne = shrinkSegment(
|
||||
isRectangularElement(element)
|
||||
? lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
)
|
||||
: lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
const diagonalTwo = shrinkSegment(
|
||||
isRectangularElement(element)
|
||||
? lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
)
|
||||
: lineSegment<GlobalPoint>(
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return [diagonalOne, diagonalTwo];
|
||||
};
|
||||
|
||||
export const getSnapOutlineMidPoint = (
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
) => {
|
||||
const center = elementCenterPoint(element, elementsMap);
|
||||
const sideMidpoints =
|
||||
element.type === "diamond"
|
||||
? getDiamondBaseCorners(element).map((curve) => {
|
||||
const point = bezierEquation(curve, 0.5);
|
||||
const rotatedPoint = pointRotateRads(point, center, element.angle);
|
||||
|
||||
return pointFrom<GlobalPoint>(rotatedPoint[0], rotatedPoint[1]);
|
||||
})
|
||||
: [
|
||||
// RIGHT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width,
|
||||
element.y + element.height / 2,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// BOTTOM midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height,
|
||||
),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// LEFT midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x, element.y + element.height / 2),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
// TOP midpoint
|
||||
pointRotateRads(
|
||||
pointFrom<GlobalPoint>(element.x + element.width / 2, element.y),
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
];
|
||||
const candidate = sideMidpoints.find(
|
||||
(midpoint) =>
|
||||
pointDistance(point, midpoint) <=
|
||||
maxBindingDistance_simple(zoom) + element.strokeWidth / 2 &&
|
||||
!hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 0,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
export const projectFixedPointOntoDiagonal = (
|
||||
arrow: ExcalidrawArrowElement,
|
||||
point: GlobalPoint,
|
||||
element: ExcalidrawBindableElement,
|
||||
startOrEnd: "start" | "end",
|
||||
elementsMap: ElementsMap,
|
||||
zoom: AppState["zoom"],
|
||||
isMidpointSnappingEnabled: boolean = true,
|
||||
): GlobalPoint | null => {
|
||||
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
|
||||
if (arrow.width < 3 && arrow.height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMidpointSnappingEnabled) {
|
||||
const sideMidPoint = getSnapOutlineMidPoint(
|
||||
point,
|
||||
element,
|
||||
elementsMap,
|
||||
zoom,
|
||||
);
|
||||
if (sideMidPoint) {
|
||||
return sideMidPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// Do the projection onto the diagonals (or center lines
|
||||
// for non-rectangular shapes)
|
||||
const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// To avoid working with stale arrow state, we use the opposite focus point
|
||||
// of the current endpoint, which will always be unchanged during moving of
|
||||
// the endpoint. This is only needed when the arrow has only two points.
|
||||
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
startOrEnd === "start" ? 1 : arrow.points.length - 2,
|
||||
elementsMap,
|
||||
);
|
||||
if (arrow.points.length === 2) {
|
||||
const otherBinding =
|
||||
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
|
||||
const otherBindable =
|
||||
otherBinding &&
|
||||
(elementsMap.get(otherBinding.elementId) as
|
||||
| ExcalidrawBindableElement
|
||||
| undefined);
|
||||
const otherFocusPoint =
|
||||
otherBinding &&
|
||||
otherBindable &&
|
||||
getGlobalFixedPointForBindableElement(
|
||||
normalizeFixedPoint(otherBinding.fixedPoint),
|
||||
otherBindable,
|
||||
elementsMap,
|
||||
);
|
||||
if (otherFocusPoint) {
|
||||
a = otherFocusPoint;
|
||||
}
|
||||
}
|
||||
|
||||
const b = pointFromVector<GlobalPoint>(
|
||||
vectorScale(
|
||||
vectorFromPoint(point, a),
|
||||
2 * pointDistance(a, point) +
|
||||
Math.max(
|
||||
pointDistance(diagonalOne[0], diagonalOne[1]),
|
||||
pointDistance(diagonalTwo[0], diagonalTwo[1]),
|
||||
),
|
||||
),
|
||||
a,
|
||||
);
|
||||
const intersector = lineSegment<GlobalPoint>(b, a);
|
||||
const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector);
|
||||
const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector);
|
||||
const d1 = p1 && pointDistance(a, p1);
|
||||
const d2 = p2 && pointDistance(a, p2);
|
||||
|
||||
let projection = null;
|
||||
if (d1 != null && d2 != null) {
|
||||
projection = d1 < d2 ? p1 : p2;
|
||||
} else {
|
||||
projection = p1 || p2 || null;
|
||||
}
|
||||
|
||||
if (projection && isPointInElement(projection, element, elementsMap)) {
|
||||
return projection;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
isLineSegment,
|
||||
lineSegment,
|
||||
pointDistanceSq,
|
||||
pointFrom,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
import { type Bounds, isBounds } from "@excalidraw/common";
|
||||
import {
|
||||
getElementBounds,
|
||||
intersectElementWithLineSegment,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isPathALoop,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
import type { LineSegment } from "@excalidraw/utils";
|
||||
|
||||
// The global data holder to collect the debug operations
|
||||
declare global {
|
||||
interface Window {
|
||||
visualDebug?: {
|
||||
data: DebugElement[][];
|
||||
currentFrame?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type DebugElement = {
|
||||
color: string;
|
||||
data: LineSegment<GlobalPoint> | Curve<GlobalPoint> | DebugPolygon;
|
||||
permanent: boolean;
|
||||
};
|
||||
|
||||
export type DebugPolygon = {
|
||||
type: "polygon";
|
||||
points: GlobalPoint[];
|
||||
fill?: boolean;
|
||||
close?: boolean;
|
||||
};
|
||||
|
||||
export const debugDrawHitVolume = (
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
options?: {
|
||||
rays?: number;
|
||||
color?: string;
|
||||
fill?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (
|
||||
(isLinearElement(element) || isFreeDrawElement(element)) &&
|
||||
!isPathALoop(element.points)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
|
||||
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
const rays = options?.rays ?? 100;
|
||||
const radius = Math.max(x2 - x1, y2 - y1) * 2;
|
||||
const points: GlobalPoint[] = [];
|
||||
|
||||
for (let i = 0; i < rays; i += 1) {
|
||||
const angle = (i / rays) * Math.PI * 2;
|
||||
const end = pointFrom<GlobalPoint>(
|
||||
center[0] + Math.cos(angle) * radius,
|
||||
center[1] + Math.sin(angle) * radius,
|
||||
);
|
||||
const hits = intersectElementWithLineSegment(
|
||||
element,
|
||||
elementsMap,
|
||||
lineSegment(center, end),
|
||||
);
|
||||
if (hits.length === 0) {
|
||||
continue;
|
||||
}
|
||||
hits.sort(pointDistanceSq);
|
||||
points.push(hits[0]);
|
||||
}
|
||||
|
||||
if (points.length >= 3) {
|
||||
debugDrawPolygon(points, {
|
||||
color: options?.color ?? "orange",
|
||||
fill: options?.fill ?? true,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`debugDrawHitVolume: could not compute hit volume for element ${element.id}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const debugDrawCubicBezier = (
|
||||
c: Curve<GlobalPoint>,
|
||||
opts?: {
|
||||
color?: string;
|
||||
permanent?: boolean;
|
||||
},
|
||||
) => {
|
||||
addToCurrentFrame({
|
||||
color: opts?.color ?? "purple",
|
||||
permanent: !!opts?.permanent,
|
||||
data: c,
|
||||
});
|
||||
};
|
||||
|
||||
export const debugDrawLine = (
|
||||
segment: LineSegment<GlobalPoint> | LineSegment<GlobalPoint>[],
|
||||
opts?: {
|
||||
color?: string;
|
||||
permanent?: boolean;
|
||||
},
|
||||
) => {
|
||||
const segments = (
|
||||
isLineSegment(segment) ? [segment] : segment
|
||||
) as LineSegment<GlobalPoint>[];
|
||||
|
||||
segments.forEach((data) =>
|
||||
addToCurrentFrame({
|
||||
color: opts?.color ?? "red",
|
||||
data,
|
||||
permanent: !!opts?.permanent,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const debugDrawPolygon = (
|
||||
points: GlobalPoint[],
|
||||
opts?: {
|
||||
color?: string;
|
||||
permanent?: boolean;
|
||||
fill?: boolean;
|
||||
close?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
addToCurrentFrame({
|
||||
color: opts?.color ?? "orange",
|
||||
permanent: !!opts?.permanent,
|
||||
data: {
|
||||
type: "polygon",
|
||||
points,
|
||||
fill: opts?.fill,
|
||||
close: opts?.close,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const debugDrawPoint = (
|
||||
p: GlobalPoint,
|
||||
opts?: {
|
||||
color?: string;
|
||||
permanent?: boolean;
|
||||
fuzzy?: boolean;
|
||||
},
|
||||
) => {
|
||||
const xOffset = opts?.fuzzy ? Math.random() * 3 : 0;
|
||||
const yOffset = opts?.fuzzy ? Math.random() * 3 : 0;
|
||||
|
||||
debugDrawLine(
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset - 10),
|
||||
pointFrom<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset + 10),
|
||||
),
|
||||
{
|
||||
color: opts?.color ?? "cyan",
|
||||
permanent: opts?.permanent,
|
||||
},
|
||||
);
|
||||
debugDrawLine(
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset + 10),
|
||||
pointFrom<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset - 10),
|
||||
),
|
||||
{
|
||||
color: opts?.color ?? "cyan",
|
||||
permanent: opts?.permanent,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const debugDrawBounds = (
|
||||
box: Bounds | Bounds[],
|
||||
opts?: {
|
||||
color?: string;
|
||||
permanent?: boolean;
|
||||
},
|
||||
) => {
|
||||
(isBounds(box) ? [box] : box).forEach((bbox: Bounds) =>
|
||||
debugDrawLine(
|
||||
[
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(bbox[0], bbox[1]),
|
||||
pointFrom<GlobalPoint>(bbox[2], bbox[1]),
|
||||
),
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(bbox[2], bbox[1]),
|
||||
pointFrom<GlobalPoint>(bbox[2], bbox[3]),
|
||||
),
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(bbox[2], bbox[3]),
|
||||
pointFrom<GlobalPoint>(bbox[0], bbox[3]),
|
||||
),
|
||||
lineSegment(
|
||||
pointFrom<GlobalPoint>(bbox[0], bbox[3]),
|
||||
pointFrom<GlobalPoint>(bbox[0], bbox[1]),
|
||||
),
|
||||
],
|
||||
{
|
||||
color: opts?.color ?? "green",
|
||||
permanent: !!opts?.permanent,
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const debugDrawPoints = (
|
||||
{
|
||||
x,
|
||||
y,
|
||||
points,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
points: readonly LocalPoint[];
|
||||
},
|
||||
options?: any,
|
||||
) => {
|
||||
points.forEach((p) =>
|
||||
debugDrawPoint(pointFrom<GlobalPoint>(x + p[0], y + p[1]), options),
|
||||
);
|
||||
};
|
||||
|
||||
export const debugCloseFrame = () => {
|
||||
window.visualDebug?.data.push([]);
|
||||
};
|
||||
|
||||
export const debugClear = () => {
|
||||
if (window.visualDebug?.data) {
|
||||
window.visualDebug.data = [];
|
||||
}
|
||||
};
|
||||
|
||||
const addToCurrentFrame = (element: DebugElement) => {
|
||||
if (window.visualDebug?.data && window.visualDebug.data.length === 0) {
|
||||
window.visualDebug.data[0] = [];
|
||||
}
|
||||
window.visualDebug?.data &&
|
||||
window.visualDebug.data[window.visualDebug.data.length - 1].push(element);
|
||||
};
|
||||
@@ -1,18 +1,25 @@
|
||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
import type { GlobalPoint } from "@excalidraw/math";
|
||||
|
||||
import { isFrameLikeElement } from "./typeChecks";
|
||||
|
||||
import { isFrameLikeElement, isTextElement } from "./typeChecks";
|
||||
import { getElementsInGroup } from "./groups";
|
||||
|
||||
import { syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { getSelectedElements } from "./selection";
|
||||
import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { getHoveredElementForBinding } from "./collision";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
|
||||
return element.frameId === frameId || element.id === frameId;
|
||||
@@ -139,6 +146,50 @@ const getContiguousFrameRangeElements = (
|
||||
return allElements.slice(rangeStart, rangeEnd + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the arrow element above any bindable elements it intersects with or
|
||||
* hovers over.
|
||||
*/
|
||||
export const moveArrowAboveBindable = (
|
||||
point: GlobalPoint,
|
||||
arrow: ExcalidrawArrowElement,
|
||||
elements: readonly Ordered<NonDeletedExcalidrawElement>[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
scene: Scene,
|
||||
hit?: NonDeletedExcalidrawElement,
|
||||
): readonly OrderedExcalidrawElement[] => {
|
||||
const hoveredElement = hit
|
||||
? hit
|
||||
: getHoveredElementForBinding(point, elements, elementsMap);
|
||||
|
||||
if (!hoveredElement) {
|
||||
return elements;
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(hoveredElement, elementsMap);
|
||||
const containerElement = isTextElement(hoveredElement)
|
||||
? getContainerElement(hoveredElement, elementsMap)
|
||||
: null;
|
||||
|
||||
const bindableIds = [
|
||||
hoveredElement.id,
|
||||
boundTextElement?.id,
|
||||
containerElement?.id,
|
||||
].filter((id): id is NonDeletedExcalidrawElement["id"] => !!id);
|
||||
const bindableIdx = elements.findIndex((el) => bindableIds.includes(el.id));
|
||||
const arrowIdx = elements.findIndex((el) => el.id === arrow.id);
|
||||
|
||||
if (arrowIdx !== -1 && bindableIdx !== -1 && arrowIdx < bindableIdx) {
|
||||
const updatedElements = Array.from(elements);
|
||||
const arrow = updatedElements.splice(arrowIdx, 1)[0];
|
||||
updatedElements.splice(bindableIdx, 0, arrow);
|
||||
|
||||
scene.replaceAllElements(updatedElements);
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns next candidate index that's available to be moved to. Currently that
|
||||
* is a non-deleted element, and not inside a group (unless we're editing it).
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
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, sans-serif, 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; max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, sans-serif, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
|
||||
} as ExcalidrawLinearElement;
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(360.3176068760539);
|
||||
expect(y1).toEqual(185.90654264413516);
|
||||
expect(x2).toEqual(480.87005902729743);
|
||||
expect(y2).toEqual(320.4751269334226);
|
||||
expect(x1).toEqual(360.9291017525165);
|
||||
expect(y1).toEqual(185.24770129343722);
|
||||
expect(x2).toEqual(481.4815539037601);
|
||||
expect(y2).toEqual(319.8162855827246);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { arrayToMap, reseed } from "@excalidraw/common";
|
||||
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import * as distance from "../src/distance";
|
||||
import { hitElementItself } from "../src/collision";
|
||||
|
||||
describe("check rotated elements can be hit:", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
@@ -25,8 +29,6 @@ describe("check rotated elements can be hit:", () => {
|
||||
[-4, -302],
|
||||
] as LocalPoint[],
|
||||
});
|
||||
//const p = [120, -211];
|
||||
//const p = [0, 13];
|
||||
const hit = hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(88, -68),
|
||||
element: window.h.elements[0],
|
||||
@@ -36,3 +38,183 @@ describe("check rotated elements can be hit:", () => {
|
||||
expect(hit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hitElementItself cache", () => {
|
||||
beforeEach(async () => {
|
||||
// reset cache
|
||||
hitElementItself({
|
||||
point: pointFrom<GlobalPoint>(50, 50),
|
||||
element: API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "#ffffff",
|
||||
}),
|
||||
threshold: Infinity,
|
||||
elementsMap: new Map([]),
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("reuses cached result when threshold increases", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(100.5, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
distanceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not reuse cache when threshold decreases", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(105, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 6,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(2);
|
||||
distanceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("invalidates cache when element version changes", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(100.5, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const movedElement = {
|
||||
...element,
|
||||
version: element.version + 1,
|
||||
versionNonce: element.versionNonce + 1,
|
||||
};
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element: movedElement,
|
||||
threshold: 1,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(2);
|
||||
distanceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("override does not affect caching", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
const elementsMap = arrayToMap([element]);
|
||||
const point = pointFrom<GlobalPoint>(50, 50);
|
||||
|
||||
const distanceSpy = jest.spyOn(distance, "distanceToElement");
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(distanceSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(
|
||||
hitElementItself({
|
||||
point,
|
||||
element,
|
||||
threshold: 10,
|
||||
elementsMap,
|
||||
overrideShouldTestInside: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,345 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not throw when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map([[element.id, element]]);
|
||||
const nextElements = new Map();
|
||||
|
||||
expect(() =>
|
||||
ElementsDelta.calculate(prevElements, nextElements),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw when adding element as already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map();
|
||||
const nextElements = new Map([[element.id, element]]);
|
||||
|
||||
expect(() =>
|
||||
ElementsDelta.calculate(prevElements, nextElements),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should create updated delta even when there is only version and versionNonce change", () => {
|
||||
const baseElement = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
const modifiedElement = {
|
||||
...baseElement,
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
};
|
||||
|
||||
// Create maps for the delta calculation
|
||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||
|
||||
// Calculate the delta
|
||||
const delta = ElementsDelta.calculate(
|
||||
prevElements as SceneElementsMap,
|
||||
nextElements as SceneElementsMap,
|
||||
);
|
||||
|
||||
expect(delta).toEqual(
|
||||
ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[baseElement.id]: Delta.create(
|
||||
{
|
||||
version: baseElement.version,
|
||||
versionNonce: baseElement.versionNonce,
|
||||
},
|
||||
{
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id1: updatedDelta },
|
||||
);
|
||||
const elementsDelta2 = ElementsDelta.empty();
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash mutually exclusive delta types", () => {
|
||||
const addedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
);
|
||||
|
||||
const removedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
);
|
||||
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{ id1: addedDelta },
|
||||
{ id2: removedDelta },
|
||||
{},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id3: updatedDelta },
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash the same delta types", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.removed.id2).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.updated.id3).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash different delta types ", () => {
|
||||
// id1: added -> updated => added
|
||||
// id2: removed -> added => added
|
||||
// id3: updated -> removed => removed
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 300, version: 1, versionNonce: 1 },
|
||||
{ x: 301, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 101, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added).toEqual({
|
||||
id1: Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
id2: Delta.create(
|
||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.removed).toEqual({
|
||||
id3: Delta.create(
|
||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.updated).toEqual({});
|
||||
});
|
||||
|
||||
it("should squash bound elements", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
boundElements: [{ id: "t1", type: "text" }],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "t2", type: "text" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "a1", type: "arrow" }],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
versionNonce: 3,
|
||||
boundElements: [{ id: "a2", type: "arrow" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||
{ id: "t1", type: "text" },
|
||||
{ id: "a1", type: "arrow" },
|
||||
]);
|
||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||
{ id: "t2", type: "text" },
|
||||
{ id: "a2", type: "arrow" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
const selectedLinearElement = {
|
||||
elementId: "id1" as LinearElementEditor["elementId"],
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
@@ -24,23 +356,23 @@ describe("AppStateDelta", () => {
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
@@ -58,9 +390,7 @@ describe("AppStateDelta", () => {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -106,9 +436,7 @@ describe("AppStateDelta", () => {
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -149,4 +477,97 @@ describe("AppStateDelta", () => {
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const delta = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta);
|
||||
const appStateDelta2 = AppStateDelta.empty();
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toBe(delta);
|
||||
});
|
||||
|
||||
it("should squash exclusive properties", () => {
|
||||
const delta1 = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
const delta2 = Delta.create(
|
||||
{ viewBackgroundColor: "#ffffff" },
|
||||
{ viewBackgroundColor: "#000000" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create(
|
||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true },
|
||||
selectedGroupIds: {},
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
);
|
||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
selectedElementIds: { id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
{
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true, id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,9 +144,8 @@ describe("duplicating multiple elements", () => {
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -155,9 +154,8 @@ describe("duplicating multiple elements", () => {
|
||||
id: "arrow2",
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
boundElements: [{ id: "text2", type: "text" }],
|
||||
});
|
||||
@@ -276,9 +274,8 @@ describe("duplicating multiple elements", () => {
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -293,15 +290,13 @@ describe("duplicating multiple elements", () => {
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -310,15 +305,13 @@ describe("duplicating multiple elements", () => {
|
||||
id: "arrow3",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
mode: "orbit",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -821,7 +814,7 @@ describe("duplication z-order", () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -100,
|
||||
y: 50,
|
||||
width: 95,
|
||||
width: 115,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { ARROW_TYPE } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
@@ -15,13 +12,11 @@ import {
|
||||
queryByTestId,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import "@excalidraw/utils/test-utils";
|
||||
import { bindBindingElement } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { bindLinearElement } from "../src/binding";
|
||||
|
||||
import { Scene } from "../src/Scene";
|
||||
|
||||
import type {
|
||||
@@ -136,6 +131,11 @@ describe("elbow arrow segment move", () => {
|
||||
});
|
||||
|
||||
describe("elbow arrow routing", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("can properly generate orthogonal arrow points", () => {
|
||||
const scene = new Scene();
|
||||
const arrow = API.createElement({
|
||||
@@ -160,8 +160,8 @@ describe("elbow arrow routing", () => {
|
||||
expect(arrow.width).toEqual(90);
|
||||
expect(arrow.height).toEqual(200);
|
||||
});
|
||||
|
||||
it("can generate proper points for bound elbow arrow", () => {
|
||||
const scene = new Scene();
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: -150,
|
||||
@@ -185,25 +185,23 @@ describe("elbow arrow routing", () => {
|
||||
height: 200,
|
||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
scene.insertElement(arrow);
|
||||
API.setElements([rectangle1, rectangle2, arrow]);
|
||||
|
||||
bindLinearElement(arrow, rectangle1, "start", scene);
|
||||
bindLinearElement(arrow, rectangle2, "end", scene);
|
||||
bindBindingElement(arrow, rectangle1, "orbit", "start", h.scene);
|
||||
bindBindingElement(arrow, rectangle2, "orbit", "end", h.scene);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
h.app.scene.mutateElement(arrow, {
|
||||
h.scene.mutateElement(arrow, {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||
});
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
[39, 0],
|
||||
[39, 200],
|
||||
[78, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -242,9 +240,9 @@ describe("elbow arrow ui", () => {
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.moveTo(-53, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.moveTo(53, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
@@ -253,11 +251,11 @@ describe("elbow arrow ui", () => {
|
||||
|
||||
expect(arrow.type).toBe("arrow");
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
expect(arrow.points).toEqual([
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
[39, 0],
|
||||
[39, 200],
|
||||
[78, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -279,9 +277,9 @@ describe("elbow arrow ui", () => {
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.moveTo(-53, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.moveTo(53, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
@@ -297,9 +295,11 @@ describe("elbow arrow ui", () => {
|
||||
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
[36, 0],
|
||||
[36, 90],
|
||||
[28, 90],
|
||||
[28, 164],
|
||||
[101, 164],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -321,9 +321,9 @@ describe("elbow arrow ui", () => {
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.moveTo(-53, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.moveTo(53, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
@@ -351,11 +351,11 @@ describe("elbow arrow ui", () => {
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
expect(duplicatedArrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
[39, 0],
|
||||
[39, 200],
|
||||
[78, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
@@ -379,9 +379,9 @@ describe("elbow arrow ui", () => {
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.moveTo(-53, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.moveTo(53, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
@@ -405,11 +405,11 @@ describe("elbow arrow ui", () => {
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
expect(duplicatedArrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
[78, 100],
|
||||
[78, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getEmbedLink } from "../src/embeddable";
|
||||
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
|
||||
|
||||
describe("YouTube timestamp parsing", () => {
|
||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||
@@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Drive video embedding", () => {
|
||||
it.each([
|
||||
{
|
||||
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
|
||||
expectedLink:
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
|
||||
},
|
||||
{
|
||||
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
|
||||
expectedLink:
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
|
||||
},
|
||||
{
|
||||
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
|
||||
expectedLink:
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
|
||||
},
|
||||
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(expectedLink);
|
||||
}
|
||||
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
|
||||
});
|
||||
|
||||
it("should preserve resourcekey when available", () => {
|
||||
const url =
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve timestamp when available", () => {
|
||||
const url =
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve resourcekey and timestamp together", () => {
|
||||
const url =
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toBe(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate Google Drive domain by default", () => {
|
||||
expect(
|
||||
embeddableURLValidator(
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
|
||||
undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,8 +217,8 @@ describe("Test Linear Elements", () => {
|
||||
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
[
|
||||
@@ -329,7 +329,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.selectedLinearElement).toBe(null);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
await getTextEditor();
|
||||
});
|
||||
|
||||
@@ -357,6 +357,7 @@ describe("Test Linear Elements", () => {
|
||||
const originalY = line.y;
|
||||
enterLineEditingMode(line);
|
||||
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(line.points.length).toEqual(2);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
@@ -379,7 +380,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
@@ -418,7 +419,7 @@ describe("Test Linear Elements", () => {
|
||||
fireEvent.click(screen.getByTitle("Round"));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`9`,
|
||||
`10`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -549,7 +550,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
@@ -600,7 +601,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@@ -641,7 +642,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@@ -689,7 +690,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
line,
|
||||
@@ -747,7 +748,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||
@@ -845,7 +846,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@@ -1303,7 +1304,7 @@ describe("Test Linear Elements", () => {
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -10,
|
||||
y: 250,
|
||||
width: 400,
|
||||
width: 410,
|
||||
height: 1,
|
||||
});
|
||||
|
||||
@@ -1316,7 +1317,7 @@ describe("Test Linear Elements", () => {
|
||||
const textElement = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
|
||||
expect(arrow.endBinding?.elementId).toBe(rect.id);
|
||||
expect(arrow.width).toBe(400);
|
||||
expect(arrow.width).toBeCloseTo(404);
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
@@ -1335,7 +1336,7 @@ describe("Test Linear Elements", () => {
|
||||
mouse.downAt(rect.x, rect.y);
|
||||
mouse.moveTo(200, 0);
|
||||
mouse.upAt(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(200, 0);
|
||||
expect(arrow.width).toBeCloseTo(204);
|
||||
expect(rect.x).toBe(200);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
import {
|
||||
type Bounds,
|
||||
KEYS,
|
||||
getSizeFromPoints,
|
||||
reseed,
|
||||
@@ -22,7 +23,6 @@ import { resizeSingleElement } from "../src/resizeElements";
|
||||
import { LinearElementEditor } from "../src/linearElementEditor";
|
||||
import { getElementPointsCoords } from "../src/bounds";
|
||||
|
||||
import type { Bounds } from "../src/bounds";
|
||||
import type {
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
@@ -174,29 +174,29 @@ describe("generic element", () => {
|
||||
expect(rectangle.angle).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("resizes with bound arrow", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -30,
|
||||
y: 50,
|
||||
width: 28,
|
||||
height: 5,
|
||||
});
|
||||
// it("resizes with bound arrow", async () => {
|
||||
// const rectangle = UI.createElement("rectangle", {
|
||||
// width: 200,
|
||||
// height: 100,
|
||||
// });
|
||||
// const arrow = UI.createElement("arrow", {
|
||||
// x: -30,
|
||||
// y: 50,
|
||||
// width: 28,
|
||||
// height: 5,
|
||||
// });
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
|
||||
UI.resize(rectangle, "e", [40, 0]);
|
||||
// UI.resize(rectangle, "e", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
|
||||
UI.resize(rectangle, "w", [50, 0]);
|
||||
// UI.resize(rectangle, "w", [50, 0]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||
});
|
||||
// expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
|
||||
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0);
|
||||
// });
|
||||
|
||||
it("resizes with a label", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
@@ -510,12 +510,12 @@ describe("arrow element", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize(rectangle, "se", [-200, -150]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
@@ -538,11 +538,11 @@ describe("arrow element", () => {
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.06);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.06);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
@@ -595,31 +595,31 @@ describe("text element", () => {
|
||||
expect(text.fontSize).toBeCloseTo(fontSize * scale);
|
||||
});
|
||||
|
||||
it("resizes with bound arrow", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "hello\nworld");
|
||||
const boundArrow = UI.createElement("arrow", {
|
||||
x: -30,
|
||||
y: 25,
|
||||
width: 28,
|
||||
height: 5,
|
||||
});
|
||||
// it("resizes with bound arrow", async () => {
|
||||
// const text = UI.createElement("text");
|
||||
// await UI.editText(text, "hello\nworld");
|
||||
// const boundArrow = UI.createElement("arrow", {
|
||||
// x: -30,
|
||||
// y: 25,
|
||||
// width: 28,
|
||||
// height: 5,
|
||||
// });
|
||||
|
||||
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
||||
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
||||
|
||||
UI.resize(text, "ne", [40, 0]);
|
||||
// UI.resize(text, "ne", [40, 0]);
|
||||
|
||||
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
|
||||
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(30);
|
||||
|
||||
const textWidth = text.width;
|
||||
const scale = 20 / text.height;
|
||||
UI.resize(text, "nw", [50, 20]);
|
||||
// const textWidth = text.width;
|
||||
// const scale = 20 / text.height;
|
||||
// UI.resize(text, "nw", [50, 20]);
|
||||
|
||||
expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
||||
expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
|
||||
30 + textWidth * scale,
|
||||
);
|
||||
});
|
||||
// expect(boundArrow.endBinding?.elementId).toEqual(text.id);
|
||||
// expect(boundArrow.width + boundArrow.endBinding!.gap).toBeCloseTo(
|
||||
// 30 + textWidth * scale,
|
||||
// );
|
||||
// });
|
||||
|
||||
it("updates font size via keyboard", async () => {
|
||||
const text = UI.createElement("text");
|
||||
@@ -801,36 +801,36 @@ describe("image element", () => {
|
||||
expect(image.scale).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("resizes with bound arrow", async () => {
|
||||
const image = API.createElement({
|
||||
type: "image",
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
API.setElements([image]);
|
||||
const arrow = UI.createElement("arrow", {
|
||||
x: -30,
|
||||
y: 50,
|
||||
width: 28,
|
||||
height: 5,
|
||||
});
|
||||
// it("resizes with bound arrow", async () => {
|
||||
// const image = API.createElement({
|
||||
// type: "image",
|
||||
// width: 100,
|
||||
// height: 100,
|
||||
// });
|
||||
// API.setElements([image]);
|
||||
// const arrow = UI.createElement("arrow", {
|
||||
// x: -30,
|
||||
// y: 50,
|
||||
// width: 28,
|
||||
// height: 5,
|
||||
// });
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
// expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
|
||||
UI.resize(image, "ne", [40, 0]);
|
||||
// UI.resize(image, "ne", [40, 0]);
|
||||
|
||||
expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
// expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
|
||||
|
||||
const imageWidth = image.width;
|
||||
const scale = 20 / image.height;
|
||||
UI.resize(image, "nw", [50, 20]);
|
||||
// const imageWidth = image.width;
|
||||
// const scale = 20 / image.height;
|
||||
// UI.resize(image, "nw", [50, 20]);
|
||||
|
||||
expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||
30 + imageWidth * scale,
|
||||
0,
|
||||
);
|
||||
});
|
||||
// expect(arrow.endBinding?.elementId).toEqual(image.id);
|
||||
// expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo(
|
||||
// 30 + imageWidth * scale,
|
||||
// 0,
|
||||
// );
|
||||
// });
|
||||
});
|
||||
|
||||
describe("multiple selection", () => {
|
||||
@@ -997,68 +997,80 @@ describe("multiple selection", () => {
|
||||
expect(diagLine.angle).toEqual(0);
|
||||
});
|
||||
|
||||
it("resizes with bound arrows", async () => {
|
||||
const rectangle = UI.createElement("rectangle", {
|
||||
position: 0,
|
||||
size: 100,
|
||||
});
|
||||
const leftBoundArrow = UI.createElement("arrow", {
|
||||
x: -110,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 0,
|
||||
});
|
||||
// it("resizes with bound arrows", async () => {
|
||||
// const rectangle = UI.createElement("rectangle", {
|
||||
// position: 0,
|
||||
// size: 100,
|
||||
// });
|
||||
// const leftBoundArrow = UI.createElement("arrow", {
|
||||
// x: -110,
|
||||
// y: 50,
|
||||
// width: 100,
|
||||
// height: 0,
|
||||
// });
|
||||
|
||||
const rightBoundArrow = UI.createElement("arrow", {
|
||||
x: 210,
|
||||
y: 50,
|
||||
width: -100,
|
||||
height: 0,
|
||||
});
|
||||
// const rightBoundArrow = UI.createElement("arrow", {
|
||||
// x: 210,
|
||||
// y: 50,
|
||||
// width: -100,
|
||||
// height: 0,
|
||||
// });
|
||||
|
||||
const selectionWidth = 210;
|
||||
const selectionHeight = 100;
|
||||
const move = [40, 40] as [number, number];
|
||||
const scale = Math.max(
|
||||
1 - move[0] / selectionWidth,
|
||||
1 - move[1] / selectionHeight,
|
||||
);
|
||||
const leftArrowBinding = { ...leftBoundArrow.endBinding };
|
||||
const rightArrowBinding = { ...rightBoundArrow.endBinding };
|
||||
delete rightArrowBinding.gap;
|
||||
// const selectionWidth = 210;
|
||||
// const selectionHeight = 100;
|
||||
// const move = [40, 40] as [number, number];
|
||||
// const scale = Math.max(
|
||||
// 1 - move[0] / selectionWidth,
|
||||
// 1 - move[1] / selectionHeight,
|
||||
// );
|
||||
// const leftArrowBinding: {
|
||||
// elementId: string;
|
||||
// gap?: number;
|
||||
// focus?: number;
|
||||
// } = {
|
||||
// ...leftBoundArrow.endBinding,
|
||||
// } as PointBinding;
|
||||
// const rightArrowBinding: {
|
||||
// elementId: string;
|
||||
// gap?: number;
|
||||
// focus?: number;
|
||||
// } = {
|
||||
// ...rightBoundArrow.endBinding,
|
||||
// } as PointBinding;
|
||||
// delete rightArrowBinding.gap;
|
||||
|
||||
UI.resize([rectangle, rightBoundArrow], "nw", move, {
|
||||
shift: true,
|
||||
});
|
||||
// UI.resize([rectangle, rightBoundArrow], "nw", move, {
|
||||
// shift: true,
|
||||
// });
|
||||
|
||||
expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||
expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
expect(leftBoundArrow.angle).toEqual(0);
|
||||
expect(leftBoundArrow.startBinding).toBeNull();
|
||||
expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||
leftArrowBinding.elementId,
|
||||
);
|
||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||
// expect(leftBoundArrow.x).toBeCloseTo(-110);
|
||||
// expect(leftBoundArrow.y).toBeCloseTo(50);
|
||||
// expect(leftBoundArrow.width).toBeCloseTo(140, 0);
|
||||
// expect(leftBoundArrow.height).toBeCloseTo(7, 0);
|
||||
// expect(leftBoundArrow.angle).toEqual(0);
|
||||
// expect(leftBoundArrow.startBinding).toBeNull();
|
||||
// expect(leftBoundArrow.endBinding?.gap).toBeCloseTo(10);
|
||||
// expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||
// leftArrowBinding.elementId,
|
||||
// );
|
||||
// expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||
|
||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||
expect(rightBoundArrow.y).toBeCloseTo(
|
||||
(selectionHeight - 50) * (1 - scale) + 50,
|
||||
);
|
||||
expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
||||
expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||
expect(rightBoundArrow.angle).toEqual(0);
|
||||
expect(rightBoundArrow.startBinding).toBeNull();
|
||||
expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
rightArrowBinding.elementId,
|
||||
);
|
||||
expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
|
||||
rightArrowBinding.focus!,
|
||||
);
|
||||
});
|
||||
// expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||
// expect(rightBoundArrow.y).toBeCloseTo(
|
||||
// (selectionHeight - 50) * (1 - scale) + 50,
|
||||
// );
|
||||
// expect(rightBoundArrow.width).toBeCloseTo(100 * scale);
|
||||
// expect(rightBoundArrow.height).toBeCloseTo(0);
|
||||
// expect(rightBoundArrow.angle).toEqual(0);
|
||||
// expect(rightBoundArrow.startBinding).toBeNull();
|
||||
// expect(rightBoundArrow.endBinding?.gap).toBeCloseTo(8.0952);
|
||||
// expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||
// rightArrowBinding.elementId,
|
||||
// );
|
||||
// expect(rightBoundArrow.endBinding?.focus).toBeCloseTo(
|
||||
// rightArrowBinding.focus!,
|
||||
// );
|
||||
// });
|
||||
|
||||
it("resizes with labeled arrows", async () => {
|
||||
const topArrow = UI.createElement("arrow", {
|
||||
@@ -1338,8 +1350,8 @@ describe("multiple selection", () => {
|
||||
|
||||
expect(boundArrow.x).toBeCloseTo(380 * scaleX);
|
||||
expect(boundArrow.y).toBeCloseTo(240 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(-60 * scaleX);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-80 * scaleY);
|
||||
expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561);
|
||||
expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356);
|
||||
|
||||
expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo(
|
||||
boundArrow.x + boundArrow.points[1][0] / 2,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getLineHeight } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "../src/textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||
|
||||
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeBoundTextPosition", () => {
|
||||
const createMockElementsMap = () => new Map();
|
||||
|
||||
// Helper function to create rectangle test case with 90-degree rotation
|
||||
const createRotatedRectangleTestCase = (
|
||||
textAlign: string,
|
||||
verticalAlign: string,
|
||||
) => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
angle: (Math.PI / 2) as any, // 90 degrees
|
||||
});
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
width: 80,
|
||||
height: 40,
|
||||
text: "hello darkness my old friend",
|
||||
textAlign: textAlign as any,
|
||||
verticalAlign: verticalAlign as any,
|
||||
containerId: container.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
const elementsMap = createMockElementsMap();
|
||||
|
||||
return { container, boundTextElement, elementsMap };
|
||||
};
|
||||
|
||||
describe("90-degree rotation with all alignment combinations", () => {
|
||||
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
|
||||
|
||||
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(
|
||||
TEXT_ALIGN.CENTER,
|
||||
VERTICAL_ALIGN.MIDDLE,
|
||||
);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(
|
||||
TEXT_ALIGN.CENTER,
|
||||
VERTICAL_ALIGN.BOTTOM,
|
||||
);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { wrapText, parseTokens } from "../src/textWrapping";
|
||||
import {
|
||||
getWrappedTextLines,
|
||||
parseTokens,
|
||||
wrapText,
|
||||
} from "../src/textWrapping";
|
||||
|
||||
import type { FontString } from "../src/types";
|
||||
|
||||
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
|
||||
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
||||
});
|
||||
|
||||
it("should retain original text offsets for wrapped lines", () => {
|
||||
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
|
||||
{
|
||||
text: "Hello",
|
||||
start: 0,
|
||||
end: 5,
|
||||
},
|
||||
{
|
||||
text: "World!",
|
||||
start: 6,
|
||||
end: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
|
||||
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
|
||||
{
|
||||
text: " Hello",
|
||||
start: 0,
|
||||
end: 7,
|
||||
},
|
||||
{
|
||||
text: "World",
|
||||
start: 9,
|
||||
end: 14,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should retain offsets when wrapping a single long token", () => {
|
||||
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
|
||||
{
|
||||
text: "Excal",
|
||||
start: 0,
|
||||
end: 5,
|
||||
},
|
||||
{
|
||||
text: "idraw",
|
||||
start: 5,
|
||||
end: 10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve empty hard lines in metadata", () => {
|
||||
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
|
||||
{
|
||||
text: "A",
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
text: "",
|
||||
start: 2,
|
||||
end: 2,
|
||||
},
|
||||
{
|
||||
text: "B",
|
||||
start: 3,
|
||||
end: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("When text is CJK", () => {
|
||||
it("should break each CJK character when width is very small", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
|
||||
Reference in New Issue
Block a user