mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Move element into package
This commit is contained in:
parent
45ac15ab40
commit
54f5c0e156
40 changed files with 0 additions and 0 deletions
2264
packages/element/binding.ts
Normal file
2264
packages/element/binding.ts
Normal file
File diff suppressed because it is too large
Load diff
144
packages/element/bounds.test.ts
Normal file
144
packages/element/bounds.test.ts
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { ROUNDNESS } from "../constants";
|
||||
import { arrayToMap } from "../utils";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "./types";
|
||||
|
||||
const _ce = ({
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
a,
|
||||
t,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
a?: number;
|
||||
t?: string;
|
||||
}) =>
|
||||
({
|
||||
type: t || "rectangle",
|
||||
strokeColor: "#000",
|
||||
backgroundColor: "#000",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 0,
|
||||
opacity: 1,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
angle: a,
|
||||
} as ExcalidrawElement);
|
||||
|
||||
describe("getElementAbsoluteCoords", () => {
|
||||
it("test x1 coordinate", () => {
|
||||
const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
|
||||
const [x1] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(10);
|
||||
});
|
||||
|
||||
it("test x2 coordinate", () => {
|
||||
const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
|
||||
const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(x2).toEqual(20);
|
||||
});
|
||||
|
||||
it("test y1 coordinate", () => {
|
||||
const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
|
||||
const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(y1).toEqual(10);
|
||||
});
|
||||
|
||||
it("test y2 coordinate", () => {
|
||||
const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
|
||||
const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(y2).toEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElementBounds", () => {
|
||||
it("rectangle", () => {
|
||||
const element = _ce({
|
||||
x: 40,
|
||||
y: 30,
|
||||
w: 20,
|
||||
h: 10,
|
||||
a: Math.PI / 4,
|
||||
t: "rectangle",
|
||||
});
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(39.39339828220179);
|
||||
expect(y1).toEqual(24.393398282201787);
|
||||
expect(x2).toEqual(60.60660171779821);
|
||||
expect(y2).toEqual(45.60660171779821);
|
||||
});
|
||||
|
||||
it("diamond", () => {
|
||||
const element = _ce({
|
||||
x: 40,
|
||||
y: 30,
|
||||
w: 20,
|
||||
h: 10,
|
||||
a: Math.PI / 4,
|
||||
t: "diamond",
|
||||
});
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
|
||||
expect(x1).toEqual(42.928932188134524);
|
||||
expect(y1).toEqual(27.928932188134524);
|
||||
expect(x2).toEqual(57.071067811865476);
|
||||
expect(y2).toEqual(42.071067811865476);
|
||||
});
|
||||
|
||||
it("ellipse", () => {
|
||||
const element = _ce({
|
||||
x: 40,
|
||||
y: 30,
|
||||
w: 20,
|
||||
h: 10,
|
||||
a: Math.PI / 4,
|
||||
t: "ellipse",
|
||||
});
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(42.09430584957905);
|
||||
expect(y1).toEqual(27.09430584957905);
|
||||
expect(x2).toEqual(57.90569415042095);
|
||||
expect(y2).toEqual(42.90569415042095);
|
||||
});
|
||||
|
||||
it("curved line", () => {
|
||||
const element = {
|
||||
..._ce({
|
||||
t: "line",
|
||||
x: 449.58203125,
|
||||
y: 186.0625,
|
||||
w: 170.12890625,
|
||||
h: 92.48828125,
|
||||
a: 0.6447741904932416,
|
||||
}),
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(67.33984375, 92.48828125),
|
||||
pointFrom<LocalPoint>(-102.7890625, 52.15625),
|
||||
],
|
||||
} 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);
|
||||
});
|
||||
});
|
1029
packages/element/bounds.ts
Normal file
1029
packages/element/bounds.ts
Normal file
File diff suppressed because it is too large
Load diff
316
packages/element/collision.ts
Normal file
316
packages/element/collision.ts
Normal file
|
@ -0,0 +1,316 @@
|
|||
import {
|
||||
curveIntersectLineSegment,
|
||||
isPointWithinBounds,
|
||||
line,
|
||||
lineSegment,
|
||||
lineSegmentIntersectionPoints,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointsEqual,
|
||||
} from "@excalidraw/math";
|
||||
import {
|
||||
ellipse,
|
||||
ellipseLineIntersectionPoints,
|
||||
} from "@excalidraw/math/ellipse";
|
||||
import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
|
||||
import { getPolygonShape } from "@excalidraw/utils/geometry/shape";
|
||||
|
||||
import type {
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Polygon,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
import type { GeometricShape } from "@excalidraw/utils/geometry/shape";
|
||||
|
||||
import { getBoundTextShape, isPathALoop } from "../shapes";
|
||||
import { isTransparent } from "../utils";
|
||||
|
||||
import { getElementBounds } from "./bounds";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isIframeLikeElement,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
import type { FrameNameBounds } from "../types";
|
||||
|
||||
export const shouldTestInside = (element: ExcalidrawElement) => {
|
||||
if (element.type === "arrow") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isIframeLikeElement(element) ||
|
||||
isTextElement(element);
|
||||
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
||||
if (element.type === "freedraw") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
||||
return isDraggableFromInside || isImageElement(element);
|
||||
};
|
||||
|
||||
export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape<Point>;
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
|
||||
export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
hit = isPointInShape(pointFrom(x, y), {
|
||||
type: "polygon",
|
||||
data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
|
||||
.data as Polygon<Point>,
|
||||
});
|
||||
}
|
||||
|
||||
return hit;
|
||||
};
|
||||
|
||||
export const hitElementBoundingBox = (
|
||||
x: number,
|
||||
y: number,
|
||||
element: ExcalidrawElement,
|
||||
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),
|
||||
pointFrom(x, y),
|
||||
pointFrom(x2, y2),
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundingBoxOnly = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
hitArgs: HitTestArgs<Point>,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return (
|
||||
!hitElementItself(hitArgs) &&
|
||||
// bound text is considered part of the element (even if it's outside the bounding box)
|
||||
!hitElementBoundText(
|
||||
hitArgs.x,
|
||||
hitArgs.y,
|
||||
getBoundTextShape(hitArgs.element, elementsMap),
|
||||
) &&
|
||||
hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
|
||||
);
|
||||
};
|
||||
|
||||
export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
textShape: GeometricShape<Point> | null,
|
||||
): boolean => {
|
||||
return !!textShape && isPointInShape(pointFrom(x, y), textShape);
|
||||
};
|
||||
|
||||
/**
|
||||
* Intersect a line with an element for binding test
|
||||
*
|
||||
* @param element
|
||||
* @param line
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export const intersectElementWithLineSegment = (
|
||||
element: ExcalidrawElement,
|
||||
line: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return intersectRectanguloidWithLineSegment(element, line, offset);
|
||||
case "diamond":
|
||||
return intersectDiamondWithLineSegment(element, line, offset);
|
||||
case "ellipse":
|
||||
return intersectEllipseWithLineSegment(element, line, offset);
|
||||
default:
|
||||
throw new Error(`Unimplemented element type '${element.type}'`);
|
||||
}
|
||||
};
|
||||
|
||||
const intersectRectanguloidWithLineSegment = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads<GlobalPoint>(
|
||||
l[0],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
const rotatedB = pointRotateRads<GlobalPoint>(
|
||||
l[1],
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element, offset);
|
||||
|
||||
return (
|
||||
[
|
||||
// Test intersection against the sides, keep only the valid
|
||||
// intersection points and rotate them back to scene space
|
||||
...sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.filter((x) => x != null)
|
||||
.map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle)),
|
||||
// Test intersection against the corners which are cubic bezier curves,
|
||||
// keep only the valid intersection points and rotate them back to scene
|
||||
// space
|
||||
...corners
|
||||
.flatMap((t) =>
|
||||
curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
|
||||
)
|
||||
.filter((i) => i != null)
|
||||
.map((j) => pointRotateRads(j, center, element.angle)),
|
||||
]
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
const intersectDiamondWithLineSegment = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
const [sides, curves] = deconstructDiamondElement(element, offset);
|
||||
|
||||
return (
|
||||
[
|
||||
...sides
|
||||
.map((s) =>
|
||||
lineSegmentIntersectionPoints(
|
||||
lineSegment<GlobalPoint>(rotatedA, rotatedB),
|
||||
s,
|
||||
),
|
||||
)
|
||||
.filter((p): p is GlobalPoint => p != null)
|
||||
// Rotate back intersection points
|
||||
.map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle)),
|
||||
...curves
|
||||
.flatMap((p) =>
|
||||
curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
|
||||
)
|
||||
.filter((p) => p != null)
|
||||
// Rotate back intersection points
|
||||
.map((p) => pointRotateRads(p, center, element.angle)),
|
||||
]
|
||||
// Remove duplicates
|
||||
.filter(
|
||||
(p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param element
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
const intersectEllipseWithLineSegment = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
l: LineSegment<GlobalPoint>,
|
||||
offset: number = 0,
|
||||
): GlobalPoint[] => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
|
||||
const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
|
||||
|
||||
return ellipseLineIntersectionPoints(
|
||||
ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
|
||||
line(rotatedA, rotatedB),
|
||||
).map((p) => pointRotateRads(p, center, element.angle));
|
||||
};
|
33
packages/element/containerCache.ts
Normal file
33
packages/element/containerCache.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import type { ExcalidrawTextContainer } from "./types";
|
||||
|
||||
export const originalContainerCache: {
|
||||
[id: ExcalidrawTextContainer["id"]]:
|
||||
| {
|
||||
height: ExcalidrawTextContainer["height"];
|
||||
}
|
||||
| undefined;
|
||||
} = {};
|
||||
|
||||
export const updateOriginalContainerCache = (
|
||||
id: ExcalidrawTextContainer["id"],
|
||||
height: ExcalidrawTextContainer["height"],
|
||||
) => {
|
||||
const data =
|
||||
originalContainerCache[id] || (originalContainerCache[id] = { height });
|
||||
data.height = height;
|
||||
return data;
|
||||
};
|
||||
|
||||
export const resetOriginalContainerCache = (
|
||||
id: ExcalidrawTextContainer["id"],
|
||||
) => {
|
||||
if (originalContainerCache[id]) {
|
||||
delete originalContainerCache[id];
|
||||
}
|
||||
};
|
||||
|
||||
export const getOriginalContainerHeightFromCache = (
|
||||
id: ExcalidrawTextContainer["id"],
|
||||
) => {
|
||||
return originalContainerCache[id]?.height ?? null;
|
||||
};
|
627
packages/element/cropElement.ts
Normal file
627
packages/element/cropElement.ts
Normal file
|
@ -0,0 +1,627 @@
|
|||
import {
|
||||
type Radians,
|
||||
pointFrom,
|
||||
pointCenter,
|
||||
pointRotateRads,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorSubtract,
|
||||
vectorAdd,
|
||||
vectorScale,
|
||||
pointFromVector,
|
||||
clamp,
|
||||
isCloseTo,
|
||||
} from "@excalidraw/math";
|
||||
import { type Point } from "points-on-curve";
|
||||
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getResizedElementAbsoluteCoords,
|
||||
} from "./bounds";
|
||||
|
||||
import type { TransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ImageCrop,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export const MINIMAL_CROP_SIZE = 10;
|
||||
|
||||
export const cropElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
transformHandle: TransformHandleType,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
widthAspectRatio?: number,
|
||||
) => {
|
||||
const { width: uncroppedWidth, height: uncroppedHeight } =
|
||||
getUncroppedWidthAndHeight(element);
|
||||
|
||||
const naturalWidthToUncropped = naturalWidth / uncroppedWidth;
|
||||
const naturalHeightToUncropped = naturalHeight / uncroppedHeight;
|
||||
|
||||
const croppedLeft = (element.crop?.x ?? 0) / naturalWidthToUncropped;
|
||||
const croppedTop = (element.crop?.y ?? 0) / naturalHeightToUncropped;
|
||||
|
||||
/**
|
||||
* uncropped width
|
||||
* *––––––––––––––––––––––––*
|
||||
* | (x,y) (natural) |
|
||||
* | *–––––––* |
|
||||
* | |///////| height | uncropped height
|
||||
* | *–––––––* |
|
||||
* | width (natural) |
|
||||
* *––––––––––––––––––––––––*
|
||||
*/
|
||||
|
||||
const rotatedPointer = pointRotateRads(
|
||||
pointFrom(pointerX, pointerY),
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height / 2),
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
pointerX = rotatedPointer[0];
|
||||
pointerY = rotatedPointer[1];
|
||||
|
||||
let nextWidth = element.width;
|
||||
let nextHeight = element.height;
|
||||
|
||||
let crop: ImageCrop | null = element.crop ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: naturalWidth,
|
||||
height: naturalHeight,
|
||||
naturalWidth,
|
||||
naturalHeight,
|
||||
};
|
||||
|
||||
const previousCropHeight = crop.height;
|
||||
const previousCropWidth = crop.width;
|
||||
|
||||
const isFlippedByX = element.scale[0] === -1;
|
||||
const isFlippedByY = element.scale[1] === -1;
|
||||
|
||||
let changeInHeight = pointerY - element.y;
|
||||
let changeInWidth = pointerX - element.x;
|
||||
|
||||
if (transformHandle.includes("n")) {
|
||||
nextHeight = clamp(
|
||||
element.height - changeInHeight,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByY ? uncroppedHeight - croppedTop : element.height + croppedTop,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("s")) {
|
||||
changeInHeight = pointerY - element.y - element.height;
|
||||
nextHeight = clamp(
|
||||
element.height + changeInHeight,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByY ? element.height + croppedTop : uncroppedHeight - croppedTop,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("e")) {
|
||||
changeInWidth = pointerX - element.x - element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
element.width + changeInWidth,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByX ? element.width + croppedLeft : uncroppedWidth - croppedLeft,
|
||||
);
|
||||
}
|
||||
|
||||
if (transformHandle.includes("w")) {
|
||||
nextWidth = clamp(
|
||||
element.width - changeInWidth,
|
||||
MINIMAL_CROP_SIZE,
|
||||
isFlippedByX ? uncroppedWidth - croppedLeft : element.width + croppedLeft,
|
||||
);
|
||||
}
|
||||
|
||||
const updateCropWidthAndHeight = (crop: ImageCrop) => {
|
||||
crop.height = nextHeight * naturalHeightToUncropped;
|
||||
crop.width = nextWidth * naturalWidthToUncropped;
|
||||
};
|
||||
|
||||
updateCropWidthAndHeight(crop);
|
||||
|
||||
const adjustFlipForHandle = (
|
||||
handle: TransformHandleType,
|
||||
crop: ImageCrop,
|
||||
) => {
|
||||
updateCropWidthAndHeight(crop);
|
||||
if (handle.includes("n")) {
|
||||
if (!isFlippedByY) {
|
||||
crop.y += previousCropHeight - crop.height;
|
||||
}
|
||||
}
|
||||
if (handle.includes("s")) {
|
||||
if (isFlippedByY) {
|
||||
crop.y += previousCropHeight - crop.height;
|
||||
}
|
||||
}
|
||||
if (handle.includes("e")) {
|
||||
if (isFlippedByX) {
|
||||
crop.x += previousCropWidth - crop.width;
|
||||
}
|
||||
}
|
||||
if (handle.includes("w")) {
|
||||
if (!isFlippedByX) {
|
||||
crop.x += previousCropWidth - crop.width;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
switch (transformHandle) {
|
||||
case "n": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToLeft = croppedLeft + element.width / 2;
|
||||
const distanceToRight =
|
||||
uncroppedWidth - croppedLeft - element.width / 2;
|
||||
|
||||
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.x += (previousCropWidth - crop.width) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "s": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToLeft = croppedLeft + element.width / 2;
|
||||
const distanceToRight =
|
||||
uncroppedWidth - croppedLeft - element.width / 2;
|
||||
|
||||
const MAX_WIDTH = Math.min(distanceToLeft, distanceToRight) * 2;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.x += (previousCropWidth - crop.width) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "w": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToTop = croppedTop + element.height / 2;
|
||||
const distanceToBottom =
|
||||
uncroppedHeight - croppedTop - element.height / 2;
|
||||
|
||||
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.y += (previousCropHeight - crop.height) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "e": {
|
||||
if (widthAspectRatio) {
|
||||
const distanceToTop = croppedTop + element.height / 2;
|
||||
const distanceToBottom =
|
||||
uncroppedHeight - croppedTop - element.height / 2;
|
||||
|
||||
const MAX_HEIGHT = Math.min(distanceToTop, distanceToBottom) * 2;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
|
||||
if (widthAspectRatio) {
|
||||
crop.y += (previousCropHeight - crop.height) / 2;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "ne": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth > -changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? uncroppedHeight - croppedTop
|
||||
: croppedTop + element.height;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? croppedLeft + element.width
|
||||
: uncroppedWidth - croppedLeft;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "nw": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth < changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? uncroppedHeight - croppedTop
|
||||
: croppedTop + element.height;
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? uncroppedWidth - croppedLeft
|
||||
: croppedLeft + element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "se": {
|
||||
if (widthAspectRatio) {
|
||||
if (changeInWidth > changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? croppedTop + element.height
|
||||
: uncroppedHeight - croppedTop;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? croppedLeft + element.width
|
||||
: uncroppedWidth - croppedLeft;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
case "sw": {
|
||||
if (widthAspectRatio) {
|
||||
if (-changeInWidth > changeInHeight) {
|
||||
const MAX_HEIGHT = isFlippedByY
|
||||
? croppedTop + element.height
|
||||
: uncroppedHeight - croppedTop;
|
||||
|
||||
nextHeight = clamp(
|
||||
nextWidth / widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_HEIGHT,
|
||||
);
|
||||
nextWidth = nextHeight * widthAspectRatio;
|
||||
} else {
|
||||
const MAX_WIDTH = isFlippedByX
|
||||
? uncroppedWidth - croppedLeft
|
||||
: croppedLeft + element.width;
|
||||
|
||||
nextWidth = clamp(
|
||||
nextHeight * widthAspectRatio,
|
||||
MINIMAL_CROP_SIZE,
|
||||
MAX_WIDTH,
|
||||
);
|
||||
nextHeight = nextWidth / widthAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
adjustFlipForHandle(transformHandle, crop);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const newOrigin = recomputeOrigin(
|
||||
element,
|
||||
transformHandle,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
!!widthAspectRatio,
|
||||
);
|
||||
|
||||
// reset crop to null if we're back to orig size
|
||||
if (
|
||||
isCloseTo(crop.width, crop.naturalWidth) &&
|
||||
isCloseTo(crop.height, crop.naturalHeight)
|
||||
) {
|
||||
crop = null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: newOrigin[0],
|
||||
y: newOrigin[1],
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
crop,
|
||||
};
|
||||
};
|
||||
|
||||
const recomputeOrigin = (
|
||||
stateAtCropStart: NonDeleted<ExcalidrawElement>,
|
||||
transformHandle: TransformHandleType,
|
||||
width: number,
|
||||
height: number,
|
||||
shouldMaintainAspectRatio?: boolean,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtCropStart,
|
||||
stateAtCropStart.width,
|
||||
stateAtCropStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft = pointFrom(x1, y1);
|
||||
const startBottomRight = pointFrom(x2, y2);
|
||||
const startCenter: any = pointCenter(startTopLeft, startBottomRight);
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(stateAtCropStart, width, height, true);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
// Calculate new topLeft based on fixed corner during resize
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
|
||||
if (["n", "w", "nw"].includes(transformHandle)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startBottomRight[1] - Math.abs(newBoundsHeight),
|
||||
];
|
||||
}
|
||||
if (transformHandle === "ne") {
|
||||
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
|
||||
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(newBoundsHeight)];
|
||||
}
|
||||
if (transformHandle === "sw") {
|
||||
const topRight = [startBottomRight[0], startTopLeft[1]];
|
||||
newTopLeft = [topRight[0] - Math.abs(newBoundsWidth), topRight[1]];
|
||||
}
|
||||
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (["s", "n"].includes(transformHandle)) {
|
||||
newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
|
||||
}
|
||||
if (["e", "w"].includes(transformHandle)) {
|
||||
newTopLeft[1] = startCenter[1] - newBoundsHeight / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtCropStart.angle;
|
||||
const rotatedTopLeft = pointRotateRads(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
|
||||
newTopLeft = pointRotateRads(
|
||||
rotatedTopLeft,
|
||||
rotatedNewCenter,
|
||||
-angle as Radians,
|
||||
);
|
||||
|
||||
const newOrigin = [...newTopLeft];
|
||||
newOrigin[0] += stateAtCropStart.x - newBoundsX1;
|
||||
newOrigin[1] += stateAtCropStart.y - newBoundsY1;
|
||||
|
||||
return newOrigin;
|
||||
};
|
||||
|
||||
// refer to https://link.excalidraw.com/l/6rfy1007QOo/6stx5PmRn0k
|
||||
export const getUncroppedImageElement = (
|
||||
element: ExcalidrawImageElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (element.crop) {
|
||||
const { width, height } = getUncroppedWidthAndHeight(element);
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
const topLeftVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x1, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topRightVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x2, y1), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const topEdgeNormalized = vectorNormalize(
|
||||
vectorSubtract(topRightVector, topLeftVector),
|
||||
);
|
||||
const bottomLeftVector = vectorFromPoint(
|
||||
pointRotateRads(pointFrom(x1, y2), pointFrom(cx, cy), element.angle),
|
||||
);
|
||||
const leftEdgeVector = vectorSubtract(bottomLeftVector, topLeftVector);
|
||||
const leftEdgeNormalized = vectorNormalize(leftEdgeVector);
|
||||
|
||||
const { cropX, cropY } = adjustCropPosition(element.crop, element.scale);
|
||||
|
||||
const rotatedTopLeft = vectorAdd(
|
||||
vectorAdd(
|
||||
topLeftVector,
|
||||
vectorScale(
|
||||
topEdgeNormalized,
|
||||
(-cropX * width) / element.crop.naturalWidth,
|
||||
),
|
||||
),
|
||||
vectorScale(
|
||||
leftEdgeNormalized,
|
||||
(-cropY * height) / element.crop.naturalHeight,
|
||||
),
|
||||
);
|
||||
|
||||
const center = pointFromVector(
|
||||
vectorAdd(
|
||||
vectorAdd(rotatedTopLeft, vectorScale(topEdgeNormalized, width / 2)),
|
||||
vectorScale(leftEdgeNormalized, height / 2),
|
||||
),
|
||||
);
|
||||
|
||||
const unrotatedTopLeft = pointRotateRads(
|
||||
pointFromVector(rotatedTopLeft),
|
||||
center,
|
||||
-element.angle as Radians,
|
||||
);
|
||||
|
||||
const uncroppedElement: ExcalidrawImageElement = {
|
||||
...element,
|
||||
x: unrotatedTopLeft[0],
|
||||
y: unrotatedTopLeft[1],
|
||||
width,
|
||||
height,
|
||||
crop: null,
|
||||
};
|
||||
|
||||
return uncroppedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const getUncroppedWidthAndHeight = (element: ExcalidrawImageElement) => {
|
||||
if (element.crop) {
|
||||
const width =
|
||||
element.width / (element.crop.width / element.crop.naturalWidth);
|
||||
const height =
|
||||
element.height / (element.crop.height / element.crop.naturalHeight);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
};
|
||||
};
|
||||
|
||||
const adjustCropPosition = (
|
||||
crop: ImageCrop,
|
||||
scale: ExcalidrawImageElement["scale"],
|
||||
) => {
|
||||
let cropX = crop.x;
|
||||
let cropY = crop.y;
|
||||
|
||||
const flipX = scale[0] === -1;
|
||||
const flipY = scale[1] === -1;
|
||||
|
||||
if (flipX) {
|
||||
cropX = crop.naturalWidth - Math.abs(cropX) - crop.width;
|
||||
}
|
||||
|
||||
if (flipY) {
|
||||
cropY = crop.naturalHeight - Math.abs(cropY) - crop.height;
|
||||
}
|
||||
|
||||
return {
|
||||
cropX,
|
||||
cropY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFlipAdjustedCropPosition = (
|
||||
element: ExcalidrawImageElement,
|
||||
natural = false,
|
||||
) => {
|
||||
const crop = element.crop;
|
||||
if (!crop) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFlippedByX = element.scale[0] === -1;
|
||||
const isFlippedByY = element.scale[1] === -1;
|
||||
|
||||
let cropX = crop.x;
|
||||
let cropY = crop.y;
|
||||
|
||||
if (isFlippedByX) {
|
||||
cropX = crop.naturalWidth - crop.width - crop.x;
|
||||
}
|
||||
|
||||
if (isFlippedByY) {
|
||||
cropY = crop.naturalHeight - crop.height - crop.y;
|
||||
}
|
||||
|
||||
if (natural) {
|
||||
return {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
};
|
||||
}
|
||||
|
||||
const { width, height } = getUncroppedWidthAndHeight(element);
|
||||
|
||||
return {
|
||||
x: cropX / (crop.naturalWidth / width),
|
||||
y: cropY / (crop.naturalHeight / height),
|
||||
};
|
||||
};
|
126
packages/element/distance.ts
Normal file
126
packages/element/distance.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
curvePointDistance,
|
||||
distanceToLineSegment,
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
} from "@excalidraw/math";
|
||||
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
|
||||
|
||||
import type { GlobalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
deconstructDiamondElement,
|
||||
deconstructRectanguloidElement,
|
||||
} from "./utils";
|
||||
|
||||
import type {
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawEllipseElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
export const distanceToBindableElement = (
|
||||
element: ExcalidrawBindableElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
return distanceToRectanguloidElement(element, p);
|
||||
case "diamond":
|
||||
return distanceToDiamondElement(element, p);
|
||||
case "ellipse":
|
||||
return distanceToEllipseElement(element, p);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point and the provided rectangular-shaped element,
|
||||
* accounting for roundness and rotation
|
||||
*
|
||||
* @param element The rectanguloid element
|
||||
* @param p The point to consider
|
||||
* @returns The eucledian distance to the outline of the rectanguloid element
|
||||
*/
|
||||
const distanceToRectanguloidElement = (
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
p: GlobalPoint,
|
||||
) => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
// To emulate a rotated rectangle we rotate the point in the inverse angle
|
||||
// instead. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
// Get the element's building components we can test against
|
||||
const [sides, corners] = deconstructRectanguloidElement(element);
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...corners
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point and the provided diamond element, accounting
|
||||
* for roundness and rotation
|
||||
*
|
||||
* @param element The diamond element
|
||||
* @param p The point to consider
|
||||
* @returns The eucledian distance to the outline of the diamond
|
||||
*/
|
||||
const distanceToDiamondElement = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
// Rotate the point to the inverse direction to simulate the rotated diamond
|
||||
// points. It's all the same distance-wise.
|
||||
const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
|
||||
|
||||
const [sides, curves] = deconstructDiamondElement(element);
|
||||
|
||||
return Math.min(
|
||||
...sides.map((s) => distanceToLineSegment(rotatedPoint, s)),
|
||||
...curves
|
||||
.map((a) => curvePointDistance(a, rotatedPoint))
|
||||
.filter((d): d is number => d !== null),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the distance of a point and the provided ellipse element, accounting
|
||||
* for roundness and rotation
|
||||
*
|
||||
* @param element The ellipse element
|
||||
* @param p The point to consider
|
||||
* @returns The eucledian distance to the outline of the ellipse
|
||||
*/
|
||||
const distanceToEllipseElement = (
|
||||
element: ExcalidrawEllipseElement,
|
||||
p: GlobalPoint,
|
||||
): number => {
|
||||
const center = pointFrom(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
return ellipseDistanceFromPoint(
|
||||
// Instead of rotating the ellipse, rotate the point to the inverse angle
|
||||
pointRotateRads(p, center, -element.angle as Radians),
|
||||
ellipse(center, element.width / 2, element.height / 2),
|
||||
);
|
||||
};
|
288
packages/element/dragElements.ts
Normal file
288
packages/element/dragElements.ts
Normal file
|
@ -0,0 +1,288 @@
|
|||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||
import { getGridPoint } from "../snapping";
|
||||
import { getFontString } from "../utils";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { getMinTextElementWidth } from "./textMeasurements";
|
||||
import {
|
||||
isArrowElement,
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
import type { NonDeletedExcalidrawElement } from "./types";
|
||||
import type Scene from "../scene/Scene";
|
||||
import type {
|
||||
AppState,
|
||||
NormalizedZoomValue,
|
||||
NullableGridSize,
|
||||
PointerDownState,
|
||||
} from "../types";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
_selectedElements: NonDeletedExcalidrawElement[],
|
||||
offset: { x: number; y: number },
|
||||
scene: Scene,
|
||||
snapOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
gridSize: NullableGridSize,
|
||||
) => {
|
||||
if (
|
||||
_selectedElements.length === 1 &&
|
||||
isElbowArrow(_selectedElements[0]) &&
|
||||
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedElements = _selectedElements.filter((element) => {
|
||||
if (isElbowArrow(element) && element.startBinding && element.endBinding) {
|
||||
const startElement = _selectedElements.find(
|
||||
(el) => el.id === element.startBinding?.elementId,
|
||||
);
|
||||
const endElement = _selectedElements.find(
|
||||
(el) => el.id === element.endBinding?.elementId,
|
||||
);
|
||||
|
||||
return startElement && endElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
|
||||
selectedElements,
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameLikeElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
for (const element of scene.getNonDeletedElements()) {
|
||||
if (element.frameId !== null && frames.includes(element.frameId)) {
|
||||
elementsToUpdate.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commonBounds = getCommonBounds(
|
||||
Array.from(elementsToUpdate).map(
|
||||
(el) => pointerDownState.originalElements.get(el.id) ?? el,
|
||||
),
|
||||
);
|
||||
const adjustedOffset = calculateOffset(
|
||||
commonBounds,
|
||||
offset,
|
||||
snapOffset,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(pointerDownState, element, adjustedOffset);
|
||||
if (!isArrowElement(element)) {
|
||||
// skip arrow labels since we calculate its position during render
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (textElement) {
|
||||
updateElementCoords(pointerDownState, textElement, adjustedOffset);
|
||||
}
|
||||
updateBoundElements(element, scene.getElementsMapIncludingDeleted(), {
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const calculateOffset = (
|
||||
commonBounds: Bounds,
|
||||
dragOffset: { x: number; y: number },
|
||||
snapOffset: { x: number; y: number },
|
||||
gridSize: NullableGridSize,
|
||||
): { x: number; y: number } => {
|
||||
const [x, y] = commonBounds;
|
||||
let nextX = x + dragOffset.x + snapOffset.x;
|
||||
let nextY = y + dragOffset.y + snapOffset.y;
|
||||
|
||||
if (snapOffset.x === 0 || snapOffset.y === 0) {
|
||||
const [nextGridX, nextGridY] = getGridPoint(
|
||||
x + dragOffset.x,
|
||||
y + dragOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
|
||||
if (snapOffset.x === 0) {
|
||||
nextX = nextGridX;
|
||||
}
|
||||
|
||||
if (snapOffset.y === 0) {
|
||||
nextY = nextGridY;
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: nextX - x,
|
||||
y: nextY - y,
|
||||
};
|
||||
};
|
||||
|
||||
const updateElementCoords = (
|
||||
pointerDownState: PointerDownState,
|
||||
element: NonDeletedExcalidrawElement,
|
||||
dragOffset: { x: number; y: number },
|
||||
) => {
|
||||
const originalElement =
|
||||
pointerDownState.originalElements.get(element.id) ?? element;
|
||||
|
||||
const nextX = originalElement.x + dragOffset.x;
|
||||
const nextY = originalElement.y + dragOffset.y;
|
||||
|
||||
mutateElement(element, {
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDragOffsetXY = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
x: number,
|
||||
y: number,
|
||||
): [number, number] => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
return [x - x1, y - y1];
|
||||
};
|
||||
|
||||
export const dragNewElement = ({
|
||||
newElement,
|
||||
elementType,
|
||||
originX,
|
||||
originY,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
shouldMaintainAspectRatio,
|
||||
shouldResizeFromCenter,
|
||||
zoom,
|
||||
widthAspectRatio = null,
|
||||
originOffset = null,
|
||||
informMutation = true,
|
||||
}: {
|
||||
newElement: NonDeletedExcalidrawElement;
|
||||
elementType: AppState["activeTool"]["type"];
|
||||
originX: number;
|
||||
originY: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
shouldMaintainAspectRatio: boolean;
|
||||
shouldResizeFromCenter: boolean;
|
||||
zoom: NormalizedZoomValue;
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null;
|
||||
originOffset?: {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
informMutation?: boolean;
|
||||
}) => {
|
||||
if (shouldMaintainAspectRatio && newElement.type !== "selection") {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||
// (originX, originY), we use ONLY width or height to control size increase.
|
||||
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||
if (Math.abs(y - originY) > Math.abs(x - originX)) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
height,
|
||||
x < originX ? -width : width,
|
||||
));
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
}
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newX = x < originX ? originX - width : originX;
|
||||
let newY = y < originY ? originY - height : originY;
|
||||
|
||||
if (shouldResizeFromCenter) {
|
||||
width += width;
|
||||
height += height;
|
||||
newX = originX - width / 2;
|
||||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
let textAutoResize = null;
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
height = newElement.height;
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: newElement.fontSize,
|
||||
fontFamily: newElement.fontFamily,
|
||||
}),
|
||||
newElement.lineHeight,
|
||||
);
|
||||
width = Math.max(width, minWidth);
|
||||
|
||||
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||
textAutoResize = {
|
||||
autoResize: false,
|
||||
};
|
||||
}
|
||||
|
||||
newY = originY;
|
||||
if (shouldResizeFromCenter) {
|
||||
newX = originX - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
let imageInitialDimension = null;
|
||||
if (isImageElement(newElement)) {
|
||||
imageInitialDimension = {
|
||||
initialWidth: width,
|
||||
initialHeight: height,
|
||||
};
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
newElement,
|
||||
{
|
||||
x: newX + (originOffset?.x ?? 0),
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
...imageInitialDimension,
|
||||
},
|
||||
informMutation,
|
||||
);
|
||||
}
|
||||
};
|
412
packages/element/elbowArrow.test.tsx
Normal file
412
packages/element/elbowArrow.test.tsx
Normal file
|
@ -0,0 +1,412 @@
|
|||
import { pointFrom } from "@excalidraw/math";
|
||||
import React from "react";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import "../../utils/test-utils";
|
||||
import { actionSelectAll } from "../actions";
|
||||
import { actionDuplicateSelection } from "../actions/actionDuplicateSelection";
|
||||
import { ARROW_TYPE } from "../constants";
|
||||
import { Excalidraw, mutateElement } from "../index";
|
||||
import Scene from "../scene/Scene";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { Pointer, UI } from "../tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryByTestId,
|
||||
render,
|
||||
} from "../tests/test-utils";
|
||||
|
||||
import { bindLinearElement } from "./binding";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("elbow arrow segment move", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("can move the second segment of a fully connected elbow arrow", () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -100,
|
||||
y: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 200,
|
||||
y: 150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(0, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(200, 200);
|
||||
mouse.click();
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.down();
|
||||
mouse.moveTo(115, 100);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
|
||||
expect(arrow.fixedSegments?.length).toBe(1);
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[110, 200],
|
||||
[190, 200],
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(105, 74.275);
|
||||
mouse.doubleClick();
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[110, 200],
|
||||
[190, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
it("can move the second segment of an unconnected elbow arrow", () => {
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(0, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(250, 200);
|
||||
mouse.click();
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(125, 100);
|
||||
mouse.down();
|
||||
mouse.moveTo(130, 100);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[130, 0],
|
||||
[130, 200],
|
||||
[250, 200],
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(130, 100);
|
||||
mouse.doubleClick();
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[125, 0],
|
||||
[125, 200],
|
||||
[250, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow routing", () => {
|
||||
it("can properly generate orthogonal arrow points", () => {
|
||||
const scene = new Scene();
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||
],
|
||||
});
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.x).toEqual(-45);
|
||||
expect(arrow.y).toEqual(-100.1);
|
||||
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,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
x: -45,
|
||||
y: -100.1,
|
||||
width: 90,
|
||||
height: 200,
|
||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
scene.insertElement(arrow);
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
bindLinearElement(arrow, rectangle1, "start", elementsMap);
|
||||
bindLinearElement(arrow, rectangle2, "end", elementsMap);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
mutateElement(arrow, {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||
});
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow ui", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
});
|
||||
|
||||
it("can follow bound shapes", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(arrow.type).toBe("arrow");
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
it("can follow bound rotated shapes", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
mouse.click(51, 51);
|
||||
|
||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
UI.updateInput(inputAngle, String("40"));
|
||||
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when only the bound arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
2283
packages/element/elbowArrow.ts
Normal file
2283
packages/element/elbowArrow.ts
Normal file
File diff suppressed because it is too large
Load diff
103
packages/element/elementLink.ts
Normal file
103
packages/element/elementLink.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Create and link between shapes.
|
||||
*/
|
||||
|
||||
import { ELEMENT_LINK_KEY } from "../constants";
|
||||
import { normalizeLink } from "../data/url";
|
||||
import { elementsAreInSameGroup } from "../groups";
|
||||
|
||||
import type { AppProps, AppState } from "../types";
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
export const defaultGetElementLinkFromSelection: Exclude<
|
||||
AppProps["generateLinkForSelection"],
|
||||
undefined
|
||||
> = (id, type) => {
|
||||
const url = window.location.href;
|
||||
|
||||
try {
|
||||
const link = new URL(url);
|
||||
link.searchParams.set(ELEMENT_LINK_KEY, id);
|
||||
|
||||
return normalizeLink(link.toString());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return normalizeLink(url);
|
||||
};
|
||||
|
||||
export const getLinkIdAndTypeFromSelection = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): {
|
||||
id: string;
|
||||
type: "element" | "group";
|
||||
} | null => {
|
||||
if (
|
||||
selectedElements.length > 0 &&
|
||||
canCreateLinkFromElements(selectedElements)
|
||||
) {
|
||||
if (selectedElements.length === 1) {
|
||||
return {
|
||||
id: selectedElements[0].id,
|
||||
type: "element",
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedElements.length > 1) {
|
||||
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
|
||||
|
||||
if (selectedGroupId) {
|
||||
return {
|
||||
id: selectedGroupId,
|
||||
type: "group",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: selectedElements[0].groupIds[0],
|
||||
type: "group",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const canCreateLinkFromElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
) => {
|
||||
if (selectedElements.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isElementLink = (url: string) => {
|
||||
try {
|
||||
const _url = new URL(url);
|
||||
return (
|
||||
_url.searchParams.has(ELEMENT_LINK_KEY) &&
|
||||
_url.host === window.location.host
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseElementLinkFromURL = (url: string) => {
|
||||
try {
|
||||
const { searchParams } = new URL(url);
|
||||
if (searchParams.has(ELEMENT_LINK_KEY)) {
|
||||
const id = searchParams.get(ELEMENT_LINK_KEY);
|
||||
return id;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
};
|
446
packages/element/embeddable.ts
Normal file
446
packages/element/embeddable.ts
Normal file
|
@ -0,0 +1,446 @@
|
|||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { CaptureUpdateAction } from "../store";
|
||||
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
|
||||
|
||||
import { newTextElement } from "./newElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isIframeElement } from "./typeChecks";
|
||||
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import type { MarkRequired } from "../utility-types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
} from "./types";
|
||||
|
||||
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||
|
||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER =
|
||||
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
|
||||
const RE_TWITTER_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
|
||||
|
||||
const RE_VALTOWN =
|
||||
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
|
||||
const RE_GIPHY =
|
||||
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
|
||||
|
||||
const RE_REDDIT =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?reddit\.com\/r\/([a-zA-Z0-9_]+)\/comments\/([a-zA-Z0-9_]+)\/([a-zA-Z0-9_]+)\/?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
|
||||
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"figma.com",
|
||||
"link.excalidraw.com",
|
||||
"gist.github.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
"giphy.com",
|
||||
"reddit.com",
|
||||
]);
|
||||
|
||||
const ALLOW_SAME_ORIGIN = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"figma.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"reddit.com",
|
||||
]);
|
||||
|
||||
export const createSrcDoc = (body: string) => {
|
||||
return `<html><body>${body}</body></html>`;
|
||||
};
|
||||
|
||||
export const getEmbedLink = (
|
||||
link: string | null | undefined,
|
||||
): IframeDataWithSandbox | null => {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (embeddedLinkCache.has(link)) {
|
||||
return embeddedLinkCache.get(link)!;
|
||||
}
|
||||
|
||||
const originalLink = link;
|
||||
|
||||
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
|
||||
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
|
||||
);
|
||||
|
||||
let type: "video" | "generic" = "generic";
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
case "embed/":
|
||||
case "watch?v=":
|
||||
case "shorts/":
|
||||
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
|
||||
break;
|
||||
case "playlist?list=":
|
||||
case "embed/videoseries?list=":
|
||||
link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
|
||||
break;
|
||||
default:
|
||||
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
|
||||
break;
|
||||
}
|
||||
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const vimeoLink = link.match(RE_VIMEO);
|
||||
if (vimeoLink?.[1]) {
|
||||
const target = vimeoLink?.[1];
|
||||
const error = !/^\d+$/.test(target)
|
||||
? new URIError("Invalid embed link format")
|
||||
: undefined;
|
||||
type = "video";
|
||||
link = `https://player.vimeo.com/video/${target}?api=1`;
|
||||
aspectRatio = { w: 560, h: 315 };
|
||||
//warning deliberately ommited so it is displayed only once per link
|
||||
//same link next time will be served from cache
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
error,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
if (figmaLink) {
|
||||
type = "generic";
|
||||
link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
|
||||
link,
|
||||
)}`;
|
||||
aspectRatio = { w: 550, h: 550 };
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
const valLink = link.match(RE_VALTOWN);
|
||||
if (valLink) {
|
||||
link =
|
||||
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
|
||||
embeddedLinkCache.set(originalLink, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
const postId = link.match(RE_TWITTER)![1];
|
||||
// the embed srcdoc still supports twitter.com domain only.
|
||||
// Note that we don't attempt to parse the username as it can consist of
|
||||
// non-latin1 characters, and the username in the url can be set to anything
|
||||
// without affecting the embed.
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://twitter.com/x/status/${postId}`,
|
||||
);
|
||||
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_REDDIT.test(link)) {
|
||||
const [, page, postId, title] = link.match(RE_REDDIT)!;
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="reddit-embed-bq" data-embed-theme="${theme}"><a href="${safeURL}"></a><br></blockquote><script async="" src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>`,
|
||||
),
|
||||
intrinsicSize: { w: 480, h: 480 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
const [, user, gistId] = link.match(RE_GH_GIST)!;
|
||||
const safeURL = escapeDoubleQuotes(
|
||||
`https://gist.github.com/${user}/${gistId}`,
|
||||
);
|
||||
const ret: IframeDataWithSandbox = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
<script src="${safeURL}.js"></script>
|
||||
<style type="text/css">
|
||||
* { margin: 0px; }
|
||||
table, .gist { height: 100%; }
|
||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||
</style>
|
||||
`),
|
||||
intrinsicSize: { w: 550, h: 720 },
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
embeddedLinkCache.set(link, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
embeddedLinkCache.set(link, {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
});
|
||||
return {
|
||||
link,
|
||||
intrinsicSize: aspectRatio,
|
||||
type,
|
||||
sandbox: { allowSameOrigin },
|
||||
};
|
||||
};
|
||||
|
||||
export const createPlaceholderEmbeddableLabel = (
|
||||
element: ExcalidrawIframeLikeElement,
|
||||
): ExcalidrawElement => {
|
||||
let text: string;
|
||||
if (isIframeElement(element)) {
|
||||
text = "IFrame element";
|
||||
} else {
|
||||
text =
|
||||
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
|
||||
}
|
||||
|
||||
const fontSize = Math.max(
|
||||
Math.min(element.width / 2, element.width / text.length),
|
||||
element.width / 30,
|
||||
);
|
||||
const fontFamily = FONT_FAMILY.Helvetica;
|
||||
|
||||
const fontString = getFontString({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
|
||||
return newTextElement({
|
||||
x: element.x + element.width / 2,
|
||||
y: element.y + element.height / 2,
|
||||
strokeColor:
|
||||
element.strokeColor !== "transparent" ? element.strokeColor : "black",
|
||||
backgroundColor: "transparent",
|
||||
fontFamily,
|
||||
fontSize,
|
||||
text: wrapText(text, fontString, element.width - 20),
|
||||
textAlign: "center",
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
angle: element.angle ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const actionSetEmbeddableAsActiveTool = register({
|
||||
name: "setEmbeddableAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
target: "Tool",
|
||||
label: "toolBar.embeddable",
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
}),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const matchHostname = (
|
||||
url: string,
|
||||
/** using a Set assumes it already contains normalized bare domains */
|
||||
allowedHostnames: Set<string> | string,
|
||||
): string | null => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
const bareDomain = hostname.replace(/^www\./, "");
|
||||
|
||||
if (allowedHostnames instanceof Set) {
|
||||
if (ALLOWED_DOMAINS.has(bareDomain)) {
|
||||
return bareDomain;
|
||||
}
|
||||
|
||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||
/^([^.]+)/,
|
||||
"*",
|
||||
);
|
||||
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
|
||||
return bareDomainWithFirstSubdomainWildcarded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
|
||||
if (bareDomain === bareAllowedHostname) {
|
||||
return bareAllowedHostname;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const maybeParseEmbedSrc = (str: string): string => {
|
||||
const twitterMatch = str.match(RE_TWITTER_EMBED);
|
||||
if (twitterMatch && twitterMatch.length === 2) {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const redditMatch = str.match(RE_REDDIT_EMBED);
|
||||
if (redditMatch && redditMatch.length === 2) {
|
||||
return redditMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = str.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
}
|
||||
|
||||
if (RE_GIPHY.test(str)) {
|
||||
return `https://giphy.com/embed/${RE_GIPHY.exec(str)![1]}`;
|
||||
}
|
||||
|
||||
const match = str.match(RE_GENERIC_EMBED);
|
||||
if (match && match.length === 2) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
export const embeddableURLValidator = (
|
||||
url: string | null | undefined,
|
||||
validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
|
||||
): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
if (validateEmbeddable != null) {
|
||||
if (typeof validateEmbeddable === "function") {
|
||||
const ret = validateEmbeddable(url);
|
||||
// if return value is undefined, leave validation to default
|
||||
if (typeof ret === "boolean") {
|
||||
return ret;
|
||||
}
|
||||
} else if (typeof validateEmbeddable === "boolean") {
|
||||
return validateEmbeddable;
|
||||
} else if (validateEmbeddable instanceof RegExp) {
|
||||
return validateEmbeddable.test(url);
|
||||
} else if (Array.isArray(validateEmbeddable)) {
|
||||
for (const domain of validateEmbeddable) {
|
||||
if (domain instanceof RegExp) {
|
||||
if (url.match(domain)) {
|
||||
return true;
|
||||
}
|
||||
} else if (matchHostname(url, domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return !!matchHostname(url, ALLOWED_DOMAINS);
|
||||
};
|
403
packages/element/flowchart.test.tsx
Normal file
403
packages/element/flowchart.test.tsx
Normal file
|
@ -0,0 +1,403 @@
|
|||
import { Excalidraw } from "../index";
|
||||
import { KEYS } from "../keys";
|
||||
import { reseed } from "../random";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { UI, Keyboard, Pointer } from "../tests/helpers/ui";
|
||||
import { render, unmountComponent } from "../tests/test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
mouse.reset();
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.state.width = 1000;
|
||||
h.state.height = 1000;
|
||||
|
||||
// The bounds of hand-drawn linear elements may change after flipping, so
|
||||
// removing this style for testing
|
||||
UI.clickTool("arrow");
|
||||
UI.clickByTitle("Architect");
|
||||
UI.clickTool("selection");
|
||||
});
|
||||
|
||||
describe("flow chart creation", () => {
|
||||
beforeEach(() => {
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
});
|
||||
|
||||
// multiple at once
|
||||
it("create multiple successor nodes at once", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
});
|
||||
|
||||
it("when directions are changed, only the last same directions will apply", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
});
|
||||
|
||||
it("when escaped, no nodes will be created", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("create nodes one at a time", () => {
|
||||
const initialNode = h.elements[0];
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
|
||||
|
||||
const firstChildNode = h.elements.filter(
|
||||
(el) => el.type === "rectangle" && el.id !== initialNode.id,
|
||||
)[0];
|
||||
expect(firstChildNode).not.toBe(null);
|
||||
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
|
||||
const secondChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id,
|
||||
)[0];
|
||||
expect(secondChildNode).not.toBe(null);
|
||||
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
|
||||
const thirdChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id &&
|
||||
el.id !== secondChildNode.id,
|
||||
)[0];
|
||||
|
||||
expect(thirdChildNode).not.toBe(null);
|
||||
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
expect(firstChildNode.x).toBe(secondChildNode.x);
|
||||
expect(secondChildNode.x).toBe(thirdChildNode.x);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flow chart navigation", () => {
|
||||
it("single node at each level", () => {
|
||||
/**
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
|
||||
|
||||
// all the way to the left, gets us to the first node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// all the way to the right, gets us to the last node
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode);
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("multiple nodes at each level", () => {
|
||||
/**
|
||||
* from the perspective of the first node, there're four layers, and
|
||||
* there are four nodes at the second layer
|
||||
*
|
||||
* -> ▨
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
* -> ▨
|
||||
* -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
const secondNode = h.elements[1];
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
// because of same level cycling,
|
||||
// going right five times should take us back to the second node again
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
|
||||
|
||||
// from the second node, going right three times should take us to the rightmost node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("take the most obvious link when possible", () => {
|
||||
/**
|
||||
* ▨ → ▨ ▨ → ▨
|
||||
* ↓ ↑
|
||||
* ▨ → ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
// last node should be the one that's selected
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// going any direction takes us to the predecessor as well
|
||||
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
718
packages/element/flowchart.ts
Normal file
718
packages/element/flowchart.ts
Normal file
|
@ -0,0 +1,718 @@
|
|||
import { pointFrom, type LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { aabbForElement } from "../shapes";
|
||||
import { invariant, toBrandedType } from "../utils";
|
||||
|
||||
import { bindLinearElement } from "./binding";
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import {
|
||||
HEADING_DOWN,
|
||||
HEADING_LEFT,
|
||||
HEADING_RIGHT,
|
||||
HEADING_UP,
|
||||
compareHeading,
|
||||
headingForPointFromElement,
|
||||
type Heading,
|
||||
} from "./heading";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { newArrowElement, newElement } from "./newElement";
|
||||
import {
|
||||
isBindableElement,
|
||||
isElbowArrow,
|
||||
isFrameElement,
|
||||
isFlowchartNodeElement,
|
||||
} from "./typeChecks";
|
||||
import {
|
||||
type ElementsMap,
|
||||
type ExcalidrawBindableElement,
|
||||
type ExcalidrawElement,
|
||||
type ExcalidrawFlowchartNodeElement,
|
||||
type NonDeletedSceneElementsMap,
|
||||
type Ordered,
|
||||
type OrderedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import type { AppState, PendingExcalidrawElements } from "../types";
|
||||
|
||||
type LinkDirection = "up" | "right" | "down" | "left";
|
||||
|
||||
const VERTICAL_OFFSET = 100;
|
||||
const HORIZONTAL_OFFSET = 100;
|
||||
|
||||
export const getLinkDirectionFromKey = (key: string): LinkDirection => {
|
||||
switch (key) {
|
||||
case KEYS.ARROW_UP:
|
||||
return "up";
|
||||
case KEYS.ARROW_DOWN:
|
||||
return "down";
|
||||
case KEYS.ARROW_RIGHT:
|
||||
return "right";
|
||||
case KEYS.ARROW_LEFT:
|
||||
return "left";
|
||||
default:
|
||||
return "right";
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeRelatives = (
|
||||
type: "predecessors" | "successors",
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const items = [...elementsMap.values()].reduce(
|
||||
(acc: { relative: ExcalidrawBindableElement; heading: Heading }[], el) => {
|
||||
let oppositeBinding;
|
||||
if (
|
||||
isElbowArrow(el) &&
|
||||
// we want check existence of the opposite binding, in the direction
|
||||
// we're interested in
|
||||
(oppositeBinding =
|
||||
el[type === "predecessors" ? "startBinding" : "endBinding"]) &&
|
||||
// similarly, we need to filter only arrows bound to target node
|
||||
el[type === "predecessors" ? "endBinding" : "startBinding"]
|
||||
?.elementId === node.id
|
||||
) {
|
||||
const relative = elementsMap.get(oppositeBinding.elementId);
|
||||
|
||||
if (!relative) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
invariant(
|
||||
isBindableElement(relative),
|
||||
"not an ExcalidrawBindableElement",
|
||||
);
|
||||
|
||||
const edgePoint = (
|
||||
type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
|
||||
) as Readonly<LocalPoint>;
|
||||
|
||||
const heading = headingForPointFromElement(node, aabbForElement(node), [
|
||||
edgePoint[0] + el.x,
|
||||
edgePoint[1] + el.y,
|
||||
] as Readonly<LocalPoint>);
|
||||
|
||||
acc.push({
|
||||
relative,
|
||||
heading,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_UP))
|
||||
.map((item) => item.relative);
|
||||
case "down":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_DOWN))
|
||||
.map((item) => item.relative);
|
||||
case "right":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_RIGHT))
|
||||
.map((item) => item.relative);
|
||||
case "left":
|
||||
return items
|
||||
.filter((item) => compareHeading(item.heading, HEADING_LEFT))
|
||||
.map((item) => item.relative);
|
||||
}
|
||||
};
|
||||
|
||||
const getSuccessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("successors", node, elementsMap, direction);
|
||||
};
|
||||
|
||||
export const getPredecessors = (
|
||||
node: ExcalidrawBindableElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
return getNodeRelatives("predecessors", node, elementsMap, direction);
|
||||
};
|
||||
|
||||
const getOffsets = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
linkedNodes: ExcalidrawElement[],
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const _HORIZONTAL_OFFSET = HORIZONTAL_OFFSET + element.width;
|
||||
|
||||
// check if vertical space or horizontal space is available first
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
// check vertical space
|
||||
const minX = element.x;
|
||||
const maxX = element.x + element.width;
|
||||
|
||||
// vertical space is available
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.x + linkedNode.width < minX || linkedNode.x > maxX,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x: 0,
|
||||
y: _VERTICAL_OFFSET * (direction === "up" ? -1 : 1),
|
||||
};
|
||||
}
|
||||
} else if (direction === "right" || direction === "left") {
|
||||
const minY = element.y;
|
||||
const maxY = element.y + element.height;
|
||||
|
||||
if (
|
||||
linkedNodes.every(
|
||||
(linkedNode) =>
|
||||
linkedNode.y + linkedNode.height < minY || linkedNode.y > maxY,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
x:
|
||||
(HORIZONTAL_OFFSET + element.width) * (direction === "left" ? -1 : 1),
|
||||
y: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === "up" || direction === "down") {
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const y = linkedNodes.length === 0 ? _VERTICAL_OFFSET : _VERTICAL_OFFSET;
|
||||
const x =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _HORIZONTAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _HORIZONTAL_OFFSET * -1;
|
||||
|
||||
if (direction === "up") {
|
||||
return {
|
||||
x,
|
||||
y: y * -1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
}
|
||||
|
||||
const _VERTICAL_OFFSET = VERTICAL_OFFSET + element.height;
|
||||
const x =
|
||||
(linkedNodes.length === 0 ? HORIZONTAL_OFFSET : HORIZONTAL_OFFSET) +
|
||||
element.width;
|
||||
const y =
|
||||
linkedNodes.length === 0
|
||||
? 0
|
||||
: (linkedNodes.length + 1) % 2 === 0
|
||||
? ((linkedNodes.length + 1) / 2) * _VERTICAL_OFFSET
|
||||
: (linkedNodes.length / 2) * _VERTICAL_OFFSET * -1;
|
||||
|
||||
if (direction === "left") {
|
||||
return {
|
||||
x: x * -1,
|
||||
y,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
};
|
||||
};
|
||||
|
||||
const addNewNode = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
) => {
|
||||
const successors = getSuccessors(element, elementsMap, direction);
|
||||
const predeccessors = getPredecessors(element, elementsMap, direction);
|
||||
|
||||
const offsets = getOffsets(
|
||||
element,
|
||||
[...successors, ...predeccessors],
|
||||
direction,
|
||||
);
|
||||
|
||||
const nextNode = newElement({
|
||||
type: element.type,
|
||||
x: element.x + offsets.x,
|
||||
y: element.y + offsets.y,
|
||||
// TODO: extract this to a util
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
roundness: element.roundness,
|
||||
roughness: element.roughness,
|
||||
backgroundColor: element.backgroundColor,
|
||||
strokeColor: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
opacity: element.opacity,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeStyle: element.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
element,
|
||||
nextNode,
|
||||
elementsMap,
|
||||
direction,
|
||||
appState,
|
||||
);
|
||||
|
||||
return {
|
||||
nextNode,
|
||||
bindingArrow,
|
||||
};
|
||||
};
|
||||
|
||||
export const addNewNodes = (
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
numberOfNodes: number,
|
||||
) => {
|
||||
// always start from 0 and distribute evenly
|
||||
const newNodes: ExcalidrawElement[] = [];
|
||||
|
||||
for (let i = 0; i < numberOfNodes; i++) {
|
||||
let nextX: number;
|
||||
let nextY: number;
|
||||
if (direction === "left" || direction === "right") {
|
||||
const totalHeight =
|
||||
VERTICAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.height;
|
||||
|
||||
const startY = startNode.y + startNode.height / 2 - totalHeight / 2;
|
||||
|
||||
let offsetX = HORIZONTAL_OFFSET + startNode.width;
|
||||
if (direction === "left") {
|
||||
offsetX *= -1;
|
||||
}
|
||||
nextX = startNode.x + offsetX;
|
||||
const offsetY = (VERTICAL_OFFSET + startNode.height) * i;
|
||||
nextY = startY + offsetY;
|
||||
} else {
|
||||
const totalWidth =
|
||||
HORIZONTAL_OFFSET * (numberOfNodes - 1) +
|
||||
numberOfNodes * startNode.width;
|
||||
const startX = startNode.x + startNode.width / 2 - totalWidth / 2;
|
||||
let offsetY = VERTICAL_OFFSET + startNode.height;
|
||||
|
||||
if (direction === "up") {
|
||||
offsetY *= -1;
|
||||
}
|
||||
nextY = startNode.y + offsetY;
|
||||
const offsetX = (HORIZONTAL_OFFSET + startNode.width) * i;
|
||||
nextX = startX + offsetX;
|
||||
}
|
||||
|
||||
const nextNode = newElement({
|
||||
type: startNode.type,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
// TODO: extract this to a util
|
||||
width: startNode.width,
|
||||
height: startNode.height,
|
||||
roundness: startNode.roundness,
|
||||
roughness: startNode.roughness,
|
||||
backgroundColor: startNode.backgroundColor,
|
||||
strokeColor: startNode.strokeColor,
|
||||
strokeWidth: startNode.strokeWidth,
|
||||
opacity: startNode.opacity,
|
||||
fillStyle: startNode.fillStyle,
|
||||
strokeStyle: startNode.strokeStyle,
|
||||
});
|
||||
|
||||
invariant(
|
||||
isFlowchartNodeElement(nextNode),
|
||||
"not an ExcalidrawFlowchartNodeElement",
|
||||
);
|
||||
|
||||
const bindingArrow = createBindingArrow(
|
||||
startNode,
|
||||
nextNode,
|
||||
elementsMap,
|
||||
direction,
|
||||
appState,
|
||||
);
|
||||
|
||||
newNodes.push(nextNode);
|
||||
newNodes.push(bindingArrow);
|
||||
}
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const createBindingArrow = (
|
||||
startBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
endBindingElement: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
appState: AppState,
|
||||
) => {
|
||||
let startX: number;
|
||||
let startY: number;
|
||||
|
||||
const PADDING = 6;
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||
startY = startBindingElement.y - PADDING;
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
startX = startBindingElement.x + startBindingElement.width / 2;
|
||||
startY = startBindingElement.y + startBindingElement.height + PADDING;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
startX = startBindingElement.x + startBindingElement.width + PADDING;
|
||||
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
startX = startBindingElement.x - PADDING;
|
||||
startY = startBindingElement.y + startBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let endX: number;
|
||||
let endY: number;
|
||||
|
||||
switch (direction) {
|
||||
case "up": {
|
||||
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||
endY = endBindingElement.y + endBindingElement.height - startY + PADDING;
|
||||
break;
|
||||
}
|
||||
case "down": {
|
||||
endX = endBindingElement.x + endBindingElement.width / 2 - startX;
|
||||
endY = endBindingElement.y - startY - PADDING;
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
endX = endBindingElement.x - startX - PADDING;
|
||||
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
case "left": {
|
||||
endX = endBindingElement.x + endBindingElement.width - startX + PADDING;
|
||||
endY = endBindingElement.y - startY + endBindingElement.height / 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bindingArrow = newArrowElement({
|
||||
type: "arrow",
|
||||
x: startX,
|
||||
y: startY,
|
||||
startArrowhead: null,
|
||||
endArrowhead: appState.currentItemEndArrowhead,
|
||||
strokeColor: startBindingElement.strokeColor,
|
||||
strokeStyle: startBindingElement.strokeStyle,
|
||||
strokeWidth: startBindingElement.strokeWidth,
|
||||
opacity: startBindingElement.opacity,
|
||||
roughness: startBindingElement.roughness,
|
||||
points: [pointFrom(0, 0), pointFrom(endX, endY)],
|
||||
elbowed: true,
|
||||
});
|
||||
|
||||
bindLinearElement(
|
||||
bindingArrow,
|
||||
startBindingElement,
|
||||
"start",
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
bindLinearElement(
|
||||
bindingArrow,
|
||||
endBindingElement,
|
||||
"end",
|
||||
elementsMap as NonDeletedSceneElementsMap,
|
||||
);
|
||||
|
||||
const changedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
changedElements.set(
|
||||
startBindingElement.id,
|
||||
startBindingElement as OrderedExcalidrawElement,
|
||||
);
|
||||
changedElements.set(
|
||||
endBindingElement.id,
|
||||
endBindingElement as OrderedExcalidrawElement,
|
||||
);
|
||||
changedElements.set(
|
||||
bindingArrow.id,
|
||||
bindingArrow as OrderedExcalidrawElement,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(bindingArrow, [
|
||||
{
|
||||
index: 1,
|
||||
point: bindingArrow.points[1],
|
||||
},
|
||||
]);
|
||||
|
||||
const update = updateElbowArrowPoints(
|
||||
bindingArrow,
|
||||
toBrandedType<NonDeletedSceneElementsMap>(
|
||||
new Map([
|
||||
...elementsMap.entries(),
|
||||
[startBindingElement.id, startBindingElement],
|
||||
[endBindingElement.id, endBindingElement],
|
||||
[bindingArrow.id, bindingArrow],
|
||||
] as [string, Ordered<ExcalidrawElement>][]),
|
||||
),
|
||||
{ points: bindingArrow.points },
|
||||
);
|
||||
|
||||
return {
|
||||
...bindingArrow,
|
||||
...update,
|
||||
};
|
||||
};
|
||||
|
||||
export class FlowChartNavigator {
|
||||
isExploring: boolean = false;
|
||||
// nodes that are ONE link away (successor and predecessor both included)
|
||||
private sameLevelNodes: ExcalidrawElement[] = [];
|
||||
private sameLevelIndex: number = 0;
|
||||
// set it to the opposite of the defalut creation direction
|
||||
private direction: LinkDirection | null = null;
|
||||
// for speedier navigation
|
||||
private visitedNodes: Set<ExcalidrawElement["id"]> = new Set();
|
||||
|
||||
clear() {
|
||||
this.isExploring = false;
|
||||
this.sameLevelNodes = [];
|
||||
this.sameLevelIndex = 0;
|
||||
this.direction = null;
|
||||
this.visitedNodes.clear();
|
||||
}
|
||||
|
||||
exploreByDirection(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
direction: LinkDirection,
|
||||
): ExcalidrawElement["id"] | null {
|
||||
if (!isBindableElement(element)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// clear if going at a different direction
|
||||
if (direction !== this.direction) {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
// add the current node to the visited
|
||||
if (!this.visitedNodes.has(element.id)) {
|
||||
this.visitedNodes.add(element.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - already started exploring, AND
|
||||
* - there are multiple nodes at the same level, AND
|
||||
* - still going at the same direction, AND
|
||||
*
|
||||
* RESULT:
|
||||
* - loop through nodes at the same level
|
||||
*
|
||||
* WHY:
|
||||
* - provides user the capability to loop through nodes at the same level
|
||||
*/
|
||||
if (
|
||||
this.isExploring &&
|
||||
direction === this.direction &&
|
||||
this.sameLevelNodes.length > 1
|
||||
) {
|
||||
this.sameLevelIndex =
|
||||
(this.sameLevelIndex + 1) % this.sameLevelNodes.length;
|
||||
|
||||
return this.sameLevelNodes[this.sameLevelIndex].id;
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
...getSuccessors(element, elementsMap, direction),
|
||||
...getPredecessors(element, elementsMap, direction),
|
||||
];
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - just started exploring at the given direction
|
||||
*
|
||||
* RESULT:
|
||||
* - go to the first node in the given direction
|
||||
*/
|
||||
if (nodes.length > 0) {
|
||||
this.sameLevelIndex = 0;
|
||||
this.isExploring = true;
|
||||
this.sameLevelNodes = nodes;
|
||||
this.direction = direction;
|
||||
this.visitedNodes.add(nodes[0].id);
|
||||
|
||||
return nodes[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* CASE:
|
||||
* - (just started exploring or still going at the same direction) OR
|
||||
* - there're no nodes at the given direction
|
||||
*
|
||||
* RESULT:
|
||||
* - go to some other unvisited linked node
|
||||
*
|
||||
* WHY:
|
||||
* - provide a speedier navigation from a given node to some predecessor
|
||||
* without the user having to change arrow key
|
||||
*/
|
||||
if (direction === this.direction || !this.isExploring) {
|
||||
if (!this.isExploring) {
|
||||
// just started and no other nodes at the given direction
|
||||
// so the current node is technically the first visited node
|
||||
// (this is needed so that we don't get stuck between looping through )
|
||||
this.visitedNodes.add(element.id);
|
||||
}
|
||||
|
||||
const otherDirections: LinkDirection[] = [
|
||||
"up",
|
||||
"right",
|
||||
"down",
|
||||
"left",
|
||||
].filter((dir): dir is LinkDirection => dir !== direction);
|
||||
|
||||
const otherLinkedNodes = otherDirections
|
||||
.map((dir) => [
|
||||
...getSuccessors(element, elementsMap, dir),
|
||||
...getPredecessors(element, elementsMap, dir),
|
||||
])
|
||||
.flat()
|
||||
.filter((linkedNode) => !this.visitedNodes.has(linkedNode.id));
|
||||
|
||||
for (const linkedNode of otherLinkedNodes) {
|
||||
if (!this.visitedNodes.has(linkedNode.id)) {
|
||||
this.visitedNodes.add(linkedNode.id);
|
||||
this.isExploring = true;
|
||||
this.direction = direction;
|
||||
return linkedNode.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class FlowChartCreator {
|
||||
isCreatingChart: boolean = false;
|
||||
private numberOfNodes: number = 0;
|
||||
private direction: LinkDirection | null = "right";
|
||||
pendingNodes: PendingExcalidrawElements | null = null;
|
||||
|
||||
createNodes(
|
||||
startNode: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
direction: LinkDirection,
|
||||
) {
|
||||
if (direction !== this.direction) {
|
||||
const { nextNode, bindingArrow } = addNewNode(
|
||||
startNode,
|
||||
elementsMap,
|
||||
appState,
|
||||
direction,
|
||||
);
|
||||
|
||||
this.numberOfNodes = 1;
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = [nextNode, bindingArrow];
|
||||
} else {
|
||||
this.numberOfNodes += 1;
|
||||
const newNodes = addNewNodes(
|
||||
startNode,
|
||||
elementsMap,
|
||||
appState,
|
||||
direction,
|
||||
this.numberOfNodes,
|
||||
);
|
||||
|
||||
this.isCreatingChart = true;
|
||||
this.direction = direction;
|
||||
this.pendingNodes = newNodes;
|
||||
}
|
||||
|
||||
// add pending nodes to the same frame as the start node
|
||||
// if every pending node is at least intersecting with the frame
|
||||
if (startNode.frameId) {
|
||||
const frame = elementsMap.get(startNode.frameId);
|
||||
|
||||
invariant(
|
||||
frame && isFrameElement(frame),
|
||||
"not an ExcalidrawFrameElement",
|
||||
);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
this.pendingNodes.every(
|
||||
(node) =>
|
||||
elementsAreInFrameBounds([node], frame, elementsMap) ||
|
||||
elementOverlapsWithFrame(node, frame, elementsMap),
|
||||
)
|
||||
) {
|
||||
this.pendingNodes = this.pendingNodes.map((node) =>
|
||||
mutateElement(
|
||||
node,
|
||||
{
|
||||
frameId: startNode.frameId,
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.isCreatingChart = false;
|
||||
this.pendingNodes = null;
|
||||
this.direction = null;
|
||||
this.numberOfNodes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const isNodeInFlowchart = (
|
||||
element: ExcalidrawFlowchartNodeElement,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
for (const [, el] of elementsMap) {
|
||||
if (
|
||||
el.type === "arrow" &&
|
||||
(el.startBinding?.elementId === element.id ||
|
||||
el.endBinding?.elementId === element.id)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
205
packages/element/heading.ts
Normal file
205
packages/element/heading.ts
Normal file
|
@ -0,0 +1,205 @@
|
|||
import {
|
||||
pointFrom,
|
||||
pointRotateRads,
|
||||
pointScaleFromOrigin,
|
||||
radiansToDegrees,
|
||||
triangleIncludesPoint,
|
||||
vectorFromPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
LocalPoint,
|
||||
GlobalPoint,
|
||||
Triangle,
|
||||
Vector,
|
||||
Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { getCenterForBounds, type Bounds } from "./bounds";
|
||||
|
||||
import type { ExcalidrawBindableElement } from "./types";
|
||||
|
||||
export const HEADING_RIGHT = [1, 0] as Heading;
|
||||
export const HEADING_DOWN = [0, 1] as Heading;
|
||||
export const HEADING_LEFT = [-1, 0] as Heading;
|
||||
export const HEADING_UP = [0, -1] as Heading;
|
||||
export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
|
||||
|
||||
export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
) => {
|
||||
const angle = radiansToDegrees(
|
||||
Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
|
||||
);
|
||||
if (angle >= 315 || angle < 45) {
|
||||
return HEADING_UP;
|
||||
} else if (angle >= 45 && angle < 135) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (angle >= 135 && angle < 225) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
return HEADING_LEFT;
|
||||
};
|
||||
|
||||
export const vectorToHeading = (vec: Vector): Heading => {
|
||||
const [x, y] = vec;
|
||||
const absX = Math.abs(x);
|
||||
const absY = Math.abs(y);
|
||||
if (x > absY) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (x <= -absY) {
|
||||
return HEADING_LEFT;
|
||||
} else if (y > absX) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
return HEADING_UP;
|
||||
};
|
||||
|
||||
export const headingForPoint = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
o: P,
|
||||
) => vectorToHeading(vectorFromPoint<P>(p, o));
|
||||
|
||||
export const headingForPointIsHorizontal = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
o: P,
|
||||
) => headingIsHorizontal(headingForPoint<P>(p, o));
|
||||
|
||||
export const compareHeading = (a: Heading, b: Heading) =>
|
||||
a[0] === b[0] && a[1] === b[1];
|
||||
|
||||
export const headingIsHorizontal = (a: Heading) =>
|
||||
compareHeading(a, HEADING_RIGHT) || compareHeading(a, HEADING_LEFT);
|
||||
|
||||
export const headingIsVertical = (a: Heading) => !headingIsHorizontal(a);
|
||||
|
||||
// Gets the heading for the point by creating a bounding box around the rotated
|
||||
// close fitting bounding box, then creating 4 search cones around the center of
|
||||
// the external bbox.
|
||||
export const headingForPointFromElement = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
element: Readonly<ExcalidrawBindableElement>,
|
||||
aabb: Readonly<Bounds>,
|
||||
p: Readonly<Point>,
|
||||
): Heading => {
|
||||
const SEARCH_CONE_MULTIPLIER = 2;
|
||||
|
||||
const midPoint = getCenterForBounds(aabb);
|
||||
|
||||
if (element.type === "diamond") {
|
||||
if (p[0] < element.x) {
|
||||
return HEADING_LEFT;
|
||||
} else if (p[1] < element.y) {
|
||||
return HEADING_UP;
|
||||
} else if (p[0] > element.x + element.width) {
|
||||
return HEADING_RIGHT;
|
||||
} else if (p[1] > element.y + element.height) {
|
||||
return HEADING_DOWN;
|
||||
}
|
||||
|
||||
const top = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width / 2, element.y),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const right = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const bottom = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x + element.width / 2, element.y + element.height),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
const left = pointRotateRads(
|
||||
pointScaleFromOrigin(
|
||||
pointFrom(element.x, element.y + element.height / 2),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
),
|
||||
midPoint,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
if (
|
||||
triangleIncludesPoint<Point>([top, right, midPoint] as Triangle<Point>, p)
|
||||
) {
|
||||
return headingForDiamond(top, right);
|
||||
} else if (
|
||||
triangleIncludesPoint<Point>(
|
||||
[right, bottom, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(right, bottom);
|
||||
} else if (
|
||||
triangleIncludesPoint<Point>(
|
||||
[bottom, left, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
) {
|
||||
return headingForDiamond(bottom, left);
|
||||
}
|
||||
|
||||
return headingForDiamond(left, top);
|
||||
}
|
||||
|
||||
const topLeft = pointScaleFromOrigin(
|
||||
pointFrom(aabb[0], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const topRight = pointScaleFromOrigin(
|
||||
pointFrom(aabb[2], aabb[1]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomLeft = pointScaleFromOrigin(
|
||||
pointFrom(aabb[0], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
const bottomRight = pointScaleFromOrigin(
|
||||
pointFrom(aabb[2], aabb[3]),
|
||||
midPoint,
|
||||
SEARCH_CONE_MULTIPLIER,
|
||||
) as Point;
|
||||
|
||||
return triangleIncludesPoint<Point>(
|
||||
[topLeft, topRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_UP
|
||||
: triangleIncludesPoint<Point>(
|
||||
[topRight, bottomRight, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_RIGHT
|
||||
: triangleIncludesPoint<Point>(
|
||||
[bottomRight, bottomLeft, midPoint] as Triangle<Point>,
|
||||
p,
|
||||
)
|
||||
? HEADING_DOWN
|
||||
: HEADING_LEFT;
|
||||
};
|
||||
|
||||
export const flipHeading = (h: Heading): Heading =>
|
||||
[
|
||||
h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
|
||||
h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
|
||||
] as Heading;
|
148
packages/element/image.ts
Normal file
148
packages/element/image.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
// -----------------------------------------------------------------------------
|
||||
// ExcalidrawImageElement & related helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { MIME_TYPES, SVG_NS } from "../constants";
|
||||
|
||||
import { isInitializedImageElement } from "./typeChecks";
|
||||
|
||||
import type { AppClassProperties, DataURL, BinaryFiles } from "../types";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
FileId,
|
||||
InitializedExcalidrawImageElement,
|
||||
} from "./types";
|
||||
|
||||
export const loadHTMLImageElement = (dataURL: DataURL) => {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
image.src = dataURL;
|
||||
});
|
||||
};
|
||||
|
||||
/** NOTE: updates cache even if already populated with given image. Thus,
|
||||
* you should filter out the images upstream if you want to optimize this. */
|
||||
export const updateImageCache = async ({
|
||||
fileIds,
|
||||
files,
|
||||
imageCache,
|
||||
}: {
|
||||
fileIds: FileId[];
|
||||
files: BinaryFiles;
|
||||
imageCache: AppClassProperties["imageCache"];
|
||||
}) => {
|
||||
const updatedFiles = new Map<FileId, true>();
|
||||
const erroredFiles = new Map<FileId, true>();
|
||||
|
||||
await Promise.all(
|
||||
fileIds.reduce((promises, fileId) => {
|
||||
const fileData = files[fileId as string];
|
||||
if (fileData && !updatedFiles.has(fileId)) {
|
||||
updatedFiles.set(fileId, true);
|
||||
return promises.concat(
|
||||
(async () => {
|
||||
try {
|
||||
if (fileData.mimeType === MIME_TYPES.binary) {
|
||||
throw new Error("Only images can be added to ImageCache");
|
||||
}
|
||||
|
||||
const imagePromise = loadHTMLImageElement(fileData.dataURL);
|
||||
const data = {
|
||||
image: imagePromise,
|
||||
mimeType: fileData.mimeType,
|
||||
} as const;
|
||||
// store the promise immediately to indicate there's an in-progress
|
||||
// initialization
|
||||
imageCache.set(fileId, data);
|
||||
|
||||
const image = await imagePromise;
|
||||
|
||||
imageCache.set(fileId, { ...data, image });
|
||||
} catch (error: any) {
|
||||
erroredFiles.set(fileId, true);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
return promises;
|
||||
}, [] as Promise<any>[]),
|
||||
);
|
||||
|
||||
return {
|
||||
imageCache,
|
||||
/** includes errored files because they cache was updated nonetheless */
|
||||
updatedFiles,
|
||||
/** files that failed when creating HTMLImageElement */
|
||||
erroredFiles,
|
||||
};
|
||||
};
|
||||
|
||||
export const getInitializedImageElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) =>
|
||||
elements.filter((element) =>
|
||||
isInitializedImageElement(element),
|
||||
) as InitializedExcalidrawImageElement[];
|
||||
|
||||
export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
|
||||
// lower-casing due to XML/HTML convention differences
|
||||
// https://johnresig.com/blog/nodename-case-sensitivity
|
||||
return node?.nodeName.toLowerCase() === "svg";
|
||||
};
|
||||
|
||||
export const normalizeSVG = (SVGString: string) => {
|
||||
const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
|
||||
const svg = doc.querySelector("svg");
|
||||
const errorNode = doc.querySelector("parsererror");
|
||||
if (errorNode || !isHTMLSVGElement(svg)) {
|
||||
throw new Error("Invalid SVG");
|
||||
} else {
|
||||
if (!svg.hasAttribute("xmlns")) {
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
}
|
||||
|
||||
let width = svg.getAttribute("width");
|
||||
let height = svg.getAttribute("height");
|
||||
|
||||
// Do not use % or auto values for width/height
|
||||
// to avoid scaling issues when rendering at different sizes/zoom levels
|
||||
if (width?.includes("%") || width === "auto") {
|
||||
width = null;
|
||||
}
|
||||
if (height?.includes("%") || height === "auto") {
|
||||
height = null;
|
||||
}
|
||||
|
||||
const viewBox = svg.getAttribute("viewBox");
|
||||
|
||||
if (!width || !height) {
|
||||
width = width || "50";
|
||||
height = height || "50";
|
||||
|
||||
if (viewBox) {
|
||||
const match = viewBox.match(
|
||||
/\d+ +\d+ +(\d+(?:\.\d+)?) +(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (match) {
|
||||
[, width, height] = match;
|
||||
}
|
||||
}
|
||||
|
||||
svg.setAttribute("width", width);
|
||||
svg.setAttribute("height", height);
|
||||
}
|
||||
|
||||
// Make sure viewBox is set
|
||||
if (!viewBox) {
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
}
|
||||
|
||||
return svg.outerHTML;
|
||||
}
|
||||
};
|
123
packages/element/index.ts
Normal file
123
packages/element/index.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newArrowElement,
|
||||
newImageElement,
|
||||
duplicateElement,
|
||||
} from "./newElement";
|
||||
export {
|
||||
getElementAbsoluteCoords,
|
||||
getElementBounds,
|
||||
getCommonBounds,
|
||||
getDiamondPoints,
|
||||
getArrowheadPoints,
|
||||
getClosestElementBounds,
|
||||
} from "./bounds";
|
||||
|
||||
export {
|
||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
} from "./transformHandles";
|
||||
export {
|
||||
resizeTest,
|
||||
getCursorForResizingElement,
|
||||
getElementWithTransformHandleType,
|
||||
getTransformHandleTypeFromCoords,
|
||||
} from "./resizeTest";
|
||||
export {
|
||||
transformElements,
|
||||
getResizeOffsetXY,
|
||||
getResizeArrowDirection,
|
||||
} from "./resizeElements";
|
||||
export {
|
||||
dragSelectedElements,
|
||||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export { redrawTextBoundingBox, getTextFromElements } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
isInvisiblySmallElement,
|
||||
resizePerfectLineForNWHandler,
|
||||
getNormalizedDimensions,
|
||||
} from "./sizeHelpers";
|
||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||
|
||||
/**
|
||||
* @deprecated unsafe, use hashElementsVersion instead
|
||||
*/
|
||||
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.reduce((acc, el) => acc + el.version, 0);
|
||||
|
||||
/**
|
||||
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
|
||||
*/
|
||||
export const hashElementsVersion = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
hash = (hash << 5) + hash + elements[i].versionNonce;
|
||||
}
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
|
||||
// string hash function (using djb2). Not cryptographically secure, use only
|
||||
// for versioning and such.
|
||||
export const hashString = (s: string): number => {
|
||||
let hash: number = 5381;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const char: number = s.charCodeAt(i);
|
||||
hash = (hash << 5) + hash + char;
|
||||
}
|
||||
return hash >>> 0; // Ensure unsigned 32-bit integer
|
||||
};
|
||||
|
||||
export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter(
|
||||
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedElements = <T extends ExcalidrawElement>(
|
||||
elements: readonly T[],
|
||||
) =>
|
||||
elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
|
||||
|
||||
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);
|
1828
packages/element/linearElementEditor.ts
Normal file
1828
packages/element/linearElementEditor.ts
Normal file
File diff suppressed because it is too large
Load diff
199
packages/element/mutateElement.ts
Normal file
199
packages/element/mutateElement.ts
Normal file
|
@ -0,0 +1,199 @@
|
|||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import Scene from "../scene/Scene";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { getUpdatedTimestamp, toBrandedType } from "../utils";
|
||||
|
||||
import { updateElbowArrowPoints } from "./elbowArrow";
|
||||
import { isElbowArrow } from "./typeChecks";
|
||||
|
||||
import type { ExcalidrawElement, NonDeletedSceneElementsMap } from "./types";
|
||||
import type { Mutable } from "../utility-types";
|
||||
|
||||
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "version" | "versionNonce" | "updated"
|
||||
>;
|
||||
|
||||
// This function tracks updates of text elements for the purposes for collaboration.
|
||||
// The version is used to compare updates when more than one user is working in
|
||||
// the same drawing. Note: this will trigger the component to update. Make sure you
|
||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
informMutation = true,
|
||||
options?: {
|
||||
// Currently only for elbow arrows.
|
||||
// If true, the elbow arrow tries to bind to the nearest element. If false
|
||||
// it tries to keep the same bound element, if any.
|
||||
isDragging?: boolean;
|
||||
},
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points, fixedSegments, fileId, startBinding, endBinding } =
|
||||
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
|
||||
) {
|
||||
const elementsMap = toBrandedType<NonDeletedSceneElementsMap>(
|
||||
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
|
||||
);
|
||||
|
||||
updates = {
|
||||
...updates,
|
||||
angle: 0 as Radians,
|
||||
...updateElbowArrowPoints(
|
||||
{
|
||||
...element,
|
||||
x: updates.x || element.x,
|
||||
y: updates.y || element.y,
|
||||
},
|
||||
elementsMap,
|
||||
{
|
||||
fixedSegments,
|
||||
points,
|
||||
startBinding,
|
||||
endBinding,
|
||||
},
|
||||
{
|
||||
isDragging: options?.isDragging,
|
||||
},
|
||||
),
|
||||
};
|
||||
} else if (typeof points !== "undefined") {
|
||||
updates = { ...getSizeFromPoints(points), ...updates };
|
||||
}
|
||||
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update because its attrs could have changed
|
||||
// (except for specific keys we handle below)
|
||||
(typeof value !== "object" ||
|
||||
value === null ||
|
||||
key === "groupIds" ||
|
||||
key === "scale")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "scale") {
|
||||
const prevScale = (element as any)[key];
|
||||
const nextScale = value;
|
||||
if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
|
||||
continue;
|
||||
}
|
||||
} else if (key === "points") {
|
||||
const prevPoints = (element as any)[key];
|
||||
const nextPoints = value;
|
||||
if (prevPoints.length === nextPoints.length) {
|
||||
let didChangePoints = false;
|
||||
let index = prevPoints.length;
|
||||
while (--index) {
|
||||
const prevPoint = prevPoints[index];
|
||||
const nextPoint = nextPoints[index];
|
||||
if (
|
||||
prevPoint[0] !== nextPoint[0] ||
|
||||
prevPoint[1] !== nextPoint[1]
|
||||
) {
|
||||
didChangePoints = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!didChangePoints) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(element as any)[key] = value;
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
return element;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updates.height !== "undefined" ||
|
||||
typeof updates.width !== "undefined" ||
|
||||
typeof fileId != "undefined" ||
|
||||
typeof points !== "undefined"
|
||||
) {
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.triggerUpdate();
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
/** pass `true` to always regenerate */
|
||||
force = false,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
if (
|
||||
(element as any)[key] === value &&
|
||||
// if object, always update because its attrs could have changed
|
||||
(typeof value !== "object" || value === null)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
didChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange && !force) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
updated: getUpdatedTimestamp(),
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutates element, bumping `version`, `versionNonce`, and `updated`.
|
||||
*
|
||||
* NOTE: does not trigger re-render.
|
||||
*/
|
||||
export const bumpVersion = <T extends Mutable<ExcalidrawElement>>(
|
||||
element: T,
|
||||
version?: ExcalidrawElement["version"],
|
||||
) => {
|
||||
element.version = (version ?? element.version) + 1;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
return element;
|
||||
};
|
377
packages/element/newElement.test.ts
Normal file
377
packages/element/newElement.test.ts
Normal file
|
@ -0,0 +1,377 @@
|
|||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { FONT_FAMILY, ROUNDNESS } from "../constants";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { isPrimitive } from "../utils";
|
||||
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { duplicateElement, duplicateElements } from "./newElement";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "./types";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
||||
expect(clone[key]).not.toBe(source[key]);
|
||||
if (source[key]) {
|
||||
assertCloneObjects(source[key], clone[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("duplicating single elements", () => {
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, {
|
||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
// assert we clone the object's prototype
|
||||
// @ts-ignore
|
||||
expect(copy.__proto__).toEqual({ hello: "world" });
|
||||
expect(copy.hasOwnProperty("hello")).toBe(false);
|
||||
|
||||
expect(copy.points).not.toBe(element.points);
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(copy.seed).not.toBe(element.seed);
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
expect(copy).toEqual({
|
||||
...element,
|
||||
id: copy.id,
|
||||
seed: copy.seed,
|
||||
});
|
||||
});
|
||||
|
||||
it("clones text element", () => {
|
||||
const element = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: null,
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
text: "hello",
|
||||
fontSize: 20,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
expect(copy).not.toHaveProperty("points");
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating multiple elements", () => {
|
||||
it("duplicateElements should clone bindings", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
{ id: "text1", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle1",
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
boundElements: [{ id: "text2", type: "text" }],
|
||||
});
|
||||
|
||||
const text2 = API.createElement({
|
||||
type: "text",
|
||||
id: "text2",
|
||||
containerId: "arrow2",
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const clonedElements = duplicateElements(origElements);
|
||||
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
expect(origElements.map((e) => e.type)).toEqual(
|
||||
clonedElements.map((e) => e.type),
|
||||
);
|
||||
origElements.forEach((origElement, idx) => {
|
||||
const clonedElement = clonedElements[idx];
|
||||
expect(origElement).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(clonedElement.id),
|
||||
type: clonedElement.type,
|
||||
}),
|
||||
);
|
||||
if ("containerId" in origElement) {
|
||||
expect(origElement.containerId).not.toBe(
|
||||
(clonedElement as any).containerId,
|
||||
);
|
||||
}
|
||||
if ("endBinding" in origElement) {
|
||||
if (origElement.endBinding) {
|
||||
expect(origElement.endBinding.elementId).not.toBe(
|
||||
(clonedElement as any).endBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).endBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
if ("startBinding" in origElement) {
|
||||
if (origElement.startBinding) {
|
||||
expect(origElement.startBinding.elementId).not.toBe(
|
||||
(clonedElement as any).startBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).startBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const clonedArrows = clonedElements.filter(
|
||||
(e) => e.type === "arrow",
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||
clonedElements as any as typeof origElements;
|
||||
|
||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: clonedText1.id,
|
||||
type: clonedText1.type,
|
||||
}),
|
||||
);
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: arrow.id,
|
||||
type: arrow.type,
|
||||
}),
|
||||
);
|
||||
|
||||
if (arrow.endBinding) {
|
||||
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
if (arrow.startBinding) {
|
||||
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
});
|
||||
|
||||
expect(clonedArrow2.boundElements).toEqual([
|
||||
{ type: "text", id: clonedArrowLabel.id },
|
||||
]);
|
||||
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
|
||||
});
|
||||
|
||||
it("should remove id references of elements that aren't found", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
// should keep
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "arrow-not-exists", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "text-not-exists", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle-not-exists",
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow3 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const [
|
||||
clonedRectangle,
|
||||
clonedText1,
|
||||
clonedArrow1,
|
||||
clonedArrow2,
|
||||
clonedArrow3,
|
||||
] = clonedElements;
|
||||
|
||||
expect(clonedRectangle.boundElements).toEqual([
|
||||
{ id: clonedArrow1.id, type: "arrow" },
|
||||
]);
|
||||
|
||||
expect(clonedText1.containerId).toBe(null);
|
||||
|
||||
expect(clonedArrow2.startBinding).toEqual({
|
||||
...arrow2.startBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
expect(clonedArrow2.endBinding).toBe(null);
|
||||
|
||||
expect(clonedArrow3.startBinding).toBe(null);
|
||||
expect(clonedArrow3.endBinding).toEqual({
|
||||
...arrow3.endBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe("should duplicate all group ids", () => {
|
||||
it("should regenerate all group ids and keep them consistent across elements", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
clonedElements;
|
||||
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
|
||||
|
||||
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
|
||||
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
|
||||
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
|
||||
});
|
||||
|
||||
it("should keep and regenerate ids of groups even if invalid", () => {
|
||||
// lone element shouldn't be able to be grouped with itself,
|
||||
// but hard to check against in a performant way so we ignore it
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const [clonedRectangle1] = duplicateElements([rectangle1]);
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
});
|
||||
});
|
||||
});
|
797
packages/element/newElement.ts
Normal file
797
packages/element/newElement.ts
Normal file
|
@ -0,0 +1,797 @@
|
|||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
ORIG_ID,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import {
|
||||
arrayToMap,
|
||||
getFontString,
|
||||
getUpdatedTimestamp,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawGenericElement,
|
||||
NonDeleted,
|
||||
TextAlign,
|
||||
GroupId,
|
||||
VerticalAlign,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawIframeElement,
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
FixedSegment,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "./types";
|
||||
import type { AppState } from "../types";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
| "width"
|
||||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "index"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "link"
|
||||
| "strokeStyle"
|
||||
| "fillStyle"
|
||||
| "strokeColor"
|
||||
| "backgroundColor"
|
||||
| "roughness"
|
||||
| "strokeWidth"
|
||||
| "roundness"
|
||||
| "locked"
|
||||
| "opacity"
|
||||
| "customData"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
type: T["type"],
|
||||
{
|
||||
x,
|
||||
y,
|
||||
strokeColor = DEFAULT_ELEMENT_PROPS.strokeColor,
|
||||
backgroundColor = DEFAULT_ELEMENT_PROPS.backgroundColor,
|
||||
fillStyle = DEFAULT_ELEMENT_PROPS.fillStyle,
|
||||
strokeWidth = DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
strokeStyle = DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
roughness = DEFAULT_ELEMENT_PROPS.roughness,
|
||||
opacity = DEFAULT_ELEMENT_PROPS.opacity,
|
||||
width = 0,
|
||||
height = 0,
|
||||
angle = 0 as Radians,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
index = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
locked = DEFAULT_ELEMENT_PROPS.locked,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
// NOTE (mtolmacs): This is a temporary check to detect extremely large
|
||||
// element position or sizing
|
||||
if (
|
||||
x < -1e6 ||
|
||||
x > 1e6 ||
|
||||
y < -1e6 ||
|
||||
y > 1e6 ||
|
||||
width < -1e6 ||
|
||||
width > 1e6 ||
|
||||
height < -1e6 ||
|
||||
height > 1e6
|
||||
) {
|
||||
console.error("New element size or position is too large", {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
// @ts-ignore
|
||||
points: rest.points,
|
||||
});
|
||||
}
|
||||
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
id: rest.id || randomId(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
strokeColor,
|
||||
backgroundColor,
|
||||
fillStyle,
|
||||
strokeWidth,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
index,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
boundElements,
|
||||
updated: getUpdatedTimestamp(),
|
||||
link,
|
||||
locked,
|
||||
customData: rest.customData,
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newElement = (
|
||||
opts: {
|
||||
type: ExcalidrawGenericElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return _newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts);
|
||||
};
|
||||
|
||||
export const newIframeElement = (
|
||||
opts: {
|
||||
type: "iframe";
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawIframeElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
|
||||
};
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: opts?.name || null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
export const newMagicFrameElement = (
|
||||
opts: {
|
||||
name?: string;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawMagicFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
|
||||
type: "magicframe",
|
||||
name: opts?.name || null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
textAlign: ExcalidrawTextElement["textAlign"];
|
||||
verticalAlign: ExcalidrawTextElement["verticalAlign"];
|
||||
},
|
||||
metrics: {
|
||||
width: number;
|
||||
height: number;
|
||||
},
|
||||
) => {
|
||||
return {
|
||||
x:
|
||||
opts.textAlign === "center"
|
||||
? metrics.width / 2
|
||||
: opts.textAlign === "right"
|
||||
? metrics.width
|
||||
: 0,
|
||||
y: opts.verticalAlign === "middle" ? metrics.height / 2 : 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
originalText?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign;
|
||||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString({ fontFamily, fontSize }),
|
||||
lineHeight,
|
||||
);
|
||||
const textAlign = opts.textAlign || DEFAULT_TEXT_ALIGN;
|
||||
const verticalAlign = opts.verticalAlign || DEFAULT_VERTICAL_ALIGN;
|
||||
const offsets = getTextElementPositionOffsets(
|
||||
{ textAlign, verticalAlign },
|
||||
metrics,
|
||||
);
|
||||
|
||||
const textElementProps: ExcalidrawTextElement = {
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
x: opts.x - offsets.x,
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: opts.originalText ?? text,
|
||||
autoResize: opts.autoResize ?? true,
|
||||
lineHeight,
|
||||
};
|
||||
|
||||
const textElement: ExcalidrawTextElement = newElementWith(
|
||||
textElementProps,
|
||||
{},
|
||||
);
|
||||
|
||||
return textElement;
|
||||
};
|
||||
|
||||
const getAdjustedDimensions = (
|
||||
element: ExcalidrawTextElement,
|
||||
elementsMap: ElementsMap,
|
||||
nextText: string,
|
||||
): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} => {
|
||||
let { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
// wrapped text
|
||||
if (!element.autoResize) {
|
||||
nextWidth = element.width;
|
||||
}
|
||||
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (
|
||||
textAlign === "center" &&
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId &&
|
||||
element.autoResize
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
width: nextWidth - prevMetrics.width,
|
||||
height: nextHeight - prevMetrics.height,
|
||||
});
|
||||
|
||||
x = element.x - offsets.x;
|
||||
y = element.y - offsets.y;
|
||||
} else {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
|
||||
|
||||
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
const deltaX2 = (x2 - nextX2) / 2;
|
||||
const deltaY2 = (y2 - nextY2) / 2;
|
||||
|
||||
[x, y] = adjustXYWithRotation(
|
||||
{
|
||||
s: true,
|
||||
e: textAlign === "center" || textAlign === "left",
|
||||
w: textAlign === "center" || textAlign === "right",
|
||||
},
|
||||
element.x,
|
||||
element.y,
|
||||
element.angle,
|
||||
deltaX1,
|
||||
deltaY1,
|
||||
deltaX2,
|
||||
deltaY2,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x: Number.isFinite(x) ? x : element.x,
|
||||
y: Number.isFinite(y) ? y : element.y,
|
||||
};
|
||||
};
|
||||
|
||||
const adjustXYWithRotation = (
|
||||
sides: {
|
||||
n?: boolean;
|
||||
e?: boolean;
|
||||
s?: boolean;
|
||||
w?: boolean;
|
||||
},
|
||||
x: number,
|
||||
y: number,
|
||||
angle: number,
|
||||
deltaX1: number,
|
||||
deltaY1: number,
|
||||
deltaX2: number,
|
||||
deltaY2: number,
|
||||
): [number, number] => {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
if (sides.e && sides.w) {
|
||||
x += deltaX1 + deltaX2;
|
||||
} else if (sides.e) {
|
||||
x += deltaX1 * (1 + cos);
|
||||
y += deltaX1 * sin;
|
||||
x += deltaX2 * (1 - cos);
|
||||
y += deltaX2 * -sin;
|
||||
} else if (sides.w) {
|
||||
x += deltaX1 * (1 - cos);
|
||||
y += deltaX1 * -sin;
|
||||
x += deltaX2 * (1 + cos);
|
||||
y += deltaX2 * sin;
|
||||
}
|
||||
|
||||
if (sides.n && sides.s) {
|
||||
y += deltaY1 + deltaY2;
|
||||
} else if (sides.n) {
|
||||
x += deltaY1 * sin;
|
||||
y += deltaY1 * (1 - cos);
|
||||
x += deltaY2 * -sin;
|
||||
y += deltaY2 * (1 + cos);
|
||||
} else if (sides.s) {
|
||||
x += deltaY1 * -sin;
|
||||
y += deltaY1 * (1 + cos);
|
||||
x += deltaY2 * sin;
|
||||
y += deltaY2 * (1 - cos);
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
export const refreshTextDimensions = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
elementsMap: ElementsMap,
|
||||
text = textElement.text,
|
||||
) => {
|
||||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
if (container || !textElement.autoResize) {
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width,
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
|
||||
return { text, ...dimensions };
|
||||
};
|
||||
|
||||
export const newFreeDrawElement = (
|
||||
opts: {
|
||||
type: "freedraw";
|
||||
points?: ExcalidrawFreeDrawElement["points"];
|
||||
simulatePressure: boolean;
|
||||
pressures?: ExcalidrawFreeDrawElement["pressures"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
pressures: opts.pressures || [],
|
||||
simulatePressure: opts.simulatePressure,
|
||||
lastCommittedPoint: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const newArrowElement = <T extends boolean>(
|
||||
opts: {
|
||||
type: ExcalidrawArrowElement["type"];
|
||||
startArrowhead?: Arrowhead | null;
|
||||
endArrowhead?: Arrowhead | null;
|
||||
points?: ExcalidrawArrowElement["points"];
|
||||
elbowed?: T;
|
||||
fixedSegments?: FixedSegment[] | null;
|
||||
} & ElementConstructorOpts,
|
||||
): T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
: NonDeleted<ExcalidrawArrowElement> => {
|
||||
if (opts.elbowed) {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawElbowArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
elbowed: true,
|
||||
fixedSegments: opts.fixedSegments || [],
|
||||
startIsSpecial: false,
|
||||
endIsSpecial: false,
|
||||
} as NonDeleted<ExcalidrawElbowArrowElement>;
|
||||
}
|
||||
|
||||
return {
|
||||
..._newElementBase<ExcalidrawArrowElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
startBinding: null,
|
||||
endBinding: null,
|
||||
startArrowhead: opts.startArrowhead || null,
|
||||
endArrowhead: opts.endArrowhead || null,
|
||||
elbowed: false,
|
||||
} as T extends true
|
||||
? NonDeleted<ExcalidrawElbowArrowElement>
|
||||
: NonDeleted<ExcalidrawArrowElement>;
|
||||
};
|
||||
|
||||
export const newImageElement = (
|
||||
opts: {
|
||||
type: ExcalidrawImageElement["type"];
|
||||
status?: ExcalidrawImageElement["status"];
|
||||
fileId?: ExcalidrawImageElement["fileId"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
crop?: ExcalidrawImageElement["crop"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
// and `transparent` will likely mean "use original colors of the image"
|
||||
strokeColor: "transparent",
|
||||
status: opts.status ?? "pending",
|
||||
fileId: opts.fileId ?? null,
|
||||
scale: opts.scale ?? [1, 1],
|
||||
crop: opts.crop ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement.
|
||||
//
|
||||
// Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
// Typed arrays and other non-null objects.
|
||||
//
|
||||
// Adapted from https://github.com/lukeed/klona
|
||||
//
|
||||
// The reason for `deepCopyElement()` wrapper is type safety (only allow
|
||||
// passing ExcalidrawElement as the top-level argument).
|
||||
const _deepCopyElement = (val: any, depth: number = 0) => {
|
||||
// only clone non-primitives
|
||||
if (val == null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
|
||||
const objectType = Object.prototype.toString.call(val);
|
||||
|
||||
if (objectType === "[object Object]") {
|
||||
const tmp =
|
||||
typeof val.constructor === "function"
|
||||
? Object.create(Object.getPrototypeOf(val))
|
||||
: {};
|
||||
for (const key in val) {
|
||||
if (val.hasOwnProperty(key)) {
|
||||
// don't copy non-serializable objects like these caches. They'll be
|
||||
// populated when the element is rendered.
|
||||
if (depth === 0 && (key === "shape" || key === "canvas")) {
|
||||
continue;
|
||||
}
|
||||
tmp[key] = _deepCopyElement(val[key], depth + 1);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
let k = val.length;
|
||||
const arr = new Array(k);
|
||||
while (k--) {
|
||||
arr[k] = _deepCopyElement(val[k], depth + 1);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// we're not cloning non-array & non-plain-object objects because we
|
||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||
// sure we start cloning them, so let's warn about it.
|
||||
if (import.meta.env.DEV) {
|
||||
if (
|
||||
objectType !== "[object Object]" &&
|
||||
objectType !== "[object Array]" &&
|
||||
objectType.startsWith("[object ")
|
||||
) {
|
||||
console.warn(
|
||||
`_deepCloneElement: unexpected object type ${objectType}. This value will not be cloned!`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones ExcalidrawElement data structure. Does not regenerate id, nonce, or
|
||||
* any value. The purpose is to to break object references for immutability
|
||||
* reasons, whenever we want to keep the original element, but ensure it's not
|
||||
* mutated.
|
||||
*
|
||||
* Only clones plain objects and arrays. Doesn't clone Date, RegExp, Map, Set,
|
||||
* Typed arrays and other non-null objects.
|
||||
*/
|
||||
export const deepCopyElement = <T extends ExcalidrawElement>(
|
||||
val: T,
|
||||
): Mutable<T> => {
|
||||
return _deepCopyElement(val);
|
||||
};
|
||||
|
||||
const __test__defineOrigId = (clonedObj: object, origId: string) => {
|
||||
Object.defineProperty(clonedObj, ORIG_ID, {
|
||||
value: origId,
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* utility wrapper to generate new id.
|
||||
*/
|
||||
const regenerateId = () => {
|
||||
return randomId();
|
||||
};
|
||||
|
||||
/**
|
||||
* Duplicate an element, often used in the alt-drag operation.
|
||||
* Note that this method has gotten a bit complicated since the
|
||||
* introduction of gruoping/ungrouping elements.
|
||||
* @param editingGroupId The current group being edited. The new
|
||||
* element will inherit this group and its
|
||||
* parents.
|
||||
* @param groupIdMapForOperation A Map that maps old group IDs to
|
||||
* duplicated ones. If you are duplicating
|
||||
* multiple elements at once, share this map
|
||||
* amongst all of them
|
||||
* @param element Element to duplicate
|
||||
* @param overrides Any element properties to override
|
||||
*/
|
||||
export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
groupIdMapForOperation: Map<GroupId, GroupId>,
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
): Readonly<TElement> => {
|
||||
let copy = deepCopyElement(element);
|
||||
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(copy, element.id);
|
||||
}
|
||||
|
||||
copy.id = regenerateId();
|
||||
copy.boundElements = null;
|
||||
copy.updated = getUpdatedTimestamp();
|
||||
copy.seed = randomInteger();
|
||||
copy.groupIds = getNewGroupIdsForDuplication(
|
||||
copy.groupIds,
|
||||
editingGroupId,
|
||||
(groupId) => {
|
||||
if (!groupIdMapForOperation.has(groupId)) {
|
||||
groupIdMapForOperation.set(groupId, regenerateId());
|
||||
}
|
||||
return groupIdMapForOperation.get(groupId)!;
|
||||
},
|
||||
);
|
||||
if (overrides) {
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones elements, regenerating their ids (including bindings) and group ids.
|
||||
*
|
||||
* If bindings don't exist in the elements array, they are removed. Therefore,
|
||||
* it's advised to supply the whole elements array, or sets of elements that
|
||||
* are encapsulated (such as library items), if the purpose is to retain
|
||||
* bindings to the cloned elements intact.
|
||||
*
|
||||
* NOTE by default does not randomize or regenerate anything except the id.
|
||||
*/
|
||||
export const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
opts?: {
|
||||
/** NOTE also updates version flags and `updated` */
|
||||
randomizeSeed: boolean;
|
||||
},
|
||||
) => {
|
||||
const clonedElements: ExcalidrawElement[] = [];
|
||||
|
||||
const origElementsMap = arrayToMap(elements);
|
||||
|
||||
// used for for migrating old ids to new ids
|
||||
const elementNewIdsMap = new Map<
|
||||
/* orig */ ExcalidrawElement["id"],
|
||||
/* new */ ExcalidrawElement["id"]
|
||||
>();
|
||||
|
||||
const maybeGetNewId = (id: ExcalidrawElement["id"]) => {
|
||||
// if we've already migrated the element id, return the new one directly
|
||||
if (elementNewIdsMap.has(id)) {
|
||||
return elementNewIdsMap.get(id)!;
|
||||
}
|
||||
// if we haven't migrated the element id, but an old element with the same
|
||||
// id exists, generate a new id for it and return it
|
||||
if (origElementsMap.has(id)) {
|
||||
const newId = regenerateId();
|
||||
elementNewIdsMap.set(id, newId);
|
||||
return newId;
|
||||
}
|
||||
// if old element doesn't exist, return null to mark it for removal
|
||||
return null;
|
||||
};
|
||||
|
||||
const groupNewIdsMap = new Map</* orig */ GroupId, /* new */ GroupId>();
|
||||
|
||||
for (const element of elements) {
|
||||
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
|
||||
|
||||
clonedElement.id = maybeGetNewId(element.id)!;
|
||||
if (isTestEnv()) {
|
||||
__test__defineOrigId(clonedElement, element.id);
|
||||
}
|
||||
|
||||
if (opts?.randomizeSeed) {
|
||||
clonedElement.seed = randomInteger();
|
||||
bumpVersion(clonedElement);
|
||||
}
|
||||
|
||||
if (clonedElement.groupIds) {
|
||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||
if (!groupNewIdsMap.has(groupId)) {
|
||||
groupNewIdsMap.set(groupId, regenerateId());
|
||||
}
|
||||
return groupNewIdsMap.get(groupId)!;
|
||||
});
|
||||
}
|
||||
|
||||
if ("containerId" in clonedElement && clonedElement.containerId) {
|
||||
const newContainerId = maybeGetNewId(clonedElement.containerId);
|
||||
clonedElement.containerId = newContainerId;
|
||||
}
|
||||
|
||||
if ("boundElements" in clonedElement && clonedElement.boundElements) {
|
||||
clonedElement.boundElements = clonedElement.boundElements.reduce(
|
||||
(
|
||||
acc: Mutable<NonNullable<ExcalidrawElement["boundElements"]>>,
|
||||
binding,
|
||||
) => {
|
||||
const newBindingId = maybeGetNewId(binding.id);
|
||||
if (newBindingId) {
|
||||
acc.push({ ...binding, id: newBindingId });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
if ("endBinding" in clonedElement && clonedElement.endBinding) {
|
||||
const newEndBindingId = maybeGetNewId(clonedElement.endBinding.elementId);
|
||||
clonedElement.endBinding = newEndBindingId
|
||||
? {
|
||||
...clonedElement.endBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if ("startBinding" in clonedElement && clonedElement.startBinding) {
|
||||
const newEndBindingId = maybeGetNewId(
|
||||
clonedElement.startBinding.elementId,
|
||||
);
|
||||
clonedElement.startBinding = newEndBindingId
|
||||
? {
|
||||
...clonedElement.startBinding,
|
||||
elementId: newEndBindingId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
if (clonedElement.frameId) {
|
||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
return clonedElements;
|
||||
};
|
1549
packages/element/resizeElements.ts
Normal file
1549
packages/element/resizeElements.ts
Normal file
File diff suppressed because it is too large
Load diff
290
packages/element/resizeTest.ts
Normal file
290
packages/element/resizeTest.ts
Normal file
|
@ -0,0 +1,290 @@
|
|||
import {
|
||||
pointFrom,
|
||||
pointOnLineSegment,
|
||||
pointRotateRads,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { SIDE_RESIZING_THRESHOLD } from "../constants";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getTransformHandlesFromCoords,
|
||||
getTransformHandles,
|
||||
getOmitSidesForDevice,
|
||||
canResizeFromSides,
|
||||
} from "./transformHandles";
|
||||
import { isImageElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { AppState, Device, Zoom } from "../types";
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
TransformHandleType,
|
||||
TransformHandle,
|
||||
MaybeTransformHandleType,
|
||||
} from "./transformHandles";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
PointerType,
|
||||
NonDeletedExcalidrawElement,
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
|
||||
const isInsideTransformHandle = (
|
||||
transformHandle: TransformHandle,
|
||||
x: number,
|
||||
y: number,
|
||||
) =>
|
||||
x >= transformHandle[0] &&
|
||||
x <= transformHandle[0] + transformHandle[2] &&
|
||||
y >= transformHandle[1] &&
|
||||
y <= transformHandle[1] + transformHandle[3];
|
||||
|
||||
export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
appState: AppState,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
): MaybeTransformHandleType => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { rotation: rotationTransformHandle, ...transformHandles } =
|
||||
getTransformHandles(
|
||||
element,
|
||||
zoom,
|
||||
elementsMap,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
);
|
||||
|
||||
if (
|
||||
rotationTransformHandle &&
|
||||
isInsideTransformHandle(rotationTransformHandle, x, y)
|
||||
) {
|
||||
return "rotation" as TransformHandleType;
|
||||
}
|
||||
|
||||
const filter = Object.keys(transformHandles).filter((key) => {
|
||||
const transformHandle =
|
||||
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
|
||||
if (!transformHandle) {
|
||||
return false;
|
||||
}
|
||||
return isInsideTransformHandle(transformHandle, x, y);
|
||||
});
|
||||
|
||||
if (filter.length > 0) {
|
||||
return filter[0] as TransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
|
||||
element,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// do not resize from the sides for linear elements with only two points
|
||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||
const SPACING = isImageElement(element)
|
||||
? 0
|
||||
: SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const ZOOMED_SIDE_RESIZING_THRESHOLD =
|
||||
SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const sides = getSelectionBorders(
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(
|
||||
pointFrom(x, y),
|
||||
side as LineSegment<Point>,
|
||||
ZOOMED_SIDE_RESIZING_THRESHOLD,
|
||||
)
|
||||
) {
|
||||
return dir as TransformHandleType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getElementWithTransformHandleType = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
elementsMap: ElementsMap,
|
||||
device: Device,
|
||||
) => {
|
||||
return elements.reduce((result, element) => {
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
const transformHandleType = resizeTest(
|
||||
element,
|
||||
elementsMap,
|
||||
appState,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
zoom,
|
||||
pointerType,
|
||||
device,
|
||||
);
|
||||
return transformHandleType ? { element, transformHandleType } : null;
|
||||
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
|
||||
};
|
||||
|
||||
export const getTransformHandleTypeFromCoords = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
[x1, y1, x2, y2]: Bounds,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
device: Device,
|
||||
): MaybeTransformHandleType => {
|
||||
const transformHandles = getTransformHandlesFromCoords(
|
||||
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
|
||||
0 as Radians,
|
||||
zoom,
|
||||
pointerType,
|
||||
getOmitSidesForDevice(device),
|
||||
);
|
||||
|
||||
const found = Object.keys(transformHandles).find((key) => {
|
||||
const transformHandle =
|
||||
transformHandles[key as Exclude<TransformHandleType, "rotation">]!;
|
||||
return (
|
||||
transformHandle &&
|
||||
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
|
||||
);
|
||||
});
|
||||
|
||||
if (found) {
|
||||
return found as MaybeTransformHandleType;
|
||||
}
|
||||
|
||||
if (canResizeFromSides(device)) {
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
|
||||
const sides = getSelectionBorders(
|
||||
pointFrom(x1 - SPACING, y1 - SPACING),
|
||||
pointFrom(x2 + SPACING, y2 + SPACING),
|
||||
pointFrom(cx, cy),
|
||||
0 as Radians,
|
||||
);
|
||||
|
||||
for (const [dir, side] of Object.entries(sides)) {
|
||||
// test to see if x, y are on the line segment
|
||||
if (
|
||||
pointOnLineSegment(
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
side as LineSegment<Point>,
|
||||
SPACING,
|
||||
)
|
||||
) {
|
||||
return dir as TransformHandleType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
|
||||
const rotateResizeCursor = (cursor: string, angle: number) => {
|
||||
const index = RESIZE_CURSORS.indexOf(cursor);
|
||||
if (index >= 0) {
|
||||
const a = Math.round(angle / (Math.PI / 4));
|
||||
cursor = RESIZE_CURSORS[(index + a) % RESIZE_CURSORS.length];
|
||||
}
|
||||
return cursor;
|
||||
};
|
||||
|
||||
/*
|
||||
* Returns bi-directional cursor for the element being resized
|
||||
*/
|
||||
export const getCursorForResizingElement = (resizingElement: {
|
||||
element?: ExcalidrawElement;
|
||||
transformHandleType: MaybeTransformHandleType;
|
||||
}): string => {
|
||||
const { element, transformHandleType } = resizingElement;
|
||||
const shouldSwapCursors =
|
||||
element && Math.sign(element.height) * Math.sign(element.width) === -1;
|
||||
let cursor = null;
|
||||
|
||||
switch (transformHandleType) {
|
||||
case "n":
|
||||
case "s":
|
||||
cursor = "ns";
|
||||
break;
|
||||
case "w":
|
||||
case "e":
|
||||
cursor = "ew";
|
||||
break;
|
||||
case "nw":
|
||||
case "se":
|
||||
if (shouldSwapCursors) {
|
||||
cursor = "nesw";
|
||||
} else {
|
||||
cursor = "nwse";
|
||||
}
|
||||
break;
|
||||
case "ne":
|
||||
case "sw":
|
||||
if (shouldSwapCursors) {
|
||||
cursor = "nwse";
|
||||
} else {
|
||||
cursor = "nesw";
|
||||
}
|
||||
break;
|
||||
case "rotation":
|
||||
return "grab";
|
||||
}
|
||||
|
||||
if (cursor && element) {
|
||||
cursor = rotateResizeCursor(cursor, element.angle);
|
||||
}
|
||||
|
||||
return cursor ? `${cursor}-resize` : "";
|
||||
};
|
||||
|
||||
const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
|
||||
[x1, y1]: Point,
|
||||
[x2, y2]: Point,
|
||||
center: Point,
|
||||
angle: Radians,
|
||||
) => {
|
||||
const topLeft = pointRotateRads(pointFrom(x1, y1), center, angle);
|
||||
const topRight = pointRotateRads(pointFrom(x2, y1), center, angle);
|
||||
const bottomLeft = pointRotateRads(pointFrom(x1, y2), center, angle);
|
||||
const bottomRight = pointRotateRads(pointFrom(x2, y2), center, angle);
|
||||
|
||||
return {
|
||||
n: [topLeft, topRight],
|
||||
e: [topRight, bottomRight],
|
||||
s: [bottomRight, bottomLeft],
|
||||
w: [bottomLeft, topLeft],
|
||||
};
|
||||
};
|
20
packages/element/showSelectedShapeActions.ts
Normal file
20
packages/element/showSelectedShapeActions.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { getSelectedElements } from "../scene";
|
||||
|
||||
import type { UIAppState } from "../types";
|
||||
import type { NonDeletedExcalidrawElement } from "./types";
|
||||
|
||||
export const showSelectedShapeActions = (
|
||||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) =>
|
||||
Boolean(
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
((appState.activeTool.type !== "custom" &&
|
||||
(appState.editingTextElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand" &&
|
||||
appState.activeTool.type !== "laser"))) ||
|
||||
getSelectedElements(elements, appState).length),
|
||||
);
|
69
packages/element/sizeHelpers.test.ts
Normal file
69
packages/element/sizeHelpers.test.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { vi } from "vitest";
|
||||
|
||||
import * as constants from "../constants";
|
||||
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
// Needed so that we can mock the value of constants which is done in
|
||||
// below tests. In Jest this wasn't needed as global override was possible
|
||||
// but vite doesn't allow that hence we need to mock
|
||||
vi.mock(
|
||||
"../constants.ts",
|
||||
//@ts-ignore
|
||||
async (importOriginal) => {
|
||||
const module: any = await importOriginal();
|
||||
return { ...module };
|
||||
},
|
||||
);
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 10, 140);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
});
|
||||
});
|
||||
});
|
233
packages/element/sizeHelpers.ts
Normal file
233
packages/element/sizeHelpers.ts
Normal file
|
@ -0,0 +1,233 @@
|
|||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { viewportCoordsToSceneCoords } from "../utils";
|
||||
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
import type { AppState, Offsets, Zoom } from "../types";
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||
// - could also be part of `_clearElements`
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
return element.points.length < 2;
|
||||
}
|
||||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
export const isElementInViewport = (
|
||||
element: ExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
viewTransformations: {
|
||||
zoom: Zoom;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft,
|
||||
clientY: viewTransformations.offsetTop,
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + width,
|
||||
clientY: viewTransformations.offsetTop + height,
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
|
||||
return (
|
||||
topLeftSceneCoords.x <= x2 &&
|
||||
topLeftSceneCoords.y <= y2 &&
|
||||
bottomRightSceneCoords.x >= x1 &&
|
||||
bottomRightSceneCoords.y >= y1
|
||||
);
|
||||
};
|
||||
|
||||
export const isElementCompletelyInViewport = (
|
||||
elements: ExcalidrawElement[],
|
||||
width: number,
|
||||
height: number,
|
||||
viewTransformations: {
|
||||
zoom: Zoom;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
},
|
||||
elementsMap: ElementsMap,
|
||||
padding?: Offsets,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements, elementsMap); // scene coordinates
|
||||
const topLeftSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + (padding?.left || 0),
|
||||
clientY: viewTransformations.offsetTop + (padding?.top || 0),
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
const bottomRightSceneCoords = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: viewTransformations.offsetLeft + width - (padding?.right || 0),
|
||||
clientY: viewTransformations.offsetTop + height - (padding?.bottom || 0),
|
||||
},
|
||||
viewTransformations,
|
||||
);
|
||||
|
||||
return (
|
||||
x1 >= topLeftSceneCoords.x &&
|
||||
y1 >= topLeftSceneCoords.y &&
|
||||
x2 <= bottomRightSceneCoords.x &&
|
||||
y2 <= bottomRightSceneCoords.y
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
export const getPerfectElementSize = (
|
||||
elementType: AppState["activeTool"]["type"],
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } => {
|
||||
const absWidth = Math.abs(width);
|
||||
const absHeight = Math.abs(height);
|
||||
|
||||
if (
|
||||
elementType === "line" ||
|
||||
elementType === "arrow" ||
|
||||
elementType === "freedraw"
|
||||
) {
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(absHeight / absWidth) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
} else if (lockedAngle === Math.PI / 2) {
|
||||
width = 0;
|
||||
} else {
|
||||
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
|
||||
}
|
||||
} else if (elementType !== "selection") {
|
||||
height = absWidth * Math.sign(height);
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const getLockedLinearCursorAlignSize = (
|
||||
originX: number,
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
} else if (lockedAngle === Math.PI / 2) {
|
||||
width = 0;
|
||||
} else {
|
||||
// locked angle line, y = mx + b => mx - y + b = 0
|
||||
const a1 = Math.tan(lockedAngle);
|
||||
const b1 = -1;
|
||||
const c1 = originY - a1 * originX;
|
||||
|
||||
// line through cursor, perpendicular to locked angle line
|
||||
const a2 = -1 / a1;
|
||||
const b2 = -1;
|
||||
const c2 = y - a2 * x;
|
||||
|
||||
// intersection of the two lines above
|
||||
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
|
||||
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
|
||||
|
||||
// delta
|
||||
width = intersectX - originX;
|
||||
height = intersectY - originY;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const resizePerfectLineForNWHandler = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const anchorX = element.x + element.width;
|
||||
const anchorY = element.y + element.height;
|
||||
const distanceToAnchorX = x - anchorX;
|
||||
const distanceToAnchorY = y - anchorY;
|
||||
if (Math.abs(distanceToAnchorX) < Math.abs(distanceToAnchorY) / 2) {
|
||||
mutateElement(element, {
|
||||
x: anchorX,
|
||||
width: 0,
|
||||
y,
|
||||
height: -distanceToAnchorY,
|
||||
});
|
||||
} else if (Math.abs(distanceToAnchorY) < Math.abs(element.width) / 2) {
|
||||
mutateElement(element, {
|
||||
y: anchorY,
|
||||
height: 0,
|
||||
});
|
||||
} else {
|
||||
const nextHeight =
|
||||
Math.sign(distanceToAnchorY) *
|
||||
Math.sign(distanceToAnchorX) *
|
||||
element.width;
|
||||
mutateElement(element, {
|
||||
x,
|
||||
y: anchorY - nextHeight,
|
||||
width: -distanceToAnchorX,
|
||||
height: nextHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getNormalizedDimensions = (
|
||||
element: Pick<ExcalidrawElement, "width" | "height" | "x" | "y">,
|
||||
): {
|
||||
width: ExcalidrawElement["width"];
|
||||
height: ExcalidrawElement["height"];
|
||||
x: ExcalidrawElement["x"];
|
||||
y: ExcalidrawElement["y"];
|
||||
} => {
|
||||
const ret = {
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
x: element.x,
|
||||
y: element.y,
|
||||
};
|
||||
|
||||
if (element.width < 0) {
|
||||
const nextWidth = Math.abs(element.width);
|
||||
ret.width = nextWidth;
|
||||
ret.x = element.x - nextWidth;
|
||||
}
|
||||
|
||||
if (element.height < 0) {
|
||||
const nextHeight = Math.abs(element.height);
|
||||
ret.height = nextHeight;
|
||||
ret.y = element.y - nextHeight;
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
404
packages/element/sortElements.test.ts
Normal file
404
packages/element/sortElements.test.ts
Normal file
|
@ -0,0 +1,404 @@
|
|||
import { API } from "../tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { normalizeElementOrder } from "./sortElements";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
const assertOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
expectedOrder: string[],
|
||||
) => {
|
||||
const actualOrder = elements.map((element) => element.id);
|
||||
expect(actualOrder).toEqual(expectedOrder);
|
||||
};
|
||||
|
||||
describe("normalizeElementsOrder", () => {
|
||||
it("sort bound-text elements", () => {
|
||||
const container = API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
});
|
||||
const otherElement = API.createElement({
|
||||
id: "otherElement",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const otherElement2 = API.createElement({
|
||||
id: "otherElement2",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
assertOrder(normalizeElementOrder([container, boundText]), [
|
||||
"container",
|
||||
"boundText",
|
||||
]);
|
||||
assertOrder(normalizeElementOrder([boundText, container]), [
|
||||
"container",
|
||||
"boundText",
|
||||
]);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
boundText,
|
||||
container,
|
||||
otherElement,
|
||||
otherElement2,
|
||||
]),
|
||||
["container", "boundText", "otherElement", "otherElement2"],
|
||||
);
|
||||
assertOrder(normalizeElementOrder([container, otherElement, boundText]), [
|
||||
"container",
|
||||
"boundText",
|
||||
"otherElement",
|
||||
]);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
container,
|
||||
otherElement,
|
||||
otherElement2,
|
||||
boundText,
|
||||
]),
|
||||
["container", "boundText", "otherElement", "otherElement2"],
|
||||
);
|
||||
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
boundText,
|
||||
otherElement,
|
||||
container,
|
||||
otherElement2,
|
||||
]),
|
||||
["otherElement", "container", "boundText", "otherElement2"],
|
||||
);
|
||||
|
||||
// noop
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
otherElement,
|
||||
container,
|
||||
boundText,
|
||||
otherElement2,
|
||||
]),
|
||||
["otherElement", "container", "boundText", "otherElement2"],
|
||||
);
|
||||
|
||||
// text has existing containerId, but container doesn't list is
|
||||
// as a boundElement
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: "container",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
}),
|
||||
]),
|
||||
["boundText", "container"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: "container",
|
||||
}),
|
||||
]),
|
||||
["boundText"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
}),
|
||||
]),
|
||||
["container"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [{ id: "x", type: "text" }],
|
||||
}),
|
||||
]),
|
||||
["container"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "arrow",
|
||||
type: "arrow",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
}),
|
||||
]),
|
||||
["arrow", "container"],
|
||||
);
|
||||
});
|
||||
|
||||
it("normalize group order", () => {
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect2",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect3",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect4",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect6",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect7",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect2",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "B_rect3",
|
||||
type: "rectangle",
|
||||
groupIds: ["B"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect4",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "B_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["B"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect6",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect7",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"],
|
||||
);
|
||||
// nested groups
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "BA_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "BA_rect2"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "BA_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["BA_rect1", "A_rect2"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "BA_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "CBA_rect3",
|
||||
type: "rectangle",
|
||||
groupIds: ["C", "B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect4",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "BA_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "BA_rect6",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "CBA_rect7",
|
||||
type: "rectangle",
|
||||
groupIds: ["C", "B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "X_rect8",
|
||||
type: "rectangle",
|
||||
groupIds: ["X"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect9",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "YX_rect10",
|
||||
type: "rectangle",
|
||||
groupIds: ["Y", "X"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "X_rect11",
|
||||
type: "rectangle",
|
||||
groupIds: ["X"],
|
||||
}),
|
||||
]),
|
||||
[
|
||||
"BA_rect1",
|
||||
"BA_rect5",
|
||||
"BA_rect6",
|
||||
"A_rect2",
|
||||
"A_rect5",
|
||||
"CBA_rect3",
|
||||
"CBA_rect7",
|
||||
"rect4",
|
||||
"X_rect8",
|
||||
"X_rect11",
|
||||
"YX_rect10",
|
||||
"rect9",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// TODO
|
||||
it.skip("normalize boundElements array", () => {
|
||||
const container = API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
mutateElement(container, {
|
||||
boundElements: [
|
||||
{ type: "text", id: boundText.id },
|
||||
{ type: "text", id: "xxx" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalizeElementOrder([container, boundText])).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
}),
|
||||
expect.objectContaining({ id: boundText.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
// should take around <100ms for 10K iterations (@dwelle's PC 22-05-25)
|
||||
it.skip("normalizeElementsOrder() perf", () => {
|
||||
const makeElements = (iterations: number) => {
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
while (iterations--) {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
groupIds: ["B", "A"],
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const otherElement = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
groupIds: ["C", "A"],
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
elements.push(boundText, otherElement, container);
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
const elements = makeElements(10000);
|
||||
const t0 = Date.now();
|
||||
normalizeElementOrder(elements);
|
||||
console.info(`${Date.now() - t0}ms`);
|
||||
});
|
||||
});
|
121
packages/element/sortElements.ts
Normal file
121
packages/element/sortElements.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { arrayToMapWithIndex } from "../utils";
|
||||
|
||||
import type { ExcalidrawElement } from "./types";
|
||||
|
||||
const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
|
||||
const origElements: ExcalidrawElement[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
|
||||
const orderInnerGroups = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ExcalidrawElement[] => {
|
||||
const firstGroupSig = elements[0]?.groupIds?.join("");
|
||||
const aGroup: ExcalidrawElement[] = [elements[0]];
|
||||
const bGroup: ExcalidrawElement[] = [];
|
||||
for (const element of elements.slice(1)) {
|
||||
if (element.groupIds?.join("") === firstGroupSig) {
|
||||
aGroup.push(element);
|
||||
} else {
|
||||
bGroup.push(element);
|
||||
}
|
||||
}
|
||||
return bGroup.length ? [...aGroup, ...orderInnerGroups(bGroup)] : aGroup;
|
||||
};
|
||||
|
||||
const groupHandledElements = new Map<string, true>();
|
||||
|
||||
origElements.forEach((element, idx) => {
|
||||
if (groupHandledElements.has(element.id)) {
|
||||
return;
|
||||
}
|
||||
if (element.groupIds?.length) {
|
||||
const topGroup = element.groupIds[element.groupIds.length - 1];
|
||||
const groupElements = origElements.slice(idx).filter((element) => {
|
||||
const ret = element?.groupIds?.some((id) => id === topGroup);
|
||||
if (ret) {
|
||||
groupHandledElements.set(element!.id, true);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
for (const elem of orderInnerGroups(groupElements)) {
|
||||
sortedElements.add(elem);
|
||||
}
|
||||
} else {
|
||||
sortedElements.add(element);
|
||||
}
|
||||
});
|
||||
|
||||
// if there's a bug which resulted in losing some of the elements, return
|
||||
// original instead as that's better than losing data
|
||||
if (sortedElements.size !== elements.length) {
|
||||
console.error("normalizeGroupElementOrder: lost some elements... bailing!");
|
||||
return elements;
|
||||
}
|
||||
|
||||
return [...sortedElements];
|
||||
};
|
||||
|
||||
/**
|
||||
* In theory, when we have text elements bound to a container, they
|
||||
* should be right after the container element in the elements array.
|
||||
* However, this is not guaranteed due to old and potential future bugs.
|
||||
*
|
||||
* This function sorts containers and their bound texts together. It prefers
|
||||
* original z-index of container (i.e. it moves bound text elements after
|
||||
* containers).
|
||||
*/
|
||||
const normalizeBoundElementsOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const elementsMap = arrayToMapWithIndex(elements);
|
||||
|
||||
const origElements: (ExcalidrawElement | null)[] = elements.slice();
|
||||
const sortedElements = new Set<ExcalidrawElement>();
|
||||
|
||||
origElements.forEach((element, idx) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (element.boundElements?.length) {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
element.boundElements.forEach((boundElement) => {
|
||||
const child = elementsMap.get(boundElement.id);
|
||||
if (child && boundElement.type === "text") {
|
||||
sortedElements.add(child[0]);
|
||||
origElements[child[1]] = null;
|
||||
}
|
||||
});
|
||||
} else if (element.type === "text" && element.containerId) {
|
||||
const parent = elementsMap.get(element.containerId);
|
||||
if (!parent?.[0].boundElements?.find((x) => x.id === element.id)) {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
|
||||
// if element has a container and container lists it, skip this element
|
||||
// as it'll be taken care of by the container
|
||||
}
|
||||
} else {
|
||||
sortedElements.add(element);
|
||||
origElements[idx] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// if there's a bug which resulted in losing some of the elements, return
|
||||
// original instead as that's better than losing data
|
||||
if (sortedElements.size !== elements.length) {
|
||||
console.error(
|
||||
"normalizeBoundElementsOrder: lost some elements... bailing!",
|
||||
);
|
||||
return elements;
|
||||
}
|
||||
|
||||
return [...sortedElements];
|
||||
};
|
||||
|
||||
export const normalizeElementOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
|
||||
};
|
208
packages/element/textElement.test.ts
Normal file
208
packages/element/textElement.test.ts
Normal file
|
@ -0,0 +1,208 @@
|
|||
import { FONT_FAMILY } from "../constants";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { API } from "../tests/helpers/api";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
} from "./textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "./textMeasurements";
|
||||
|
||||
import type { ExcalidrawTextElementWithContainer } from "./types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeContainerDimensionForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
160,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
226,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
320,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: '"container-id',
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getLineHeight()).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return line height using default font family for unknown font", () => {
|
||||
const UNKNOWN_FONT = 5;
|
||||
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return correct line height", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
524
packages/element/textElement.ts
Normal file
524
packages/element/textElement.ts
Normal file
|
@ -0,0 +1,524 @@
|
|||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { getFontString, arrayToMap } from "../utils";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import { isBoundToContainer, isArrowElement } from "./typeChecks";
|
||||
|
||||
import { isTextElement } from ".";
|
||||
|
||||
import type { MaybeTransformHandleType } from "./transformHandles";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
import type { AppState } from "../types";
|
||||
import type { ExtractSetType } from "../utility-types";
|
||||
|
||||
export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
informMutation = true,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
const boundTextUpdates = {
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
text: textElement.text,
|
||||
width: textElement.width,
|
||||
height: textElement.height,
|
||||
angle: container?.angle ?? textElement.angle,
|
||||
};
|
||||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container || !textElement.autoResize) {
|
||||
maxWidth = container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width;
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
|
||||
if (textElement.autoResize) {
|
||||
boundTextUpdates.width = metrics.width;
|
||||
}
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
if (container) {
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container, textElement);
|
||||
|
||||
if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
|
||||
const nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight }, informMutation);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
if (metrics.width > maxContainerWidth) {
|
||||
const nextWidth = computeContainerDimensionForBoundText(
|
||||
metrics.width,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { width: nextWidth }, informMutation);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
} as ExcalidrawTextElementWithContainer;
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
boundTextUpdates.x = x;
|
||||
boundTextUpdates.y = y;
|
||||
}
|
||||
|
||||
mutateElement(textElement, boundTextUpdates, informMutation);
|
||||
};
|
||||
|
||||
export const bindTextToShapeAfterDuplication = (
|
||||
newElements: ExcalidrawElement[],
|
||||
oldElements: ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
): void => {
|
||||
const newElementsMap = arrayToMap(newElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
oldElements.forEach((element) => {
|
||||
const newElementId = oldIdToDuplicatedId.get(element.id) as string;
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
if (boundTextElementId) {
|
||||
const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
|
||||
if (newTextElementId) {
|
||||
const newContainer = newElementsMap.get(newElementId);
|
||||
if (newContainer) {
|
||||
mutateElement(newContainer, {
|
||||
boundElements: (element.boundElements || [])
|
||||
.filter(
|
||||
(boundElement) =>
|
||||
boundElement.id !== newTextElementId &&
|
||||
boundElement.id !== boundTextElementId,
|
||||
)
|
||||
.concat({
|
||||
type: "text",
|
||||
id: newTextElementId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const newTextElement = newElementsMap.get(newTextElementId);
|
||||
if (newTextElement && isTextElement(newTextElement)) {
|
||||
mutateElement(newTextElement, {
|
||||
containerId: newContainer ? newElementId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
return;
|
||||
}
|
||||
resetOriginalContainerCache(container.id);
|
||||
const textElement = getBoundTextElement(container, elementsMap);
|
||||
if (textElement && textElement.text) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
const maxHeight = getBoundTextMaxHeight(container, textElement);
|
||||
let containerHeight = container.height;
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
) {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
nextHeight = metrics.height;
|
||||
nextWidth = metrics.width;
|
||||
}
|
||||
// increase height in case text element height exceeds
|
||||
if (nextHeight > maxHeight) {
|
||||
containerHeight = computeContainerDimensionForBoundText(
|
||||
nextHeight,
|
||||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
(transformHandleType === "ne" ||
|
||||
transformHandleType === "nw" ||
|
||||
transformHandleType === "n")
|
||||
? container.y - diff
|
||||
: container.y;
|
||||
mutateElement(container, {
|
||||
height: containerHeight,
|
||||
y: updatedY,
|
||||
});
|
||||
}
|
||||
|
||||
mutateElement(textElement, {
|
||||
text,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
|
||||
if (!isArrowElement(container)) {
|
||||
mutateElement(
|
||||
textElement,
|
||||
computeBoundTextPosition(container, textElement, elementsMap),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const computeBoundTextPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y = containerCoords.y;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
|
||||
} else {
|
||||
y =
|
||||
containerCoords.y +
|
||||
(maxContainerHeight / 2 - boundTextElement.height / 2);
|
||||
}
|
||||
if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
|
||||
x = containerCoords.x;
|
||||
} else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
|
||||
x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
|
||||
} else {
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
if (boundTextElementId) {
|
||||
return (elementsMap.get(boundTextElementId) ||
|
||||
null) as ExcalidrawTextElementWithContainer | null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerElement = (
|
||||
element: ExcalidrawTextElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
): ExcalidrawTextContainer | null => {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
if (element.containerId) {
|
||||
return (elementsMap.get(element.containerId) ||
|
||||
null) as ExcalidrawTextContainer | null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (!isArrowElement(container)) {
|
||||
return {
|
||||
x: container.x + container.width / 2,
|
||||
y: container.y + container.height / 2,
|
||||
};
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
container,
|
||||
elementsMap,
|
||||
);
|
||||
if (points.length % 2 === 1) {
|
||||
const index = Math.floor(container.points.length / 2);
|
||||
const midPoint = LinearElementEditor.getPointGlobalCoordinates(
|
||||
container,
|
||||
container.points[index],
|
||||
elementsMap,
|
||||
);
|
||||
return { x: midPoint[0], y: midPoint[1] };
|
||||
}
|
||||
const index = container.points.length / 2 - 1;
|
||||
let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
|
||||
container,
|
||||
elementsMap,
|
||||
appState,
|
||||
)[index];
|
||||
if (!midSegmentMidpoint) {
|
||||
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
|
||||
container,
|
||||
points[index],
|
||||
points[index + 1],
|
||||
index + 1,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
|
||||
};
|
||||
|
||||
export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
|
||||
let offsetX = BOUND_TEXT_PADDING;
|
||||
let offsetY = BOUND_TEXT_PADDING;
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
|
||||
offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
|
||||
offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
|
||||
}
|
||||
// The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
|
||||
if (container.type === "diamond") {
|
||||
offsetX += container.width / 4;
|
||||
offsetY += container.height / 4;
|
||||
}
|
||||
return {
|
||||
x: container.x + offsetX,
|
||||
y: container.y + offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export const getTextElementAngle = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
) => {
|
||||
if (!container || isArrowElement(container)) {
|
||||
return textElement.angle;
|
||||
}
|
||||
return container.angle;
|
||||
};
|
||||
|
||||
export const getBoundTextElementPosition = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
if (isArrowElement(container)) {
|
||||
return LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldAllowVerticalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const suppportsHorizontalAlign = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
return selectedElements.some((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element, elementsMap);
|
||||
if (isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return isTextElement(element);
|
||||
});
|
||||
};
|
||||
|
||||
const VALID_CONTAINER_TYPES = new Set([
|
||||
"rectangle",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
export const isValidTextContainer = (element: {
|
||||
type: ExcalidrawElementType;
|
||||
}) => VALID_CONTAINER_TYPES.has(element.type);
|
||||
|
||||
export const computeContainerDimensionForBoundText = (
|
||||
dimension: number,
|
||||
containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
|
||||
) => {
|
||||
dimension = Math.ceil(dimension);
|
||||
const padding = BOUND_TEXT_PADDING * 2;
|
||||
|
||||
if (containerType === "ellipse") {
|
||||
return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
|
||||
}
|
||||
if (containerType === "arrow") {
|
||||
return dimension + padding * 8;
|
||||
}
|
||||
if (containerType === "diamond") {
|
||||
return 2 * (dimension + padding);
|
||||
}
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElement | null,
|
||||
) => {
|
||||
const { width } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const minWidth =
|
||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
||||
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
||||
}
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The width of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const { height } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
return boundTextElement.height;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
if (container.type === "ellipse") {
|
||||
// The height of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
|
||||
// equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
|
||||
return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
if (container.type === "diamond") {
|
||||
// The height of the largest rectangle inscribed inside a rhombus is
|
||||
// Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
|
||||
return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
return height - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
/** retrieves text from text elements and concatenates to a single string */
|
||||
export const getTextFromElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
separator = "\n\n",
|
||||
) => {
|
||||
const text = elements
|
||||
.reduce((acc: string[], element) => {
|
||||
if (isTextElement(element)) {
|
||||
acc.push(element.text);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join(separator);
|
||||
return text;
|
||||
};
|
225
packages/element/textMeasurements.ts
Normal file
225
packages/element/textMeasurements.ts
Normal file
|
@ -0,0 +1,225 @@
|
|||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
} from "../constants";
|
||||
import { getFontString, isTestEnv, normalizeEOL } from "../utils";
|
||||
|
||||
import type { FontString, ExcalidrawTextElement } from "./types";
|
||||
|
||||
export const measureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const _text = text
|
||||
.split("\n")
|
||||
// replace empty lines with single space because leading/trailing empty
|
||||
// lines would be stripped from computation
|
||||
.map((x) => x || " ")
|
||||
.join("\n");
|
||||
const fontSize = parseFloat(font);
|
||||
const height = getTextHeight(_text, fontSize, lineHeight);
|
||||
const width = getTextWidth(_text, font);
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
|
||||
|
||||
// FIXME rename to getApproxMinContainerWidth
|
||||
export const getApproxMinLineWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const maxCharWidth = getMaxCharWidth(font);
|
||||
if (maxCharWidth === 0) {
|
||||
return (
|
||||
measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
|
||||
BOUND_TEXT_PADDING * 2
|
||||
);
|
||||
}
|
||||
return maxCharWidth + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getMinTextElementWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const isMeasureTextSupported = () => {
|
||||
const width = getTextWidth(
|
||||
DUMMY_TEXT,
|
||||
getFontString({
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
}),
|
||||
);
|
||||
return width > 0;
|
||||
};
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
normalizeEOL(text)
|
||||
// replace tabs with spaces so they render and measure correctly
|
||||
.replace(/\t/g, " ")
|
||||
);
|
||||
};
|
||||
|
||||
const splitIntoLines = (text: string) => {
|
||||
return normalizeText(text).split("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* To get unitless line-height (if unknown) we can calculate it by dividing
|
||||
* height-per-line by fontSize.
|
||||
*/
|
||||
export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
|
||||
const lineCount = splitIntoLines(textElement.text).length;
|
||||
return (textElement.height /
|
||||
lineCount /
|
||||
textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
|
||||
};
|
||||
|
||||
/**
|
||||
* We calculate the line height from the font size and the unitless line height,
|
||||
* aligning with the W3C spec.
|
||||
*/
|
||||
export const getLineHeightInPx = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
let textMetricsProvider: TextMetricsProvider | undefined;
|
||||
|
||||
/**
|
||||
* Set a custom text metrics provider.
|
||||
*
|
||||
* Useful for overriding the width calculation algorithm where canvas API is not available / desired.
|
||||
*/
|
||||
export const setCustomTextMetricsProvider = (provider: TextMetricsProvider) => {
|
||||
textMetricsProvider = provider;
|
||||
};
|
||||
|
||||
export interface TextMetricsProvider {
|
||||
getLineWidth(text: string, fontString: FontString): number;
|
||||
}
|
||||
|
||||
class CanvasTextMetricsProvider implements TextMetricsProvider {
|
||||
private canvas: HTMLCanvasElement;
|
||||
|
||||
constructor() {
|
||||
this.canvas = document.createElement("canvas");
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||
* - text wrapping
|
||||
* - wysiwyg editor (+padding)
|
||||
*
|
||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||
*/
|
||||
public getLineWidth(text: string, fontString: FontString): number {
|
||||
const context = this.canvas.getContext("2d")!;
|
||||
context.font = fontString;
|
||||
const metrics = context.measureText(text);
|
||||
const advanceWidth = metrics.width;
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return advanceWidth * 10;
|
||||
}
|
||||
|
||||
return advanceWidth;
|
||||
}
|
||||
}
|
||||
|
||||
export const getLineWidth = (text: string, font: FontString) => {
|
||||
if (!textMetricsProvider) {
|
||||
textMetricsProvider = new CanvasTextMetricsProvider();
|
||||
}
|
||||
|
||||
return textMetricsProvider.getLineWidth(text, font);
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
export const getTextHeight = (
|
||||
text: string,
|
||||
fontSize: number,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
const lineCount = splitIntoLines(text).length;
|
||||
return getLineHeightInPx(fontSize, lineHeight) * lineCount;
|
||||
};
|
||||
|
||||
export const charWidth = (() => {
|
||||
const cachedCharWidth: { [key: FontString]: Array<number> } = {};
|
||||
|
||||
const calculate = (char: string, font: FontString) => {
|
||||
const unicode = char.charCodeAt(0);
|
||||
if (!cachedCharWidth[font]) {
|
||||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][unicode]) {
|
||||
const width = getLineWidth(char, font);
|
||||
cachedCharWidth[font][unicode] = width;
|
||||
}
|
||||
|
||||
return cachedCharWidth[font][unicode];
|
||||
};
|
||||
|
||||
const getCache = (font: FontString) => {
|
||||
return cachedCharWidth[font];
|
||||
};
|
||||
|
||||
const clearCache = (font: FontString) => {
|
||||
cachedCharWidth[font] = [];
|
||||
};
|
||||
|
||||
return {
|
||||
calculate,
|
||||
getCache,
|
||||
clearCache,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getMinCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
|
||||
return Math.min(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getMaxCharWidth = (font: FontString) => {
|
||||
const cache = charWidth.getCache(font);
|
||||
if (!cache) {
|
||||
return 0;
|
||||
}
|
||||
const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
|
||||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
634
packages/element/textWrapping.test.ts
Normal file
634
packages/element/textWrapping.test.ts
Normal file
|
@ -0,0 +1,634 @@
|
|||
import { wrapText, parseTokens } from "./textWrapping";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
// font is irrelevant as jsdom does not support FontFace API
|
||||
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
|
||||
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
|
||||
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello\nExcalidraw`);
|
||||
});
|
||||
|
||||
it("should return the text as is if max width is invalid", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||
expect(wrapText(text, font, -1)).toEqual(text);
|
||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
it("should not wrap number when wrapping line", () => {
|
||||
const text = "don't wrap this number 99,100.99";
|
||||
const maxWidth = 300;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("don't wrap this number\n99,100.99");
|
||||
});
|
||||
|
||||
it("should trim all trailing whitespaces", () => {
|
||||
const text = "Hello ";
|
||||
const maxWidth = 50;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should trim all but one trailing whitespaces", () => {
|
||||
const text = "Hello ";
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello ");
|
||||
});
|
||||
|
||||
it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
|
||||
const text = " Hello World";
|
||||
const maxWidth = 90;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" Hello\nWorld");
|
||||
});
|
||||
|
||||
it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
|
||||
const text = " Hello World ";
|
||||
const maxWidth = 90;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" Hello\nWorld ");
|
||||
});
|
||||
|
||||
it("should trim keep those whitespace that fit in the trailing line", () => {
|
||||
const text = "Hello Wo rl d ";
|
||||
const maxWidth = 100;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello Wo\nrl d ");
|
||||
});
|
||||
|
||||
it("should support multiple (multi-codepoint) emojis", () => {
|
||||
const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when text contains hyphen", () => {
|
||||
let text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
const res = wrapText(text, font, 110);
|
||||
expect(res).toBe(
|
||||
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
|
||||
);
|
||||
|
||||
text = "Hello thereusing-now";
|
||||
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
|
||||
});
|
||||
|
||||
it("should support wrapping nested lists", () => {
|
||||
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
|
||||
|
||||
const maxWidth = 100;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
||||
});
|
||||
|
||||
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"
|
||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(
|
||||
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
|
||||
);
|
||||
});
|
||||
|
||||
it("should break CJK text into longer segments when width is larger", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||
const maxWidth = 30;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
|
||||
// measureText is mocked, so it's not precisely what would happen in prod
|
||||
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
|
||||
});
|
||||
|
||||
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
|
||||
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
|
||||
|
||||
const maxWidth = 150;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
|
||||
|
||||
const maxWidth3 = 30;
|
||||
const res3 = wrapText(text, font, maxWidth3);
|
||||
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
|
||||
});
|
||||
|
||||
it("should break before and after a regular CJK character", () => {
|
||||
const text = "HelloたWorld";
|
||||
const maxWidth1 = 50;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe("Hello\nた\nWorld");
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe("Helloた\nWorld");
|
||||
});
|
||||
|
||||
it("should break before and after certain CJK symbols", () => {
|
||||
const text = "こんにちは〃世界";
|
||||
const maxWidth1 = 50;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe("こんにちは\n〃世界");
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe("こんにちは〃\n世界");
|
||||
});
|
||||
|
||||
it("should break after, not before for certain CJK pairs", () => {
|
||||
const text = "Hello た。";
|
||||
const maxWidth = 70;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello\nた。");
|
||||
});
|
||||
|
||||
it("should break before, not after for certain CJK pairs", () => {
|
||||
const text = "Hello「たWorld」";
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello\n「た\nWorld」");
|
||||
});
|
||||
|
||||
it("should break after, not before for certain CJK character pairs", () => {
|
||||
const text = "「Helloた」World";
|
||||
const maxWidth = 70;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("「Hello\nた」World");
|
||||
});
|
||||
|
||||
it("should break Chinese sentences", () => {
|
||||
const text = `中国你好!这是一个测试。
|
||||
我们来看看:人民币¥1234「很贵」
|
||||
(括号)、逗号,句号。空格 换行 全角符号…—`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`中国你好!这是一\n个测试。
|
||||
我们来看看:人民\n币¥1234「很\n贵」
|
||||
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`中国你好!\n这是一个测\n试。
|
||||
我们来看\n看:人民币\n¥1234\n「很贵」
|
||||
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
|
||||
});
|
||||
|
||||
it("should break Japanese sentences", () => {
|
||||
const text = `日本こんにちは!これはテストです。
|
||||
見てみましょう:円¥1234「高い」
|
||||
(括弧)、読点、句点。
|
||||
空白 改行 全角記号…ー`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
|
||||
見てみましょ\nう:円¥1234\n「高い」
|
||||
(括弧)、読\n点、句点。
|
||||
空白 改行\n全角記号…ー`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
|
||||
見てみ\nましょう:\n円\n¥1234\n「高い」
|
||||
(括\n弧)、読\n点、句点。
|
||||
空白\n改行 全角\n記号…ー`);
|
||||
});
|
||||
|
||||
it("should break Korean sentences", () => {
|
||||
const text = `한국 안녕하세요! 이것은 테스트입니다.
|
||||
우리 보자: 원화₩1234「비싸다」
|
||||
(괄호), 쉼표, 마침표.
|
||||
공백 줄바꿈 전각기호…—`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
|
||||
우리 보자: 원\n화₩1234「비\n싸다」
|
||||
(괄호), 쉼\n표, 마침표.
|
||||
공백 줄바꿈 전\n각기호…—`);
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
|
||||
우리 보자:\n원화\n₩1234\n「비싸다」
|
||||
(괄호),\n쉼표, 마침\n표.
|
||||
공백 줄바꿈\n전각기호…—`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contains leading whitespaces", () => {
|
||||
const text = " \t Hello world";
|
||||
|
||||
it("should preserve leading whitespaces", () => {
|
||||
const maxWidth = 120;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" \t Hello\nworld");
|
||||
});
|
||||
|
||||
it("should break and collapse leading whitespaces when line breaks", () => {
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("\nHello\nworld");
|
||||
});
|
||||
|
||||
it("should break and collapse leading whitespaces whe words break", () => {
|
||||
const maxWidth = 30;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("\nHel\nlo\nwor\nld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contains trailing whitespaces", () => {
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 190;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should ignore trailing whitespaces when line breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 400;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
|
||||
});
|
||||
|
||||
it("should not ignore trailing whitespaces when word breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 300;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
|
||||
});
|
||||
|
||||
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 180;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 70,
|
||||
res: `Hello\nwhats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 15,
|
||||
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `Hello whats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 240,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "push the word if its equal to max width",
|
||||
width: 50,
|
||||
res: `Hello\nwhats\nup`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello\n whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 70,
|
||||
res: `Hello\n whats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 15,
|
||||
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
width: 140,
|
||||
res: `Hello\n whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 160,
|
||||
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
|
||||
},
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 120,
|
||||
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 590,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test parseTokens", () => {
|
||||
it("should tokenize latin", () => {
|
||||
let text = "Excalidraw is a virtual collaborative whiteboard";
|
||||
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Excalidraw",
|
||||
" ",
|
||||
"is",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"virtual",
|
||||
" ",
|
||||
"collaborative",
|
||||
" ",
|
||||
"whiteboard",
|
||||
]);
|
||||
|
||||
text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Wikipedia",
|
||||
" ",
|
||||
"is",
|
||||
" ",
|
||||
"hosted",
|
||||
" ",
|
||||
"by",
|
||||
" ",
|
||||
"Wikimedia-",
|
||||
" ",
|
||||
"Foundation,",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"non-",
|
||||
"profit",
|
||||
" ",
|
||||
"organization",
|
||||
" ",
|
||||
"that",
|
||||
" ",
|
||||
"also",
|
||||
" ",
|
||||
"hosts",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"range-",
|
||||
"of",
|
||||
" ",
|
||||
"other",
|
||||
" ",
|
||||
"projects",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not tokenize number", () => {
|
||||
const text = "99,100.99";
|
||||
const tokens = parseTokens(text);
|
||||
expect(tokens).toEqual(["99,100.99"]);
|
||||
});
|
||||
|
||||
it("should tokenize joined emojis", () => {
|
||||
const text = `😬🌍🗺🔥☂️👩🏽🦰👨👩👧👦👩🏾🔬🏳️🌈🧔♀️🧑🤝🧑🙅🏽♂️✅0️⃣🇨🇿🦅`;
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
expect(tokens).toEqual([
|
||||
"😬",
|
||||
"🌍",
|
||||
"🗺",
|
||||
"🔥",
|
||||
"☂️",
|
||||
"👩🏽🦰",
|
||||
"👨👩👧👦",
|
||||
"👩🏾🔬",
|
||||
"🏳️🌈",
|
||||
"🧔♀️",
|
||||
"🧑🤝🧑",
|
||||
"🙅🏽♂️",
|
||||
"✅",
|
||||
"0️⃣",
|
||||
"🇨🇿",
|
||||
"🦅",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should tokenize emojis mixed with mixed text", () => {
|
||||
const text = `😬a🌍b🗺c🔥d☂️《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳️🌈안🧔♀️g🧑🤝🧑h🙅🏽♂️e✅f0️⃣g🇨🇿10🦅#hash`;
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
expect(tokens).toEqual([
|
||||
"😬",
|
||||
"a",
|
||||
"🌍",
|
||||
"b",
|
||||
"🗺",
|
||||
"c",
|
||||
"🔥",
|
||||
"d",
|
||||
"☂️",
|
||||
"《",
|
||||
"👩🏽🦰",
|
||||
"》",
|
||||
"👨👩👧👦",
|
||||
"德",
|
||||
"👩🏾🔬",
|
||||
"こ",
|
||||
"🏳️🌈",
|
||||
"안",
|
||||
"🧔♀️",
|
||||
"g",
|
||||
"🧑🤝🧑",
|
||||
"h",
|
||||
"🙅🏽♂️",
|
||||
"e",
|
||||
"✅",
|
||||
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
|
||||
"🇨🇿",
|
||||
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
|
||||
"🦅",
|
||||
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
|
||||
]);
|
||||
});
|
||||
|
||||
it("should tokenize decomposed chars into their composed variants", () => {
|
||||
// each input character is in a decomposed form
|
||||
const text = "čでäぴέ다й한";
|
||||
expect(text.normalize("NFC").length).toEqual(8);
|
||||
expect(text).toEqual(text.normalize("NFD"));
|
||||
|
||||
const tokens = parseTokens(text);
|
||||
expect(tokens.length).toEqual(8);
|
||||
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
|
||||
});
|
||||
|
||||
it("should tokenize artificial CJK", () => {
|
||||
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
|
||||
// [
|
||||
// '《道', '德', '經》', '醫-',
|
||||
// '醫', 'こ', 'ん', 'に',
|
||||
// 'ち', 'は', '世', '界!',
|
||||
// '안', '녕', '하', '세',
|
||||
// '요', '세', '계;', '요』,',
|
||||
// '다.', '다...', '원/', '달',
|
||||
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
|
||||
// 'た…', '[Hello]', ' ', '\t',
|
||||
// ' ', 'World?', 'ニ', 'ュ',
|
||||
// 'ー', 'ヨ', 'ー', 'ク・',
|
||||
// '¥3700.55', 'す。', '090-', '1234-',
|
||||
// '5678', '¥1,000〜', '$5,000', '「素',
|
||||
// '晴', 'ら', 'し', 'い!」',
|
||||
// '〔重', '要〕', '#', '1:',
|
||||
// 'Taro', '君', '30%', 'は、',
|
||||
// '(た', 'な', 'ば', 'た)',
|
||||
// '〰', '¥110±', '¥570', 'で',
|
||||
// '20℃〜', '9:30〜', '10:00', '【一',
|
||||
// '番】'
|
||||
// ]
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
// Latin
|
||||
expect(tokens).toContain("[[1]]");
|
||||
expect(tokens).toContain("[Hello]");
|
||||
expect(tokens).toContain("World?");
|
||||
expect(tokens).toContain("Taro");
|
||||
|
||||
// Chinese
|
||||
expect(tokens).toContain("《道");
|
||||
expect(tokens).toContain("德");
|
||||
expect(tokens).toContain("經》");
|
||||
expect(tokens).toContain("醫-");
|
||||
expect(tokens).toContain("醫");
|
||||
|
||||
// Japanese
|
||||
expect(tokens).toContain("こ");
|
||||
expect(tokens).toContain("ん");
|
||||
expect(tokens).toContain("に");
|
||||
expect(tokens).toContain("ち");
|
||||
expect(tokens).toContain("は");
|
||||
expect(tokens).toContain("世");
|
||||
expect(tokens).toContain("ク・");
|
||||
expect(tokens).toContain("界!");
|
||||
expect(tokens).toContain("た…");
|
||||
expect(tokens).toContain("す。");
|
||||
expect(tokens).toContain("ュ");
|
||||
expect(tokens).toContain("「素");
|
||||
expect(tokens).toContain("晴");
|
||||
expect(tokens).toContain("ら");
|
||||
expect(tokens).toContain("し");
|
||||
expect(tokens).toContain("い!」");
|
||||
expect(tokens).toContain("君");
|
||||
expect(tokens).toContain("は、");
|
||||
expect(tokens).toContain("(た");
|
||||
expect(tokens).toContain("な");
|
||||
expect(tokens).toContain("ば");
|
||||
expect(tokens).toContain("た)");
|
||||
expect(tokens).toContain("で");
|
||||
expect(tokens).toContain("【一");
|
||||
expect(tokens).toContain("番】");
|
||||
|
||||
// Check for Korean
|
||||
expect(tokens).toContain("안");
|
||||
expect(tokens).toContain("녕");
|
||||
expect(tokens).toContain("하");
|
||||
expect(tokens).toContain("세");
|
||||
expect(tokens).toContain("요");
|
||||
expect(tokens).toContain("세");
|
||||
expect(tokens).toContain("계;");
|
||||
expect(tokens).toContain("요』,");
|
||||
expect(tokens).toContain("다.");
|
||||
expect(tokens).toContain("다...");
|
||||
expect(tokens).toContain("원/");
|
||||
expect(tokens).toContain("달");
|
||||
expect(tokens).toContain("(((다)))");
|
||||
expect(tokens).toContain("〚({((한))>)〛");
|
||||
expect(tokens).toContain("(「た」)");
|
||||
|
||||
// Numbers and units
|
||||
expect(tokens).toContain("¥3700.55");
|
||||
expect(tokens).toContain("090-");
|
||||
expect(tokens).toContain("1234-");
|
||||
expect(tokens).toContain("5678");
|
||||
expect(tokens).toContain("¥1,000〜");
|
||||
expect(tokens).toContain("$5,000");
|
||||
expect(tokens).toContain("1:");
|
||||
expect(tokens).toContain("30%");
|
||||
expect(tokens).toContain("¥110±");
|
||||
expect(tokens).toContain("20℃〜");
|
||||
expect(tokens).toContain("9:30〜");
|
||||
expect(tokens).toContain("10:00");
|
||||
|
||||
// Punctuation and symbols
|
||||
expect(tokens).toContain(" ");
|
||||
expect(tokens).toContain("\t");
|
||||
expect(tokens).toContain(" ");
|
||||
expect(tokens).toContain("ニ");
|
||||
expect(tokens).toContain("ー");
|
||||
expect(tokens).toContain("ヨ");
|
||||
expect(tokens).toContain("〰");
|
||||
expect(tokens).toContain("#");
|
||||
});
|
||||
});
|
||||
});
|
570
packages/element/textWrapping.ts
Normal file
570
packages/element/textWrapping.ts
Normal file
|
@ -0,0 +1,570 @@
|
|||
import { ENV } from "../constants";
|
||||
|
||||
import { charWidth, getLineWidth } from "./textMeasurements";
|
||||
|
||||
import type { FontString } from "./types";
|
||||
|
||||
let cachedCjkRegex: RegExp | undefined;
|
||||
let cachedLineBreakRegex: RegExp | undefined;
|
||||
let cachedEmojiRegex: RegExp | undefined;
|
||||
|
||||
/**
|
||||
* Test if a given text contains any CJK characters (including symbols, punctuation, etc,).
|
||||
*/
|
||||
export const containsCJK = (text: string) => {
|
||||
if (!cachedCjkRegex) {
|
||||
cachedCjkRegex = Regex.class(...Object.values(CJK));
|
||||
}
|
||||
|
||||
return cachedCjkRegex.test(text);
|
||||
};
|
||||
|
||||
const getLineBreakRegex = () => {
|
||||
if (!cachedLineBreakRegex) {
|
||||
try {
|
||||
cachedLineBreakRegex = getLineBreakRegexAdvanced();
|
||||
} catch {
|
||||
cachedLineBreakRegex = getLineBreakRegexSimple();
|
||||
}
|
||||
}
|
||||
|
||||
return cachedLineBreakRegex;
|
||||
};
|
||||
|
||||
const getEmojiRegex = () => {
|
||||
if (!cachedEmojiRegex) {
|
||||
cachedEmojiRegex = getEmojiRegexUnicode();
|
||||
}
|
||||
|
||||
return cachedEmojiRegex;
|
||||
};
|
||||
|
||||
/**
|
||||
* Common symbols used across different languages.
|
||||
*/
|
||||
const COMMON = {
|
||||
/**
|
||||
* Natural breaking points for any grammars.
|
||||
*
|
||||
* Hello world
|
||||
* ↑ BREAK ALWAYS " " → ["Hello", " ", "world"]
|
||||
* Hello-world
|
||||
* ↑ BREAK AFTER "-" → ["Hello-", "world"]
|
||||
*/
|
||||
WHITESPACE: /\s/u,
|
||||
HYPHEN: /-/u,
|
||||
/**
|
||||
* Generally do not break, unless closed symbol is followed by an opening symbol.
|
||||
*
|
||||
* Also, western punctation is often used in modern Korean and expects to be treated
|
||||
* similarly to the CJK opening and closing symbols.
|
||||
*
|
||||
* Hello(한글)→ ["Hello", "(한", "글)"]
|
||||
* ↑ BREAK BEFORE "("
|
||||
* ↑ BREAK AFTER ")"
|
||||
*/
|
||||
OPENING: /<\(\[\{/u,
|
||||
CLOSING: />\)\]\}.,:;!\?…\//u,
|
||||
};
|
||||
|
||||
/**
|
||||
* Characters and symbols used in Chinese, Japanese and Korean.
|
||||
*/
|
||||
const CJK = {
|
||||
/**
|
||||
* Every CJK breaks before and after, unless it's paired with an opening or closing symbol.
|
||||
*
|
||||
* Does not include every possible char used in CJK texts, such as currency, parentheses or punctuation.
|
||||
*/
|
||||
CHAR: /\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}`'^〃〰〆#&*+-ー/\=|¦〒¬ ̄/u,
|
||||
/**
|
||||
* Opening and closing CJK punctuation breaks before and after all such characters (in case of many),
|
||||
* and creates pairs with neighboring characters.
|
||||
*
|
||||
* Hello た。→ ["Hello", "た。"]
|
||||
* ↑ DON'T BREAK "た。"
|
||||
* * Hello「た」 World → ["Hello", "「た」", "World"]
|
||||
* ↑ DON'T BREAK "「た"
|
||||
* ↑ DON'T BREAK "た"
|
||||
* ↑ BREAK BEFORE "「"
|
||||
* ↑ BREAK AFTER "」"
|
||||
*/
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
OPENING:/([{〈《⦅「「『【〖〔〘〚<〝/u,
|
||||
CLOSING: /)]}〉》⦆」」』】〗〕〙〛>。.,、〟‥?!:;・〜〞/u,
|
||||
/**
|
||||
* Currency symbols break before, not after
|
||||
*
|
||||
* Price¥100 → ["Price", "¥100"]
|
||||
* ↑ BREAK BEFORE "¥"
|
||||
*/
|
||||
CURRENCY: /¥₩£¢$/u,
|
||||
};
|
||||
|
||||
const EMOJI = {
|
||||
FLAG: /\p{RI}\p{RI}/u,
|
||||
JOINER:
|
||||
/(?:\p{Emoji_Modifier}|\uFE0F\u20E3?|[\u{E0020}-\u{E007E}]+\u{E007F})?/u,
|
||||
ZWJ: /\u200D/u,
|
||||
ANY: /[\p{Emoji}]/u,
|
||||
MOST: /[\p{Extended_Pictographic}\p{Emoji_Presentation}]/u,
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple fallback for browsers (mainly Safari < 16.4) that don't support "Lookbehind assertion".
|
||||
*
|
||||
* Browser support as of 10/2024:
|
||||
* - 91% Lookbehind assertion https://caniuse.com/mdn-javascript_regular_expressions_lookbehind_assertion
|
||||
* - 94% Unicode character class escape https://caniuse.com/mdn-javascript_regular_expressions_unicode_character_class_escape
|
||||
*
|
||||
* Does not include advanced CJK breaking rules, but covers most of the core cases, especially for latin.
|
||||
*/
|
||||
const getLineBreakRegexSimple = () =>
|
||||
Regex.or(
|
||||
getEmojiRegex(),
|
||||
Break.On(COMMON.HYPHEN, COMMON.WHITESPACE, CJK.CHAR),
|
||||
);
|
||||
|
||||
/**
|
||||
* Specifies the line breaking rules based for alphabetic-based languages,
|
||||
* Chinese, Japanese, Korean and Emojis.
|
||||
*
|
||||
* "Hello-world" → ["Hello-", "world"]
|
||||
* "Hello 「世界。」🌎🗺" → ["Hello", " ", "「世", "界。」", "🌎", "🗺"]
|
||||
*/
|
||||
const getLineBreakRegexAdvanced = () =>
|
||||
Regex.or(
|
||||
// Unicode-defined regex for (multi-codepoint) Emojis
|
||||
getEmojiRegex(),
|
||||
// Rules for whitespace and hyphen
|
||||
Break.Before(COMMON.WHITESPACE).Build(),
|
||||
Break.After(COMMON.WHITESPACE, COMMON.HYPHEN).Build(),
|
||||
// Rules for CJK (chars, symbols, currency)
|
||||
Break.Before(CJK.CHAR, CJK.CURRENCY)
|
||||
.NotPrecededBy(COMMON.OPENING, CJK.OPENING)
|
||||
.Build(),
|
||||
Break.After(CJK.CHAR)
|
||||
.NotFollowedBy(COMMON.HYPHEN, COMMON.CLOSING, CJK.CLOSING)
|
||||
.Build(),
|
||||
// Rules for opening and closing punctuation
|
||||
Break.BeforeMany(CJK.OPENING).NotPrecededBy(COMMON.OPENING).Build(),
|
||||
Break.AfterMany(CJK.CLOSING).NotFollowedBy(COMMON.CLOSING).Build(),
|
||||
Break.AfterMany(COMMON.CLOSING).FollowedBy(COMMON.OPENING).Build(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Matches various emoji types.
|
||||
*
|
||||
* 1. basic emojis (😀, 🌍)
|
||||
* 2. flags (🇨🇿)
|
||||
* 3. multi-codepoint emojis:
|
||||
* - skin tones (👍🏽)
|
||||
* - variation selectors (☂️)
|
||||
* - keycaps (1️⃣)
|
||||
* - tag sequences (🏴)
|
||||
* - emoji sequences (👨👩👧👦, 👩🚀, 🏳️🌈)
|
||||
*
|
||||
* Unicode points:
|
||||
* - \uFE0F: presentation selector
|
||||
* - \u20E3: enclosing keycap
|
||||
* - \u200D: zero width joiner
|
||||
* - \u{E0020}-\u{E007E}: tags
|
||||
* - \u{E007F}: cancel tag
|
||||
*
|
||||
* @see https://unicode.org/reports/tr51/#EBNF_and_Regex, with changes:
|
||||
* - replaced \p{Emoji} with [\p{Extended_Pictographic}\p{Emoji_Presentation}], see more in `should tokenize emojis mixed with mixed text` test
|
||||
* - replaced \p{Emod} with \p{Emoji_Modifier} as some engines do not understand the abbreviation (i.e. https://devina.io/redos-checker)
|
||||
*/
|
||||
const getEmojiRegexUnicode = () =>
|
||||
Regex.group(
|
||||
Regex.or(
|
||||
EMOJI.FLAG,
|
||||
Regex.and(
|
||||
EMOJI.MOST,
|
||||
EMOJI.JOINER,
|
||||
Regex.build(
|
||||
`(?:${EMOJI.ZWJ.source}(?:${EMOJI.FLAG.source}|${EMOJI.ANY.source}${EMOJI.JOINER.source}))*`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Regex utilities for unicode character classes.
|
||||
*/
|
||||
const Regex = {
|
||||
/**
|
||||
* Builds a regex from a string.
|
||||
*/
|
||||
build: (regex: string): RegExp => new RegExp(regex, "u"),
|
||||
/**
|
||||
* Joins regexes into a single string.
|
||||
*/
|
||||
join: (...regexes: RegExp[]): string => regexes.map((x) => x.source).join(""),
|
||||
/**
|
||||
* Joins regexes into a single regex as with "and" operator.
|
||||
*/
|
||||
and: (...regexes: RegExp[]): RegExp => Regex.build(Regex.join(...regexes)),
|
||||
/**
|
||||
* Joins regexes into a single regex with "or" operator.
|
||||
*/
|
||||
or: (...regexes: RegExp[]): RegExp =>
|
||||
Regex.build(regexes.map((x) => x.source).join("|")),
|
||||
/**
|
||||
* Puts regexes into a matching group.
|
||||
*/
|
||||
group: (...regexes: RegExp[]): RegExp =>
|
||||
Regex.build(`(${Regex.join(...regexes)})`),
|
||||
/**
|
||||
* Puts regexes into a character class.
|
||||
*/
|
||||
class: (...regexes: RegExp[]): RegExp =>
|
||||
Regex.build(`[${Regex.join(...regexes)}]`),
|
||||
};
|
||||
|
||||
/**
|
||||
* Human-readable lookahead and lookbehind utilities for defining line break
|
||||
* opportunities between pairs of character classes.
|
||||
*/
|
||||
const Break = {
|
||||
/**
|
||||
* Break on the given class of characters.
|
||||
*/
|
||||
On: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
return Regex.build(`([${joined}])`);
|
||||
},
|
||||
/**
|
||||
* Break before the given class of characters.
|
||||
*/
|
||||
Before: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?=[${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"FollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Break after the given class of characters.
|
||||
*/
|
||||
After: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<=[${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"PreceededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Break before one or multiple characters of the same class.
|
||||
*/
|
||||
BeforeMany: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<![${joined}])(?=[${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"FollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Break after one or multiple character from the same class.
|
||||
*/
|
||||
AfterMany: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<=[${joined}])(?![${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"PreceededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Do not break before the given class of characters.
|
||||
*/
|
||||
NotBefore: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?![${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotFollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Do not break after the given class of characters.
|
||||
*/
|
||||
NotAfter: (...regexes: RegExp[]) => {
|
||||
const joined = Regex.join(...regexes);
|
||||
const builder = () => Regex.build(`(?<![${joined}])`);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotPrecededBy"
|
||||
>;
|
||||
},
|
||||
Chain: (rootBuilder: () => RegExp) => ({
|
||||
/**
|
||||
* Build the root regex.
|
||||
*/
|
||||
Build: rootBuilder,
|
||||
/**
|
||||
* Specify additional class of characters that should precede the root regex.
|
||||
*/
|
||||
PreceededBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const preceeded = Break.After(...regexes).Build();
|
||||
const builder = () => Regex.and(preceeded, root);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"PreceededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Specify additional class of characters that should follow the root regex.
|
||||
*/
|
||||
FollowedBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const followed = Break.Before(...regexes).Build();
|
||||
const builder = () => Regex.and(root, followed);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"FollowedBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Specify additional class of characters that should not precede the root regex.
|
||||
*/
|
||||
NotPrecededBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const notPreceeded = Break.NotAfter(...regexes).Build();
|
||||
const builder = () => Regex.and(notPreceeded, root);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotPrecededBy"
|
||||
>;
|
||||
},
|
||||
/**
|
||||
* Specify additional class of characters that should not follow the root regex.
|
||||
*/
|
||||
NotFollowedBy: (...regexes: RegExp[]) => {
|
||||
const root = rootBuilder();
|
||||
const notFollowed = Break.NotBefore(...regexes).Build();
|
||||
const builder = () => Regex.and(root, notFollowed);
|
||||
return Break.Chain(builder) as Omit<
|
||||
ReturnType<typeof Break.Chain>,
|
||||
"NotFollowedBy"
|
||||
>;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Breaks the line into the tokens based on the found line break opporutnities.
|
||||
*/
|
||||
export const parseTokens = (line: string) => {
|
||||
const breakLineRegex = getLineBreakRegex();
|
||||
|
||||
// normalizing to single-codepoint composed chars due to canonical equivalence
|
||||
// of multi-codepoint versions for chars like č, で (~ so that we don't break a line in between c and ˇ)
|
||||
// filtering due to multi-codepoint chars like 👨👩👧👦, 👩🏽🦰
|
||||
return line.normalize("NFC").split(breakLineRegex).filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original text into the lines based on the given width.
|
||||
*/
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font);
|
||||
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
const wrappedLine = wrapLine(originalLine, font, maxWidth);
|
||||
lines.push(...wrappedLine);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the original line into the lines based on the given width.
|
||||
*/
|
||||
const wrapLine = (
|
||||
line: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string[] => {
|
||||
const lines: Array<string> = [];
|
||||
const tokens = parseTokens(line);
|
||||
const tokenIterator = tokens[Symbol.iterator]();
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidth = 0;
|
||||
|
||||
let iterator = tokenIterator.next();
|
||||
|
||||
while (!iterator.done) {
|
||||
const token = iterator.value;
|
||||
const testLine = currentLine + token;
|
||||
|
||||
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
|
||||
const testLineWidth = isSingleCharacter(token)
|
||||
? currentLineWidth + charWidth.calculate(token, font)
|
||||
: getLineWidth(testLine, font);
|
||||
|
||||
// build up the current line, skipping length check for possibly trailing whitespaces
|
||||
if (/\s/.test(token) || testLineWidth <= maxWidth) {
|
||||
currentLine = testLine;
|
||||
currentLineWidth = testLineWidth;
|
||||
iterator = tokenIterator.next();
|
||||
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 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();
|
||||
} else {
|
||||
// push & reset, but don't iterate on the next token, as we didn't use it yet!
|
||||
lines.push(currentLine.trimEnd());
|
||||
|
||||
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
|
||||
currentLine = "";
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// iterator done, push the trailing line if exists
|
||||
if (currentLine) {
|
||||
const trailingLine = trimLine(currentLine, font, maxWidth);
|
||||
lines.push(trailingLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the word into the lines based on the given width.
|
||||
*/
|
||||
const wrapWord = (
|
||||
word: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): Array<string> => {
|
||||
// multi-codepoint emojis are already broken apart and shouldn't be broken further
|
||||
if (getEmojiRegex().test(word)) {
|
||||
return [word];
|
||||
}
|
||||
|
||||
satisfiesWordInvariant(word);
|
||||
|
||||
const lines: Array<string> = [];
|
||||
const chars = Array.from(word);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const char of chars) {
|
||||
const _charWidth = charWidth.calculate(char, font);
|
||||
const testLineWidth = currentLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth <= maxWidth) {
|
||||
currentLine = currentLine + char;
|
||||
currentLineWidth = testLineWidth;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
currentLine = char;
|
||||
currentLineWidth = _charWidth;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
|
||||
*/
|
||||
const trimLine = (line: string, font: FontString, maxWidth: number) => {
|
||||
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
|
||||
|
||||
if (!shouldTrimWhitespaces) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// defensively default to `trimeEnd` in case the regex does not match
|
||||
let [, trimmedLine, whitespaces] = line.match(/^(.+?)(\s+)$/) ?? [
|
||||
line,
|
||||
line.trimEnd(),
|
||||
"",
|
||||
];
|
||||
|
||||
let trimmedLineWidth = getLineWidth(trimmedLine, font);
|
||||
|
||||
for (const whitespace of Array.from(whitespaces)) {
|
||||
const _charWidth = charWidth.calculate(whitespace, font);
|
||||
const testLineWidth = trimmedLineWidth + _charWidth;
|
||||
|
||||
if (testLineWidth > maxWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine + whitespace;
|
||||
trimmedLineWidth = testLineWidth;
|
||||
}
|
||||
|
||||
return trimmedLine;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the given string is a single character.
|
||||
*
|
||||
* Handles multi-byte chars (é, 中) and purposefully does not handle multi-codepoint char (👨👩👧👦, 👩🏽🦰).
|
||||
*/
|
||||
const isSingleCharacter = (maybeSingleCharacter: string) => {
|
||||
return (
|
||||
maybeSingleCharacter.codePointAt(0) !== undefined &&
|
||||
maybeSingleCharacter.codePointAt(1) === undefined
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Invariant for the word wrapping algorithm.
|
||||
*/
|
||||
const satisfiesWordInvariant = (word: string) => {
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
if (/\s/.test(word)) {
|
||||
throw new Error("Word should not contain any whitespaces!");
|
||||
}
|
||||
}
|
||||
};
|
1567
packages/element/textWysiwyg.test.tsx
Normal file
1567
packages/element/textWysiwyg.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
732
packages/element/textWysiwyg.tsx
Normal file
732
packages/element/textWysiwyg.tsx
Normal file
|
@ -0,0 +1,732 @@
|
|||
import {
|
||||
actionResetZoom,
|
||||
actionZoomIn,
|
||||
actionZoomOut,
|
||||
} from "../actions/actionCanvas";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
actionIncreaseFontSize,
|
||||
} from "../actions/actionProperties";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
import { CLASSES, POINTER_BUTTON } from "../constants";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import Scene from "../scene/Scene";
|
||||
import {
|
||||
isWritableElement,
|
||||
getFontString,
|
||||
getFontFamilyString,
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
|
||||
import {
|
||||
originalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { bumpVersion, mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
redrawTextBoundingBox,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
computeBoundTextPosition,
|
||||
getBoundTextElement,
|
||||
} from "./textElement";
|
||||
import { getTextWidth } from "./textMeasurements";
|
||||
import { normalizeText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "./types";
|
||||
import type App from "../components/App";
|
||||
import type { AppState } from "../types";
|
||||
|
||||
const getTransform = (
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
appState: AppState,
|
||||
maxWidth: number,
|
||||
maxHeight: number,
|
||||
) => {
|
||||
const { zoom } = appState;
|
||||
const degree = (180 * angle) / Math.PI;
|
||||
let translateX = (width * (zoom.value - 1)) / 2;
|
||||
let translateY = (height * (zoom.value - 1)) / 2;
|
||||
if (width > maxWidth && zoom.value !== 1) {
|
||||
translateX = (maxWidth * (zoom.value - 1)) / 2;
|
||||
}
|
||||
if (height > maxHeight && zoom.value !== 1) {
|
||||
translateY = (maxHeight * (zoom.value - 1)) / 2;
|
||||
}
|
||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||
};
|
||||
|
||||
export const textWysiwyg = ({
|
||||
id,
|
||||
onChange,
|
||||
onSubmit,
|
||||
getViewportCoords,
|
||||
element,
|
||||
canvas,
|
||||
excalidrawContainer,
|
||||
app,
|
||||
autoSelect = true,
|
||||
}: {
|
||||
id: ExcalidrawElement["id"];
|
||||
/**
|
||||
* textWysiwyg only deals with `originalText`
|
||||
*
|
||||
* Note: `text`, which can be wrapped and therefore different from `originalText`,
|
||||
* is derived from `originalText`
|
||||
*/
|
||||
onChange?: (nextOriginalText: string) => void;
|
||||
onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
|
||||
getViewportCoords: (x: number, y: number) => [number, number];
|
||||
element: ExcalidrawTextElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
excalidrawContainer: HTMLDivElement | null;
|
||||
app: App;
|
||||
autoSelect?: boolean;
|
||||
}) => {
|
||||
const textPropertiesUpdated = (
|
||||
updatedTextElement: ExcalidrawTextElement,
|
||||
editable: HTMLTextAreaElement,
|
||||
) => {
|
||||
if (!editable.style.fontFamily || !editable.style.fontSize) {
|
||||
return false;
|
||||
}
|
||||
const currentFont = editable.style.fontFamily.replace(/"/g, "");
|
||||
if (
|
||||
getFontFamilyString({ fontFamily: updatedTextElement.fontFamily }) !==
|
||||
currentFont
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (`${updatedTextElement.fontSize}px` !== editable.style.fontSize) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const updateWysiwygStyle = () => {
|
||||
const appState = app.state;
|
||||
const updatedTextElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElement>(id);
|
||||
|
||||
if (!updatedTextElement) {
|
||||
return;
|
||||
}
|
||||
const { textAlign, verticalAlign } = updatedTextElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
const container = getContainerElement(
|
||||
updatedTextElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
let width = updatedTextElement.width;
|
||||
|
||||
// set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let height = updatedTextElement.height;
|
||||
|
||||
let maxWidth = updatedTextElement.width;
|
||||
let maxHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
const boundTextCoords =
|
||||
LinearElementEditor.getBoundTextElementPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
coordX = boundTextCoords.x;
|
||||
coordY = boundTextCoords.y;
|
||||
}
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
container.height,
|
||||
);
|
||||
} else {
|
||||
originalContainerData = originalContainerCache[container.id];
|
||||
if (!originalContainerData) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
container.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = getBoundTextMaxWidth(container, updatedTextElement);
|
||||
|
||||
maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && height > maxHeight) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
height,
|
||||
container.type,
|
||||
);
|
||||
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
return;
|
||||
} else if (
|
||||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
container.height > originalContainerData.height &&
|
||||
height < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
|
||||
// restore cursor position after value updated so it doesn't
|
||||
// go to the end of text when container auto expanded
|
||||
if (
|
||||
initialSelectionStart === initialSelectionEnd &&
|
||||
initialSelectionEnd !== initialLength
|
||||
) {
|
||||
// get diff between length and selection end and shift
|
||||
// the cursor by "diff" times to position correctly
|
||||
const diff = initialLength - initialSelectionEnd;
|
||||
editable.selectionStart = editable.value.length - diff;
|
||||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
width = Math.min(width, maxWidth);
|
||||
} else {
|
||||
width += 0.5;
|
||||
}
|
||||
|
||||
// add 5% buffer otherwise it causes wysiwyg to jump
|
||||
height *= 1.05;
|
||||
|
||||
const font = getFontString(updatedTextElement);
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
Object.assign(editable.style, {
|
||||
font,
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: updatedTextElement.lineHeight,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transform: getTransform(
|
||||
width,
|
||||
height,
|
||||
getTextElementAngle(updatedTextElement, container),
|
||||
appState,
|
||||
maxWidth,
|
||||
editorMaxHeight,
|
||||
),
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedTextElement.strokeColor,
|
||||
opacity: updatedTextElement.opacity / 100,
|
||||
filter: "var(--theme-filter)",
|
||||
maxHeight: `${editorMaxHeight}px`,
|
||||
});
|
||||
editable.scrollTop = 0;
|
||||
// For some reason updating font attribute doesn't set font family
|
||||
// hence updating font family explicitly for test environment
|
||||
if (isTestEnv()) {
|
||||
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||
}
|
||||
|
||||
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||
}
|
||||
};
|
||||
|
||||
const editable = document.createElement("textarea");
|
||||
|
||||
editable.dir = "auto";
|
||||
editable.tabIndex = 0;
|
||||
editable.dataset.type = "wysiwyg";
|
||||
// prevent line wrapping on Safari
|
||||
editable.wrap = "off";
|
||||
editable.classList.add("excalidraw-wysiwyg");
|
||||
|
||||
let whiteSpace = "pre";
|
||||
let wordBreak = "normal";
|
||||
|
||||
if (isBoundToContainer(element) || !element.autoResize) {
|
||||
whiteSpace = "pre-wrap";
|
||||
wordBreak = "break-word";
|
||||
}
|
||||
Object.assign(editable.style, {
|
||||
position: "absolute",
|
||||
display: "inline-block",
|
||||
minHeight: "1em",
|
||||
backfaceVisibility: "hidden",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
outline: 0,
|
||||
resize: "none",
|
||||
background: "transparent",
|
||||
overflow: "hidden",
|
||||
// must be specified because in dark mode canvas creates a stacking context
|
||||
zIndex: "var(--zIndex-wysiwyg)",
|
||||
wordBreak,
|
||||
// prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
|
||||
whiteSpace,
|
||||
overflowWrap: "break-word",
|
||||
boxSizing: "content-box",
|
||||
});
|
||||
editable.value = element.originalText;
|
||||
updateWysiwygStyle();
|
||||
|
||||
if (onChange) {
|
||||
editable.onpaste = async (event) => {
|
||||
const clipboardData = await parseClipboard(event, true);
|
||||
if (!clipboardData.text) {
|
||||
return;
|
||||
}
|
||||
const data = normalizeText(clipboardData.text);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const container = getContainerElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
const font = getFontString({
|
||||
fontSize: app.state.currentItemFontSize,
|
||||
fontFamily: app.state.currentItemFontFamily,
|
||||
});
|
||||
if (container) {
|
||||
const boundTextElement = getBoundTextElement(
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getBoundTextMaxWidth(container, boundTextElement),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
editable.oninput = () => {
|
||||
const normalized = normalizeText(editable.value);
|
||||
if (editable.value !== normalized) {
|
||||
const selectionStart = editable.selectionStart;
|
||||
editable.value = normalized;
|
||||
// put the cursor at some position close to where it was before
|
||||
// normalization (otherwise it'll end up at the end of the text)
|
||||
editable.selectionStart = selectionStart;
|
||||
editable.selectionEnd = selectionStart;
|
||||
}
|
||||
onChange(editable.value);
|
||||
};
|
||||
}
|
||||
|
||||
editable.onkeydown = (event) => {
|
||||
if (!event.shiftKey && actionZoomIn.keyTest(event)) {
|
||||
event.preventDefault();
|
||||
app.actionManager.executeAction(actionZoomIn);
|
||||
updateWysiwygStyle();
|
||||
} else if (!event.shiftKey && actionZoomOut.keyTest(event)) {
|
||||
event.preventDefault();
|
||||
app.actionManager.executeAction(actionZoomOut);
|
||||
updateWysiwygStyle();
|
||||
} else if (!event.shiftKey && actionResetZoom.keyTest(event)) {
|
||||
event.preventDefault();
|
||||
app.actionManager.executeAction(actionResetZoom);
|
||||
updateWysiwygStyle();
|
||||
} else if (actionDecreaseFontSize.keyTest(event)) {
|
||||
app.actionManager.executeAction(actionDecreaseFontSize);
|
||||
} else if (actionIncreaseFontSize.keyTest(event)) {
|
||||
app.actionManager.executeAction(actionIncreaseFontSize);
|
||||
} else if (event.key === KEYS.ESCAPE) {
|
||||
event.preventDefault();
|
||||
submittedViaKeyboard = true;
|
||||
handleSubmit();
|
||||
} else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
|
||||
event.preventDefault();
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
submittedViaKeyboard = true;
|
||||
handleSubmit();
|
||||
} else if (
|
||||
event.key === KEYS.TAB ||
|
||||
(event[KEYS.CTRL_OR_CMD] &&
|
||||
(event.code === CODES.BRACKET_LEFT ||
|
||||
event.code === CODES.BRACKET_RIGHT))
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (event.isComposing) {
|
||||
return;
|
||||
} else if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
|
||||
outdent();
|
||||
} else {
|
||||
indent();
|
||||
}
|
||||
// We must send an input event to resize the element
|
||||
editable.dispatchEvent(new Event("input"));
|
||||
}
|
||||
};
|
||||
|
||||
const TAB_SIZE = 4;
|
||||
const TAB = " ".repeat(TAB_SIZE);
|
||||
const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`);
|
||||
const indent = () => {
|
||||
const { selectionStart, selectionEnd } = editable;
|
||||
const linesStartIndices = getSelectedLinesStartIndices();
|
||||
|
||||
let value = editable.value;
|
||||
linesStartIndices.forEach((startIndex: number) => {
|
||||
const startValue = value.slice(0, startIndex);
|
||||
const endValue = value.slice(startIndex);
|
||||
|
||||
value = `${startValue}${TAB}${endValue}`;
|
||||
});
|
||||
|
||||
editable.value = value;
|
||||
|
||||
editable.selectionStart = selectionStart + TAB_SIZE;
|
||||
editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length;
|
||||
};
|
||||
|
||||
const outdent = () => {
|
||||
const { selectionStart, selectionEnd } = editable;
|
||||
const linesStartIndices = getSelectedLinesStartIndices();
|
||||
const removedTabs: number[] = [];
|
||||
|
||||
let value = editable.value;
|
||||
linesStartIndices.forEach((startIndex) => {
|
||||
const tabMatch = value
|
||||
.slice(startIndex, startIndex + TAB_SIZE)
|
||||
.match(RE_LEADING_TAB);
|
||||
|
||||
if (tabMatch) {
|
||||
const startValue = value.slice(0, startIndex);
|
||||
const endValue = value.slice(startIndex + tabMatch[0].length);
|
||||
|
||||
// Delete a tab from the line
|
||||
value = `${startValue}${endValue}`;
|
||||
removedTabs.push(startIndex);
|
||||
}
|
||||
});
|
||||
|
||||
editable.value = value;
|
||||
|
||||
if (removedTabs.length) {
|
||||
if (selectionStart > removedTabs[removedTabs.length - 1]) {
|
||||
editable.selectionStart = Math.max(
|
||||
selectionStart - TAB_SIZE,
|
||||
removedTabs[removedTabs.length - 1],
|
||||
);
|
||||
} else {
|
||||
// If the cursor is before the first tab removed, ex:
|
||||
// Line| #1
|
||||
// Line #2
|
||||
// Lin|e #3
|
||||
// we should reset the selectionStart to his initial value.
|
||||
editable.selectionStart = selectionStart;
|
||||
}
|
||||
editable.selectionEnd = Math.max(
|
||||
editable.selectionStart,
|
||||
selectionEnd - TAB_SIZE * removedTabs.length,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns indices of start positions of selected lines, in reverse order
|
||||
*/
|
||||
const getSelectedLinesStartIndices = () => {
|
||||
let { selectionStart, selectionEnd, value } = editable;
|
||||
|
||||
// chars before selectionStart on the same line
|
||||
const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0]
|
||||
.length;
|
||||
// put caret at the start of the line
|
||||
selectionStart = selectionStart - startOffset;
|
||||
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
|
||||
return selected
|
||||
.split("\n")
|
||||
.reduce(
|
||||
(startIndices, line, idx, lines) =>
|
||||
startIndices.concat(
|
||||
idx
|
||||
? // curr line index is prev line's start + prev line's length + \n
|
||||
startIndices[idx - 1] + lines[idx - 1].length + 1
|
||||
: // first selected line
|
||||
selectionStart,
|
||||
),
|
||||
[] as number[],
|
||||
)
|
||||
.reverse();
|
||||
};
|
||||
|
||||
const stopEvent = (event: Event) => {
|
||||
if (event.target instanceof HTMLCanvasElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// using a state variable instead of passing it to the handleSubmit callback
|
||||
// so that we don't need to create separate a callback for event handlers
|
||||
let submittedViaKeyboard = false;
|
||||
const handleSubmit = () => {
|
||||
// prevent double submit
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
isDestroyed = true;
|
||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||
// wysiwyg on update
|
||||
cleanup();
|
||||
const updateElement = Scene.getScene(element)?.getElement(
|
||||
element.id,
|
||||
) as ExcalidrawTextElement;
|
||||
if (!updateElement) {
|
||||
return;
|
||||
}
|
||||
const container = getContainerElement(
|
||||
updateElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (container) {
|
||||
if (editable.value.trim()) {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId || boundTextElementId !== element.id) {
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
type: "text",
|
||||
id: element.id,
|
||||
}),
|
||||
});
|
||||
} else if (isArrowElement(container)) {
|
||||
// updating an arrow label may change bounds, prevent stale cache:
|
||||
bumpVersion(container);
|
||||
}
|
||||
} else {
|
||||
mutateElement(container, {
|
||||
boundElements: container.boundElements?.filter(
|
||||
(ele) =>
|
||||
!isTextElement(
|
||||
ele as ExcalidrawTextElement | ExcalidrawLinearElement,
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
redrawTextBoundingBox(
|
||||
updateElement,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
viaKeyboard: submittedViaKeyboard,
|
||||
nextOriginalText: editable.value,
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
// remove events to ensure they don't late-fire
|
||||
editable.onblur = null;
|
||||
editable.oninput = null;
|
||||
editable.onkeydown = null;
|
||||
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
window.removeEventListener("resize", updateWysiwygStyle);
|
||||
window.removeEventListener("wheel", stopEvent, true);
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("pointerup", bindBlurEvent);
|
||||
window.removeEventListener("blur", handleSubmit);
|
||||
window.removeEventListener("beforeunload", handleSubmit);
|
||||
unbindUpdate();
|
||||
unbindOnScroll();
|
||||
|
||||
editable.remove();
|
||||
};
|
||||
|
||||
const bindBlurEvent = (event?: MouseEvent) => {
|
||||
window.removeEventListener("pointerup", bindBlurEvent);
|
||||
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
|
||||
// trigger the blur on ensuing pointerup.
|
||||
// Also to handle cases such as picking a color which would trigger a blur
|
||||
// in that same tick.
|
||||
const target = event?.target;
|
||||
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isPropertiesTrigger) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const temporarilyDisableSubmit = () => {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||||
// alt-tabbing away
|
||||
window.addEventListener("blur", handleSubmit);
|
||||
};
|
||||
|
||||
// prevent blur when changing properties from the menu
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const target = event?.target;
|
||||
|
||||
// panning canvas
|
||||
if (event.button === POINTER_BUTTON.WHEEL) {
|
||||
// trying to pan by clicking inside text area itself -> handle here
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
event.preventDefault();
|
||||
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
|
||||
}
|
||||
temporarilyDisableSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(
|
||||
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
||||
) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isPropertiesTrigger
|
||||
) {
|
||||
temporarilyDisableSubmit();
|
||||
} else if (
|
||||
event.target instanceof HTMLCanvasElement &&
|
||||
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
||||
// so without introducing crazier hacks, nothing we can do
|
||||
!isTestEnv()
|
||||
) {
|
||||
// On mobile, blur event doesn't seem to always fire correctly,
|
||||
// so we want to also submit on pointerdown outside the wysiwyg.
|
||||
// Done in the next frame to prevent pointerdown from creating a new text
|
||||
// immediately (if tools locked) so that users on mobile have chance
|
||||
// to submit first (to hide virtual keyboard).
|
||||
// Note: revisit if we want to differ this behavior on Desktop
|
||||
requestAnimationFrame(() => {
|
||||
handleSubmit();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// handle updates of textElement properties of editing element
|
||||
const unbindUpdate = app.scene.onUpdate(() => {
|
||||
updateWysiwygStyle();
|
||||
const isPopupOpened = !!document.activeElement?.closest(
|
||||
".properties-content",
|
||||
);
|
||||
if (!isPopupOpened) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const unbindOnScroll = app.onScrollChangeEmitter.on(() => {
|
||||
updateWysiwygStyle();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let isDestroyed = false;
|
||||
|
||||
if (autoSelect) {
|
||||
// select on init (focusing is done separately inside the bindBlurEvent()
|
||||
// because we need it to happen *after* the blur event from `pointerdown`)
|
||||
editable.select();
|
||||
}
|
||||
bindBlurEvent();
|
||||
|
||||
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
|
||||
// is preferred so we catch changes from host, where window may not resize.
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (canvas && "ResizeObserver" in window) {
|
||||
observer = new window.ResizeObserver(() => {
|
||||
updateWysiwygStyle();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
} else {
|
||||
window.addEventListener("resize", updateWysiwygStyle);
|
||||
}
|
||||
|
||||
editable.onpointerdown = (event) => event.stopPropagation();
|
||||
|
||||
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
|
||||
// triggered the wysiwyg
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("pointerdown", onPointerDown, { capture: true });
|
||||
});
|
||||
window.addEventListener("beforeunload", handleSubmit);
|
||||
excalidrawContainer
|
||||
?.querySelector(".excalidraw-textEditorContainer")!
|
||||
.appendChild(editable);
|
||||
};
|
344
packages/element/transformHandles.ts
Normal file
344
packages/element/transformHandles.ts
Normal file
|
@ -0,0 +1,344 @@
|
|||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
} from "../constants";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointerType,
|
||||
} from "./types";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
| "s"
|
||||
| "w"
|
||||
| "e"
|
||||
| "nw"
|
||||
| "ne"
|
||||
| "sw"
|
||||
| "se";
|
||||
|
||||
export type TransformHandleType = TransformHandleDirection | "rotation";
|
||||
|
||||
export type TransformHandle = Bounds;
|
||||
export type TransformHandles = Partial<{
|
||||
[T in TransformHandleType]: TransformHandle;
|
||||
}>;
|
||||
export type MaybeTransformHandleType = TransformHandleType | false;
|
||||
|
||||
const transformHandleSizes: { [k in PointerType]: number } = {
|
||||
mouse: 8,
|
||||
pen: 16,
|
||||
touch: 28,
|
||||
};
|
||||
|
||||
const ROTATION_RESIZE_HANDLE_GAP = 16;
|
||||
|
||||
export const DEFAULT_OMIT_SIDES = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_FRAME = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_LINE_SLASH = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
nw: true,
|
||||
se: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_LINE_BACKSLASH = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
};
|
||||
|
||||
const generateTransformHandle = (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: Radians,
|
||||
): TransformHandle => {
|
||||
const [xx, yy] = pointRotateRads(
|
||||
pointFrom(x + width / 2, y + height / 2),
|
||||
pointFrom(cx, cy),
|
||||
angle,
|
||||
);
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getOmitSidesForDevice = (device: Device) => {
|
||||
if (canResizeFromSides(device)) {
|
||||
return DEFAULT_OMIT_SIDES;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const getTransformHandlesFromCoords = (
|
||||
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
|
||||
angle: Radians,
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||
margin = 4,
|
||||
spacing = DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
): TransformHandles => {
|
||||
const size = transformHandleSizes[pointerType];
|
||||
const handleWidth = size / zoom.value;
|
||||
const handleHeight = size / zoom.value;
|
||||
|
||||
const handleMarginX = size / zoom.value;
|
||||
const handleMarginY = size / zoom.value;
|
||||
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
const dashedLineMargin = margin / zoom.value;
|
||||
const centeringOffset = (size - spacing * 2) / (2 * zoom.value);
|
||||
|
||||
const transformHandles: TransformHandles = {
|
||||
nw: omitSides.nw
|
||||
? undefined
|
||||
: generateTransformHandle(
|
||||
x1 - dashedLineMargin - handleMarginX + centeringOffset,
|
||||
y1 - dashedLineMargin - handleMarginY + centeringOffset,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
),
|
||||
ne: omitSides.ne
|
||||
? undefined
|
||||
: generateTransformHandle(
|
||||
x2 + dashedLineMargin - centeringOffset,
|
||||
y1 - dashedLineMargin - handleMarginY + centeringOffset,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
),
|
||||
sw: omitSides.sw
|
||||
? undefined
|
||||
: generateTransformHandle(
|
||||
x1 - dashedLineMargin - handleMarginX + centeringOffset,
|
||||
y2 + dashedLineMargin - centeringOffset,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
),
|
||||
se: omitSides.se
|
||||
? undefined
|
||||
: generateTransformHandle(
|
||||
x2 + dashedLineMargin - centeringOffset,
|
||||
y2 + dashedLineMargin - centeringOffset,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
),
|
||||
rotation: omitSides.rotation
|
||||
? undefined
|
||||
: generateTransformHandle(
|
||||
x1 + width / 2 - handleWidth / 2,
|
||||
y1 -
|
||||
dashedLineMargin -
|
||||
handleMarginY +
|
||||
centeringOffset -
|
||||
ROTATION_RESIZE_HANDLE_GAP / zoom.value,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
),
|
||||
};
|
||||
|
||||
// We only want to show height handles (all cardinal directions) above a certain size
|
||||
// Note: we render using "mouse" size so we should also use "mouse" size for this check
|
||||
const minimumSizeForEightHandles =
|
||||
(5 * transformHandleSizes.mouse) / zoom.value;
|
||||
if (Math.abs(width) > minimumSizeForEightHandles) {
|
||||
if (!omitSides.n) {
|
||||
transformHandles.n = generateTransformHandle(
|
||||
x1 + width / 2 - handleWidth / 2,
|
||||
y1 - dashedLineMargin - handleMarginY + centeringOffset,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
if (!omitSides.s) {
|
||||
transformHandles.s = generateTransformHandle(
|
||||
x1 + width / 2 - handleWidth / 2,
|
||||
y2 + dashedLineMargin - centeringOffset,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Math.abs(height) > minimumSizeForEightHandles) {
|
||||
if (!omitSides.w) {
|
||||
transformHandles.w = generateTransformHandle(
|
||||
x1 - dashedLineMargin - handleMarginX + centeringOffset,
|
||||
y1 + height / 2 - handleHeight / 2,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
if (!omitSides.e) {
|
||||
transformHandles.e = generateTransformHandle(
|
||||
x2 + dashedLineMargin - centeringOffset,
|
||||
y1 + height / 2 - handleHeight / 2,
|
||||
handleWidth,
|
||||
handleHeight,
|
||||
cx,
|
||||
cy,
|
||||
angle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return transformHandles;
|
||||
};
|
||||
|
||||
export const getTransformHandles = (
|
||||
element: ExcalidrawElement,
|
||||
zoom: Zoom,
|
||||
elementsMap: ElementsMap,
|
||||
pointerType: PointerType = "mouse",
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
|
||||
): TransformHandles => {
|
||||
// so that when locked element is selected (especially when you toggle lock
|
||||
// via keyboard) the locked element is visually distinct, indicating
|
||||
// you can't move/resize
|
||||
if (
|
||||
element.locked ||
|
||||
// Elbow arrows cannot be rotated
|
||||
isElbowArrow(element)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||
if (element.points.length === 2) {
|
||||
// only check the last point because starting point is always (0,0)
|
||||
const [, p1] = element.points;
|
||||
if (p1[0] === 0 || p1[1] === 0) {
|
||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||
} else if (p1[0] > 0 && p1[1] < 0) {
|
||||
omitSides = OMIT_SIDES_FOR_LINE_SLASH;
|
||||
} else if (p1[0] > 0 && p1[1] > 0) {
|
||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||
} else if (p1[0] < 0 && p1[1] > 0) {
|
||||
omitSides = OMIT_SIDES_FOR_LINE_SLASH;
|
||||
} else if (p1[0] < 0 && p1[1] < 0) {
|
||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||
}
|
||||
}
|
||||
} else if (isFrameLikeElement(element)) {
|
||||
omitSides = {
|
||||
...omitSides,
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const margin = isLinearElement(element)
|
||||
? DEFAULT_TRANSFORM_HANDLE_SPACING + 8
|
||||
: isImageElement(element)
|
||||
? 0
|
||||
: DEFAULT_TRANSFORM_HANDLE_SPACING;
|
||||
return getTransformHandlesFromCoords(
|
||||
getElementAbsoluteCoords(element, elementsMap, true),
|
||||
element.angle,
|
||||
zoom,
|
||||
pointerType,
|
||||
omitSides,
|
||||
margin,
|
||||
isImageElement(element) ? 0 : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldShowBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
return true;
|
||||
}
|
||||
const element = elements[0];
|
||||
if (isElbowArrow(element)) {
|
||||
// Elbow arrows cannot be resized as single selected elements
|
||||
return false;
|
||||
}
|
||||
if (!isLinearElement(element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return element.points.length > 2;
|
||||
};
|
67
packages/element/typeChecks.test.ts
Normal file
67
packages/element/typeChecks.test.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { API } from "../tests/helpers/api";
|
||||
|
||||
import { hasBoundTextElement } from "./typeChecks";
|
||||
|
||||
describe("Test TypeChecks", () => {
|
||||
describe("Test hasBoundTextElement", () => {
|
||||
it("should return true for text bindable containers with bound text", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "ellipse",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for text bindable containers without bound text", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
boundElements: [{ type: "arrow", id: "arrow-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return false for non text bindable containers", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
339
packages/element/typeChecks.ts
Normal file
339
packages/element/typeChecks.ts
Normal file
|
@ -0,0 +1,339 @@
|
|||
import { ROUNDNESS } from "../constants";
|
||||
import { assertNever } from "../utils";
|
||||
|
||||
import type { ElementOrToolType } from "../types";
|
||||
import type { MarkNonNullable } from "../utility-types";
|
||||
import type { Bounds } from "./bounds";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
InitializedExcalidrawImageElement,
|
||||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
RoundnessType,
|
||||
ExcalidrawFrameLikeElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawIframeElement,
|
||||
ExcalidrawIframeLikeElement,
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
} from "./types";
|
||||
|
||||
export const isInitializedImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is InitializedExcalidrawImageElement => {
|
||||
return !!element && element.type === "image" && !!element.fileId;
|
||||
};
|
||||
|
||||
export const isImageElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawImageElement => {
|
||||
return !!element && element.type === "image";
|
||||
};
|
||||
|
||||
export const isEmbeddableElement = (
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
): element is ExcalidrawEmbeddableElement => {
|
||||
return !!element && element.type === "embeddable";
|
||||
};
|
||||
|
||||
export const isIframeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawIframeElement => {
|
||||
return !!element && element.type === "iframe";
|
||||
};
|
||||
|
||||
export const isIframeLikeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawIframeLikeElement => {
|
||||
return (
|
||||
!!element && (element.type === "iframe" || element.type === "embeddable")
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElement => {
|
||||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameElement => {
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isMagicFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawMagicFrameElement => {
|
||||
return element != null && element.type === "magicframe";
|
||||
};
|
||||
|
||||
export const isFrameLikeElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameLikeElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "frame" || element.type === "magicframe")
|
||||
);
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
return element != null && isFreeDrawElementType(element.type);
|
||||
};
|
||||
|
||||
export const isFreeDrawElementType = (
|
||||
elementType: ExcalidrawElementType,
|
||||
): boolean => {
|
||||
return elementType === "freedraw";
|
||||
};
|
||||
|
||||
export const isLinearElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
return element != null && element.type === "arrow";
|
||||
};
|
||||
|
||||
export const isElbowArrow = (
|
||||
element?: ExcalidrawElement,
|
||||
): element is ExcalidrawElbowArrowElement => {
|
||||
return isArrowElement(element) && element.elbowed;
|
||||
};
|
||||
|
||||
export const isLinearElementType = (
|
||||
elementType: ElementOrToolType,
|
||||
): boolean => {
|
||||
return (
|
||||
elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
isBindingElementType(element.type)
|
||||
);
|
||||
};
|
||||
|
||||
export const isBindingElementType = (
|
||||
elementType: ElementOrToolType,
|
||||
): boolean => {
|
||||
return elementType === "arrow";
|
||||
};
|
||||
|
||||
export const isBindableElement = (
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
export const isRectanguloidElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "image" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: Remove this when proper distance calculation is introduced
|
||||
// @see binding.ts:distanceToBindableElement()
|
||||
export const isRectangularElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawBindableElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "image" ||
|
||||
element.type === "text" ||
|
||||
element.type === "iframe" ||
|
||||
element.type === "embeddable" ||
|
||||
element.type === "frame" ||
|
||||
element.type === "magicframe" ||
|
||||
element.type === "freedraw")
|
||||
);
|
||||
};
|
||||
|
||||
export const isTextBindableContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
includeLocked = true,
|
||||
): element is ExcalidrawTextContainer => {
|
||||
return (
|
||||
element != null &&
|
||||
(!element.locked || includeLocked === true) &&
|
||||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
isArrowElement(element))
|
||||
);
|
||||
};
|
||||
|
||||
export const isExcalidrawElement = (
|
||||
element: any,
|
||||
): element is ExcalidrawElement => {
|
||||
const type: ExcalidrawElementType | undefined = element?.type;
|
||||
if (!type) {
|
||||
return false;
|
||||
}
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "rectangle":
|
||||
case "iframe":
|
||||
case "embeddable":
|
||||
case "ellipse":
|
||||
case "arrow":
|
||||
case "freedraw":
|
||||
case "line":
|
||||
case "frame":
|
||||
case "magicframe":
|
||||
case "image":
|
||||
case "selection": {
|
||||
return true;
|
||||
}
|
||||
default: {
|
||||
assertNever(type, null);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isFlowchartNodeElement = (
|
||||
element: ExcalidrawElement,
|
||||
): element is ExcalidrawFlowchartNodeElement => {
|
||||
return (
|
||||
element.type === "rectangle" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "diamond"
|
||||
);
|
||||
};
|
||||
|
||||
export const hasBoundTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is MarkNonNullable<ExcalidrawBindableElement, "boundElements"> => {
|
||||
return (
|
||||
isTextBindableContainer(element) &&
|
||||
!!element.boundElements?.some(({ type }) => type === "text")
|
||||
);
|
||||
};
|
||||
|
||||
export const isBoundToContainer = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElementWithContainer => {
|
||||
return (
|
||||
element !== null &&
|
||||
"containerId" in element &&
|
||||
element.containerId !== null &&
|
||||
isTextElement(element)
|
||||
);
|
||||
};
|
||||
|
||||
export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "rectangle" ||
|
||||
type === "embeddable" ||
|
||||
type === "iframe" ||
|
||||
type === "image";
|
||||
|
||||
export const isUsingProportionalRadius = (type: string) =>
|
||||
type === "line" || type === "arrow" || type === "diamond";
|
||||
|
||||
export const canApplyRoundnessTypeToElement = (
|
||||
roundnessType: RoundnessType,
|
||||
element: ExcalidrawElement,
|
||||
) => {
|
||||
if (
|
||||
(roundnessType === ROUNDNESS.ADAPTIVE_RADIUS ||
|
||||
// if legacy roundness, it can be applied to elements that currently
|
||||
// use adaptive radius
|
||||
roundnessType === ROUNDNESS.LEGACY) &&
|
||||
isUsingAdaptiveRadius(element.type)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
roundnessType === ROUNDNESS.PROPORTIONAL_RADIUS &&
|
||||
isUsingProportionalRadius(element.type)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getDefaultRoundnessTypeForElement = (
|
||||
element: ExcalidrawElement,
|
||||
) => {
|
||||
if (isUsingProportionalRadius(element.type)) {
|
||||
return {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
if (isUsingAdaptiveRadius(element.type)) {
|
||||
return {
|
||||
type: ROUNDNESS.ADAPTIVE_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
413
packages/element/types.ts
Normal file
413
packages/element/types.ts
Normal file
|
@ -0,0 +1,413 @@
|
|||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
TEXT_ALIGN,
|
||||
THEME,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type {
|
||||
MakeBrand,
|
||||
MarkNonNullable,
|
||||
Merge,
|
||||
ValueOf,
|
||||
} from "../utility-types";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
|
||||
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
|
||||
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
|
||||
export type Theme = typeof THEME[keyof typeof THEME];
|
||||
export type FontString = string & { _brand: "fontString" };
|
||||
export type GroupId = string;
|
||||
export type PointerType = "mouse" | "pen" | "touch";
|
||||
export type StrokeRoundness = "round" | "sharp";
|
||||
export type RoundnessType = ValueOf<typeof ROUNDNESS>;
|
||||
export type StrokeStyle = "solid" | "dashed" | "dotted";
|
||||
export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
|
||||
|
||||
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
|
||||
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
|
||||
export type FractionalIndex = string & { _brand: "franctionalIndex" };
|
||||
|
||||
export type BoundElement = Readonly<{
|
||||
id: ExcalidrawLinearElement["id"];
|
||||
type: "arrow" | "text";
|
||||
}>;
|
||||
|
||||
type _ExcalidrawElementBase = Readonly<{
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
strokeColor: string;
|
||||
backgroundColor: string;
|
||||
fillStyle: FillStyle;
|
||||
strokeWidth: number;
|
||||
strokeStyle: StrokeStyle;
|
||||
roundness: null | { type: RoundnessType; value?: number };
|
||||
roughness: number;
|
||||
opacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: Radians;
|
||||
/** Random integer used to seed shape generation so that the roughjs shape
|
||||
doesn't differ across renders. */
|
||||
seed: number;
|
||||
/** Integer that is sequentially incremented on each change. Used to reconcile
|
||||
elements during collaboration or when saving to server. */
|
||||
version: number;
|
||||
/** Random integer that is regenerated on each change.
|
||||
Used for deterministic reconciliation of updates during collaboration,
|
||||
in case the versions (see above) are identical. */
|
||||
versionNonce: number;
|
||||
/** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
|
||||
Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
|
||||
Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
|
||||
Could be null, i.e. for new elements which were not yet assigned to the scene. */
|
||||
index: FractionalIndex | null;
|
||||
isDeleted: boolean;
|
||||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
frameId: string | null;
|
||||
/** other elements that are bound to this element */
|
||||
boundElements: readonly BoundElement[] | null;
|
||||
/** epoch (ms) timestamp of last element update */
|
||||
updated: number;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
customData?: Record<string, any>;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
type: "selection";
|
||||
};
|
||||
|
||||
export type ExcalidrawRectangleElement = _ExcalidrawElementBase & {
|
||||
type: "rectangle";
|
||||
};
|
||||
|
||||
export type ExcalidrawDiamondElement = _ExcalidrawElementBase & {
|
||||
type: "diamond";
|
||||
};
|
||||
|
||||
export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
||||
type: "ellipse";
|
||||
};
|
||||
|
||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "embeddable";
|
||||
}>;
|
||||
|
||||
export type MagicGenerationData =
|
||||
| {
|
||||
status: "pending";
|
||||
}
|
||||
| { status: "done"; html: string }
|
||||
| {
|
||||
status: "error";
|
||||
message?: string;
|
||||
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||
};
|
||||
|
||||
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "iframe";
|
||||
// TODO move later to AI-specific frame
|
||||
customData?: { generationData?: MagicGenerationData };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawIframeLikeElement =
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type IframeData =
|
||||
| {
|
||||
intrinsicSize: { w: number; h: number };
|
||||
error?: Error;
|
||||
sandbox?: { allowSameOrigin?: boolean };
|
||||
} & (
|
||||
| { type: "video" | "generic"; link: string }
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
);
|
||||
|
||||
export type ImageCrop = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "image";
|
||||
fileId: FileId | null;
|
||||
/** whether respective file is persisted */
|
||||
status: "pending" | "saved" | "error";
|
||||
/** X and Y scale factors <-1, 1>, used for image axis flipping */
|
||||
scale: [number, number];
|
||||
/** whether an element is cropped */
|
||||
crop: ImageCrop | null;
|
||||
}>;
|
||||
|
||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
|
||||
ExcalidrawImageElement,
|
||||
"fileId"
|
||||
>;
|
||||
|
||||
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
type: "frame";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
|
||||
type: "magicframe";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
export type ExcalidrawFrameLikeElement =
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawMagicFrameElement;
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawFlowchartNodeElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
export type ExcalidrawRectanguloidElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawIframeLikeElement
|
||||
| ExcalidrawFrameLikeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
/**
|
||||
* ExcalidrawElement should be JSON serializable and (eventually) contain
|
||||
* no computed data. The list of all ExcalidrawElements should be shareable
|
||||
* between peers and contain no state local to the peer.
|
||||
*/
|
||||
export type ExcalidrawElement =
|
||||
| ExcalidrawGenericElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawArrowElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawMagicFrameElement
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement;
|
||||
|
||||
export type ExcalidrawNonSelectionElement = Exclude<
|
||||
ExcalidrawElement,
|
||||
ExcalidrawSelectionElement
|
||||
>;
|
||||
|
||||
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
|
||||
index: FractionalIndex;
|
||||
};
|
||||
|
||||
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
};
|
||||
|
||||
export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
|
||||
|
||||
export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "text";
|
||||
fontSize: number;
|
||||
fontFamily: FontFamilyValues;
|
||||
text: string;
|
||||
textAlign: TextAlign;
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* If `true` the width will fit the text. If `false`, the text will
|
||||
* wrap to fit the width.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
autoResize: boolean;
|
||||
/**
|
||||
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
*/
|
||||
lineHeight: number & { _brand: "unitlessLineHeight" };
|
||||
}>;
|
||||
|
||||
export type ExcalidrawBindableElement =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawIframeElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement
|
||||
| ExcalidrawMagicFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawArrowElement;
|
||||
|
||||
export type ExcalidrawTextElementWithContainer = {
|
||||
containerId: ExcalidrawTextContainer["id"];
|
||||
} & ExcalidrawTextElement;
|
||||
|
||||
export type FixedPoint = [number, number];
|
||||
|
||||
export type PointBinding = {
|
||||
elementId: ExcalidrawBindableElement["id"];
|
||||
focus: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
export type FixedPointBinding = Merge<
|
||||
PointBinding,
|
||||
{
|
||||
// Represents the fixed point binding information in form of a vertical and
|
||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||
// gives the user selected fixed point by multiplying the bound element width
|
||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||
// bound element-local point coordinate.
|
||||
fixedPoint: FixedPoint;
|
||||
}
|
||||
>;
|
||||
|
||||
export type 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";
|
||||
|
||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "line" | "arrow";
|
||||
points: readonly LocalPoint[];
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
startBinding: PointBinding | null;
|
||||
endBinding: PointBinding | null;
|
||||
startArrowhead: Arrowhead | null;
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "arrow";
|
||||
elbowed: boolean;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawElbowArrowElement = Merge<
|
||||
ExcalidrawArrowElement,
|
||||
{
|
||||
elbowed: true;
|
||||
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
|
||||
* the data from the points array. It allows creating the expected arrow
|
||||
* path when the arrow with fixed segments is bound on a horizontal side and
|
||||
* moved to a vertical and vica versa.
|
||||
*/
|
||||
startIsSpecial: boolean | null;
|
||||
/**
|
||||
* Marks that the 3rd point backwards from the end should be used as the 2nd
|
||||
* point of the arrow in order to temporarily hide the last segment of the
|
||||
* arrow without losing the data from the points array. It allows creating
|
||||
* the expected arrow path when the arrow with fixed segments is bound on a
|
||||
* horizontal side and moved to a vertical and vica versa.
|
||||
*/
|
||||
endIsSpecial: boolean | null;
|
||||
}
|
||||
>;
|
||||
|
||||
export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "freedraw";
|
||||
points: readonly LocalPoint[];
|
||||
pressures: readonly number[];
|
||||
simulatePressure: boolean;
|
||||
lastCommittedPoint: LocalPoint | null;
|
||||
}>;
|
||||
|
||||
export type FileId = string & { _brand: "FileId" };
|
||||
|
||||
export type ExcalidrawElementType = ExcalidrawElement["type"];
|
||||
|
||||
/**
|
||||
* Map of excalidraw elements.
|
||||
* Unspecified whether deleted or non-deleted.
|
||||
* Can be a subset of Scene elements.
|
||||
*/
|
||||
export type ElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement>;
|
||||
|
||||
/**
|
||||
* Map of non-deleted elements.
|
||||
* Can be a subset of Scene elements.
|
||||
*/
|
||||
export type NonDeletedElementsMap = Map<
|
||||
ExcalidrawElement["id"],
|
||||
NonDeletedExcalidrawElement
|
||||
> &
|
||||
MakeBrand<"NonDeletedElementsMap">;
|
||||
|
||||
/**
|
||||
* Map of all excalidraw Scene elements, including deleted.
|
||||
* Not a subset. Use this type when you need access to current Scene elements.
|
||||
*/
|
||||
export type SceneElementsMap = Map<
|
||||
ExcalidrawElement["id"],
|
||||
Ordered<ExcalidrawElement>
|
||||
> &
|
||||
MakeBrand<"SceneElementsMap">;
|
||||
|
||||
/**
|
||||
* Map of all non-deleted Scene elements.
|
||||
* Not a subset. Use this type when you need access to current Scene elements.
|
||||
*/
|
||||
export type NonDeletedSceneElementsMap = Map<
|
||||
ExcalidrawElement["id"],
|
||||
Ordered<NonDeletedExcalidrawElement>
|
||||
> &
|
||||
MakeBrand<"NonDeletedSceneElementsMap">;
|
||||
|
||||
export type ElementsMapOrArray =
|
||||
| readonly ExcalidrawElement[]
|
||||
| Readonly<ElementsMap>;
|
359
packages/element/utils.ts
Normal file
359
packages/element/utils.ts
Normal file
|
@ -0,0 +1,359 @@
|
|||
import {
|
||||
curve,
|
||||
lineSegment,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
rectangle,
|
||||
vectorFromPoint,
|
||||
vectorNormalize,
|
||||
vectorScale,
|
||||
type GlobalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import type { Curve, LineSegment } from "@excalidraw/math";
|
||||
|
||||
import { getCornerRadius } from "../shapes";
|
||||
|
||||
import { getDiamondPoints } from ".";
|
||||
|
||||
import type {
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawRectanguloidElement,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Get the building components of a rectanguloid element in the form of
|
||||
* line segments and curves.
|
||||
*
|
||||
* @param element Target rectanguloid element
|
||||
* @param offset Optional offset to expand the rectanguloid shape
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructRectanguloidElement(
|
||||
element: ExcalidrawRectanguloidElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const roundness = getCornerRadius(
|
||||
Math.min(element.width, element.height),
|
||||
element,
|
||||
);
|
||||
|
||||
if (roundness <= 0) {
|
||||
const r = rectangle(
|
||||
pointFrom(element.x - offset, element.y - offset),
|
||||
pointFrom(
|
||||
element.x + element.width + offset,
|
||||
element.y + element.height + offset,
|
||||
),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
const sides = [top, right, bottom, left];
|
||||
|
||||
return [sides, []];
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const r = rectangle(
|
||||
pointFrom(element.x, element.y),
|
||||
pointFrom(element.x + element.width, element.y + element.height),
|
||||
);
|
||||
|
||||
const top = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
|
||||
);
|
||||
const right = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
|
||||
pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
|
||||
);
|
||||
const bottom = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
|
||||
pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
|
||||
);
|
||||
const left = lineSegment<GlobalPoint>(
|
||||
pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
|
||||
pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
|
||||
);
|
||||
|
||||
const offsets = [
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), // TOP LEFT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
|
||||
),
|
||||
offset,
|
||||
), //TOP RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM RIGHT
|
||||
vectorScale(
|
||||
vectorNormalize(
|
||||
vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
|
||||
),
|
||||
offset,
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const corners = [
|
||||
curve(
|
||||
pointFromVector(offsets[0], left[1]),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
|
||||
left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
|
||||
top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], top[0]),
|
||||
), // TOP LEFT
|
||||
curve(
|
||||
pointFromVector(offsets[1], top[1]),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
|
||||
top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
|
||||
right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], right[0]),
|
||||
), // TOP RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[2], right[1]),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
|
||||
right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
|
||||
bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], bottom[1]),
|
||||
), // BOTTOM RIGHT
|
||||
curve(
|
||||
pointFromVector(offsets[3], bottom[0]),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
|
||||
bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
|
||||
left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], left[0]),
|
||||
), // BOTTOM LEFT
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the building components of a diamond element in the form of
|
||||
* line segments and curves as a tuple, in this order.
|
||||
*
|
||||
* @param element The element to deconstruct
|
||||
* @param offset An optional offset
|
||||
* @returns Tuple of line segments (0) and curves (1)
|
||||
*/
|
||||
export function deconstructDiamondElement(
|
||||
element: ExcalidrawDiamondElement,
|
||||
offset: number = 0,
|
||||
): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
|
||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||
getDiamondPoints(element);
|
||||
const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
|
||||
const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
|
||||
|
||||
if (element.roundness?.type == null) {
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY - offset),
|
||||
pointFrom(element.x + rightX + offset, element.y + rightY),
|
||||
pointFrom(element.x + bottomX, element.y + bottomY + offset),
|
||||
pointFrom(element.x + leftX - offset, element.y + leftY),
|
||||
];
|
||||
|
||||
// Create the line segment parts of the diamond
|
||||
// NOTE: Horizontal and vertical seems to be flipped here
|
||||
const topRight = lineSegment<GlobalPoint>(
|
||||
pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
|
||||
pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
|
||||
);
|
||||
const bottomRight = lineSegment<GlobalPoint>(
|
||||
pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
|
||||
pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
|
||||
);
|
||||
const bottomLeft = lineSegment<GlobalPoint>(
|
||||
pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
|
||||
pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
|
||||
);
|
||||
const topLeft = lineSegment<GlobalPoint>(
|
||||
pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
|
||||
pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
|
||||
);
|
||||
|
||||
return [[topRight, bottomRight, bottomLeft, topLeft], []];
|
||||
}
|
||||
|
||||
const center = pointFrom<GlobalPoint>(
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
);
|
||||
|
||||
const [top, right, bottom, left]: GlobalPoint[] = [
|
||||
pointFrom(element.x + topX, element.y + topY),
|
||||
pointFrom(element.x + rightX, element.y + rightY),
|
||||
pointFrom(element.x + bottomX, element.y + bottomY),
|
||||
pointFrom(element.x + leftX, element.y + leftY),
|
||||
];
|
||||
|
||||
const offsets = [
|
||||
vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
|
||||
vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
|
||||
vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
|
||||
];
|
||||
|
||||
const corners = [
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(offsets[0], right),
|
||||
pointFromVector(
|
||||
offsets[0],
|
||||
pointFrom<GlobalPoint>(
|
||||
right[0] - verticalRadius,
|
||||
right[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // RIGHT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] + verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(offsets[1], bottom),
|
||||
pointFromVector(
|
||||
offsets[1],
|
||||
pointFrom<GlobalPoint>(
|
||||
bottom[0] - verticalRadius,
|
||||
bottom[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // BOTTOM
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(offsets[2], left),
|
||||
pointFromVector(
|
||||
offsets[2],
|
||||
pointFrom<GlobalPoint>(
|
||||
left[0] + verticalRadius,
|
||||
left[1] - horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // LEFT
|
||||
curve(
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] - verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(offsets[3], top),
|
||||
pointFromVector(
|
||||
offsets[3],
|
||||
pointFrom<GlobalPoint>(
|
||||
top[0] + verticalRadius,
|
||||
top[1] + horizontalRadius,
|
||||
),
|
||||
),
|
||||
), // TOP
|
||||
];
|
||||
|
||||
const sides = [
|
||||
lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
|
||||
lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
|
||||
lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
|
||||
lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
|
||||
];
|
||||
|
||||
return [sides, corners];
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue