(
fixedPoint: T,
-): T extends null ? null : FixedPoint => {
+): FixedPoint => {
+ if (!isFixedPoint(fixedPoint)) {
+ return [0.5001, 0.5001];
+ }
+
+ const EPSILON = 0.0001;
+
// Do not allow a precise 0.5 for fixed point ratio
// to avoid jumping arrow heading due to floating point imprecision
if (
- fixedPoint &&
- (Math.abs(fixedPoint[0] - 0.5) < 0.0001 ||
- Math.abs(fixedPoint[1] - 0.5) < 0.0001)
+ Math.abs(fixedPoint[0] - 0.5) < EPSILON ||
+ Math.abs(fixedPoint[1] - 0.5) < EPSILON
) {
return fixedPoint.map((ratio) =>
- Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio,
- ) as T extends null ? null : FixedPoint;
+ Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio,
+ ) as FixedPoint;
}
- return fixedPoint as any as T extends null ? null : FixedPoint;
+
+ return fixedPoint;
};
type Side =
diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts
index 0daa80f15d..a072b81a90 100644
--- a/packages/element/src/bounds.ts
+++ b/packages/element/src/bounds.ts
@@ -680,8 +680,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
-export const getBoundsFromPoints = (
- points: ExcalidrawFreeDrawElement["points"],
+export const getBoundsFromPoints = (
+ points: readonly P[],
+ padding: number = 0,
): Bounds => {
let minX = Infinity;
let minY = Infinity;
@@ -695,7 +696,7 @@ export const getBoundsFromPoints = (
maxY = Math.max(maxY, y);
}
- return [minX, minY, maxX, maxY];
+ return [minX - padding, minY - padding, maxX + padding, maxY + padding];
};
const getFreeDrawElementAbsoluteCoords = (
@@ -709,6 +710,9 @@ const getFreeDrawElementAbsoluteCoords = (
return [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2];
};
+const CARDINALITY_MARKER_SIZE = 20;
+const CROWFOOT_ARROWHEAD_SIZE = 15;
+
/** @returns number in pixels */
export const getArrowheadSize = (arrowhead: Arrowhead): number => {
switch (arrowhead) {
@@ -717,10 +721,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
- case "crowfoot_many":
- case "crowfoot_one":
- case "crowfoot_one_or_many":
- return 20;
+ case "cardinality_many":
+ case "cardinality_one_or_many":
+ case "cardinality_zero_or_many":
+ return CROWFOOT_ARROWHEAD_SIZE;
+ case "cardinality_one":
+ case "cardinality_exactly_one":
+ case "cardinality_zero_or_one":
+ return CARDINALITY_MARKER_SIZE;
default:
return 15;
}
@@ -743,7 +751,12 @@ export const getArrowheadPoints = (
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
+ offsetMultiplier = 0,
) => {
+ if (arrowhead === null) {
+ return null;
+ }
+
if (shape.length < 1) {
return null;
}
@@ -824,29 +837,30 @@ export const getArrowheadPoints = (
const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier);
- const xs = x2 - nx * minSize;
- const ys = y2 - ny * minSize;
+ const tx = x2 - nx * minSize * offsetMultiplier;
+ const ty = y2 - ny * minSize * offsetMultiplier;
+ const xs = tx - nx * minSize;
+ const ys = ty - ny * minSize;
- if (
- arrowhead === "dot" ||
- arrowhead === "circle" ||
- arrowhead === "circle_outline"
- ) {
- const diameter = Math.hypot(ys - y2, xs - x2) + element.strokeWidth - 2;
- return [x2, y2, diameter];
+ if (arrowhead === "circle" || arrowhead === "circle_outline") {
+ const diameter = Math.hypot(ys - ty, xs - tx) + element.strokeWidth - 2;
+ return [tx, ty, diameter];
}
const angle = getArrowheadAngle(arrowhead);
- if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
+ if (
+ arrowhead === "cardinality_many" ||
+ arrowhead === "cardinality_one_or_many"
+ ) {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
pointFrom(xs, ys),
degreesToRadians(angle),
);
@@ -856,12 +870,12 @@ export const getArrowheadPoints = (
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
((-angle * Math.PI) / 180) as Radians,
);
const [x4, y4] = pointRotateRads(
pointFrom(xs, ys),
- pointFrom(x2, y2),
+ pointFrom(tx, ty),
degreesToRadians(angle),
);
@@ -874,9 +888,9 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
- pointFrom(x2 + minSize * 2, y2),
- pointFrom(x2, y2),
- Math.atan2(py - y2, px - x2) as Radians,
+ pointFrom(tx + minSize * 2, ty),
+ pointFrom(tx, ty),
+ Math.atan2(py - ty, px - tx) as Radians,
);
} else {
const [px, py] =
@@ -885,16 +899,16 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
- pointFrom(x2 - minSize * 2, y2),
- pointFrom(x2, y2),
- Math.atan2(y2 - py, x2 - px) as Radians,
+ pointFrom(tx - minSize * 2, ty),
+ pointFrom(tx, ty),
+ Math.atan2(ty - py, tx - px) as Radians,
);
}
- return [x2, y2, x3, y3, ox, oy, x4, y4];
+ return [tx, ty, x3, y3, ox, oy, x4, y4];
}
- return [x2, y2, x3, y3, x4, y4];
+ return [tx, ty, x3, y3, x4, y4];
};
// TODO reuse shape.ts
@@ -1248,6 +1262,17 @@ export const pointInsideBounds =
(
): boolean =>
p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
+// TODO make pointInsideBounds inclusive and remove this function once we
+// test nothing is breaking
+export const pointInsideBoundsInclusive =
(
+ p: P,
+ bounds: Bounds,
+): boolean =>
+ p[0] >= bounds[0] &&
+ p[0] <= bounds[2] &&
+ p[1] >= bounds[1] &&
+ p[1] <= bounds[3];
+
export const doBoundsIntersect = (
bounds1: Bounds | null,
bounds2: Bounds | null,
@@ -1262,13 +1287,21 @@ export const doBoundsIntersect = (
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
};
+export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
+ [
+ pointFrom(innerBounds[0], innerBounds[1]),
+ pointFrom(innerBounds[0], innerBounds[3]),
+ pointFrom(innerBounds[2], innerBounds[1]),
+ pointFrom(innerBounds[2], innerBounds[3]),
+ ].every((point) => pointInsideBoundsInclusive(point, outerBounds));
+
export const elementCenterPoint = (
element: ExcalidrawElement,
elementsMap: ElementsMap,
xOffset: number = 0,
yOffset: number = 0,
) => {
- if (isLinearElement(element)) {
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y] = pointFrom((x1 + x2) / 2, (y1 + y2) / 2);
diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts
index 9605b3197d..1f2372ec31 100644
--- a/packages/element/src/collision.ts
+++ b/packages/element/src/collision.ts
@@ -61,6 +61,8 @@ import { distanceToElement } from "./distance";
import { getBindingGap } from "./binding";
+import { hasBackground } from "./comparisons";
+
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -83,7 +85,7 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
}
const isDraggableFromInside =
- !isTransparent(element.backgroundColor) ||
+ (hasBackground(element.type) && !isTransparent(element.backgroundColor)) ||
hasBoundTextElement(element) ||
isIframeLikeElement(element) ||
isTextElement(element);
@@ -154,14 +156,11 @@ export const hitElementItself = ({
// Hit test against the extended, rotated bounding box of the element first
const bounds = getElementBounds(element, elementsMap, true);
- const hitBounds = isPointWithinBounds(
- pointFrom(bounds[0] - threshold, bounds[1] - threshold),
- pointRotateRads(
- point,
- getCenterForBounds(bounds),
- -element.angle as Radians,
- ),
- pointFrom(bounds[2] + threshold, bounds[3] + threshold),
+ const hitBounds = isPointInRotatedBounds(
+ point,
+ bounds,
+ element.angle,
+ threshold,
);
// PERF: Bail out early if the point is not even in the
@@ -192,18 +191,32 @@ export const hitElementItself = ({
return result;
};
+const isPointInRotatedBounds = (
+ point: GlobalPoint,
+ bounds: Bounds,
+ angle: Radians,
+ tolerance = 0,
+) => {
+ const adjustedPoint =
+ angle === 0
+ ? point
+ : pointRotateRads(point, getCenterForBounds(bounds), -angle as Radians);
+
+ return isPointWithinBounds(
+ pointFrom(bounds[0] - tolerance, bounds[1] - tolerance),
+ adjustedPoint,
+ pointFrom(bounds[2] + tolerance, bounds[3] + tolerance),
+ );
+};
+
export const hitElementBoundingBox = (
point: GlobalPoint,
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), point, pointFrom(x2, y2));
+ const bounds = getElementBounds(element, elementsMap, true);
+ return isPointInRotatedBounds(point, bounds, element.angle, tolerance);
};
export const hitElementBoundingBoxOnly = (
@@ -313,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
) {
candidateElements.push(element);
- if (!isTransparent(element.backgroundColor)) {
+ if (
+ hasBackground(element.type) &&
+ !isTransparent(element.backgroundColor)
+ ) {
break;
}
}
@@ -465,7 +481,12 @@ export const intersectElementWithLineSegment = (
case "line":
case "freedraw":
case "arrow":
- return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
+ return intersectLinearOrFreeDrawWithLineSegment(
+ element,
+ line,
+ elementsMap,
+ onlyFirst,
+ );
}
};
@@ -532,11 +553,15 @@ const lineIntersections = (
const intersectLinearOrFreeDrawWithLineSegment = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
segment: LineSegment,
+ elementsMap: ElementsMap,
onlyFirst = false,
): GlobalPoint[] => {
// NOTE: This is the only one which return the decomposed elements
// rotated! This is due to taking advantage of roughjs definitions.
- const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
+ const [lines, curves] = deconstructLinearOrFreeDrawElement(
+ element,
+ elementsMap,
+ );
const intersections: GlobalPoint[] = [];
for (const l of lines) {
@@ -564,7 +589,9 @@ const intersectLinearOrFreeDrawWithLineSegment = (
continue;
}
- const hits = curveIntersectLineSegment(c, segment);
+ const hits = curveIntersectLineSegment(c, segment, {
+ iterLimit: 10,
+ });
if (hits.length > 0) {
intersections.push(...hits);
diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts
index 4766ac9eef..c94652a1aa 100644
--- a/packages/element/src/distance.ts
+++ b/packages/element/src/distance.ts
@@ -48,7 +48,7 @@ export const distanceToElement = (
case "line":
case "arrow":
case "freedraw":
- return distanceToLinearOrFreeDraElement(element, p);
+ return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -133,9 +133,13 @@ const distanceToEllipseElement = (
const distanceToLinearOrFreeDraElement = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+ elementsMap: ElementsMap,
p: GlobalPoint,
) => {
- const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
+ const [lines, curves] = deconstructLinearOrFreeDrawElement(
+ element,
+ elementsMap,
+ );
return Math.min(
...lines.map((s) => distanceToLineSegment(p, s)),
...curves.map((a) => curvePointDistance(a, p)),
diff --git a/packages/element/src/duplicate.ts b/packages/element/src/duplicate.ts
index c2cee4c089..24135c0879 100644
--- a/packages/element/src/duplicate.ts
+++ b/packages/element/src/duplicate.ts
@@ -111,6 +111,9 @@ export const duplicateElements = (
* user interaction.
*/
type: "everything";
+ // TODO remove/review this once we add frame children order migration
+ // and invariant checks
+ preserveFrameChildrenOrder?: boolean;
}
| {
/**
@@ -170,6 +173,8 @@ export const duplicateElements = (
opts.type === "in-place"
? opts.idsOfElementsToDuplicate
: new Map(elements.map((el) => [el.id, el]));
+ const preserveFrameChildrenOrder =
+ opts.type === "everything" && opts.preserveFrameChildrenOrder;
// For sanity
if (opts.type === "in-place") {
@@ -250,6 +255,9 @@ export const duplicateElements = (
elementsWithDuplicates.splice(index + 1, 0, ...castArray(elements));
};
+ // main
+ // ---------------------------------------------------------------------------
+
const frameIdsToDuplicate = new Set(
elements
.filter(
@@ -274,7 +282,7 @@ export const duplicateElements = (
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
- isFrameLikeElement(element)
+ isFrameLikeElement(element) && !preserveFrameChildrenOrder
? [...getFrameChildren(elements, element.id), element]
: [element],
);
@@ -290,13 +298,25 @@ export const duplicateElements = (
// frame duplication
// -------------------------------------------------------------------------
- if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
+ if (
+ !preserveFrameChildrenOrder &&
+ element.frameId &&
+ frameIdsToDuplicate.has(element.frameId)
+ ) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
+ if (preserveFrameChildrenOrder) {
+ insertBeforeOrAfterIndex(
+ findLastIndex(elementsWithDuplicates, (el) => el.id === frameId),
+ copyElements(element),
+ );
+ continue;
+ }
+
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithDuplicates, (el) => {
diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts
index 63b3b7926d..024b846b12 100644
--- a/packages/element/src/elbowArrow.ts
+++ b/packages/element/src/elbowArrow.ts
@@ -915,6 +915,8 @@ export const updateElbowArrowPoints = (
},
options?: {
isDragging?: boolean;
+ isBindingEnabled?: boolean;
+ isMidpointSnappingEnabled?: boolean;
},
): ElementUpdate => {
if (arrow.points.length < 2) {
@@ -1202,6 +1204,8 @@ const getElbowArrowData = (
options?: {
isDragging?: boolean;
zoom?: AppState["zoom"];
+ isBindingEnabled?: boolean;
+ isMidpointSnappingEnabled?: boolean;
},
) => {
const origStartGlobalPoint: GlobalPoint = pointTranslate<
@@ -1215,7 +1219,7 @@ const getElbowArrowData = (
let hoveredStartElement = null;
let hoveredEndElement = null;
- if (options?.isDragging) {
+ if (options?.isDragging && options?.isBindingEnabled !== false) {
const elements = Array.from(elementsMap.values());
hoveredStartElement =
getHoveredElement(
@@ -1255,6 +1259,8 @@ const getElbowArrowData = (
hoveredStartElement,
elementsMap,
options?.isDragging,
+ options?.isBindingEnabled,
+ options?.isMidpointSnappingEnabled,
);
const endGlobalPoint = getGlobalPoint(
{
@@ -1270,6 +1276,8 @@ const getElbowArrowData = (
hoveredEndElement,
elementsMap,
options?.isDragging,
+ options?.isBindingEnabled,
+ options?.isMidpointSnappingEnabled,
);
const startHeading = getBindPointHeading(
startGlobalPoint,
@@ -2116,8 +2124,8 @@ const normalizeArrowElementUpdate = (
offsetY < -MAX_POS ||
offsetY > MAX_POS ||
offsetX + points[points.length - 1][0] < -MAX_POS ||
- offsetY + points[points.length - 1][0] > MAX_POS ||
- offsetX + points[points.length - 1][1] < -MAX_POS ||
+ offsetX + points[points.length - 1][0] > MAX_POS ||
+ offsetY + points[points.length - 1][1] < -MAX_POS ||
offsetY + points[points.length - 1][1] > MAX_POS
) {
console.error(
@@ -2213,14 +2221,18 @@ const getGlobalPoint = (
element?: ExcalidrawBindableElement | null,
elementsMap?: ElementsMap,
isDragging?: boolean,
+ isBindingEnabled = true,
+ isMidpointSnappingEnabled = true,
): GlobalPoint => {
if (isDragging) {
- if (element && elementsMap) {
+ if (isBindingEnabled && element && elementsMap) {
return bindPointToSnapToElementOutline(
arrow,
element,
startOrEnd,
elementsMap,
+ undefined,
+ isMidpointSnappingEnabled,
);
}
diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts
index 71c75cc23a..917ca0a7af 100644
--- a/packages/element/src/embeddable.ts
+++ b/packages/element/src/embeddable.ts
@@ -56,7 +56,7 @@ const RE_REDDIT =
const RE_REDDIT_EMBED =
/^ {
+const parseYouTubeLikeTimestamp = (url: string): number => {
let timeParam: string | null | undefined;
try {
@@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
};
+const parseGoogleDriveVideoLink = (
+ url: string,
+): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
+ try {
+ const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
+ const hostname = urlObj.hostname.replace(/^www\./, "");
+ if (hostname !== "drive.google.com") {
+ return null;
+ }
+
+ let fileId: string | null = null;
+ const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
+ if (pathMatch?.[1]) {
+ fileId = pathMatch[1];
+ } else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
+ // Shared Drive links can be emitted as:
+ // - /open?id= (common "open in Drive" format)
+ // - /uc?...&id= (download/export endpoint often seen in copied links)
+ fileId = urlObj.searchParams.get("id");
+ }
+
+ if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
+ return null;
+ }
+
+ // Some Drive share links include `resourcekey` for access to link-shared
+ // files; preserve it in the preview URL so embeds keep working.
+ const resourceKey = urlObj.searchParams.get("resourcekey");
+ const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
+
+ return {
+ fileId,
+ resourceKey:
+ resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
+ ? resourceKey
+ : undefined,
+ // Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
+ // normalize to seconds for a stable preview URL.
+ timestamp: timestamp > 0 ? timestamp : undefined,
+ };
+ } catch (error) {
+ return null;
+ }
+};
+
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
+ "drive.google.com",
"figma.com",
"link.excalidraw.com",
"gist.github.com",
@@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"youtu.be",
"vimeo.com",
"player.vimeo.com",
+ "drive.google.com",
"figma.com",
"twitter.com",
"x.com",
@@ -142,7 +189,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
- const startTime = parseYouTubeTimestamp(originalLink);
+ const startTime = parseYouTubeLikeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
@@ -201,6 +248,36 @@ export const getEmbedLink = (
};
}
+ const googleDriveVideo = parseGoogleDriveVideoLink(link);
+ if (googleDriveVideo) {
+ type = "video";
+ const searchParams = new URLSearchParams();
+ if (googleDriveVideo.resourceKey) {
+ searchParams.set("resourcekey", googleDriveVideo.resourceKey);
+ }
+ if (googleDriveVideo.timestamp) {
+ searchParams.set("t", `${googleDriveVideo.timestamp}`);
+ }
+
+ const search = searchParams.toString();
+ link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
+ search ? `?${search}` : ""
+ }`;
+ aspectRatio = { w: 560, h: 315 };
+ embeddedLinkCache.set(originalLink, {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ });
+ return {
+ link,
+ intrinsicSize: aspectRatio,
+ type,
+ sandbox: { allowSameOrigin },
+ };
+ }
+
const figmaLink = link.match(RE_FIGMA);
if (figmaLink) {
type = "generic";
diff --git a/packages/element/src/fractionalIndex.ts b/packages/element/src/fractionalIndex.ts
index 44ca523c80..90a2e7c217 100644
--- a/packages/element/src/fractionalIndex.ts
+++ b/packages/element/src/fractionalIndex.ts
@@ -1,7 +1,10 @@
-import { generateNKeysBetween } from "fractional-indexing";
-
import { arrayToMap } from "@excalidraw/common";
+import {
+ validateOrderKey,
+ generateNKeysBetween,
+} from "@excalidraw/fractional-indexing";
+
import { mutateElement, newElementWith } from "./mutateElement";
import { getBoundTextElement } from "./textElement";
import { hasBoundTextElement } from "./typeChecks";
@@ -382,6 +385,13 @@ const isValidFractionalIndex = (
return false;
}
+ try {
+ // Format validation
+ validateOrderKey(index);
+ } catch {
+ return false;
+ }
+
if (predecessor && successor) {
return predecessor < index && index < successor;
}
diff --git a/packages/element/src/frame.ts b/packages/element/src/frame.ts
index 3c82099546..3d1449a072 100644
--- a/packages/element/src/frame.ts
+++ b/packages/element/src/frame.ts
@@ -1,7 +1,6 @@
import { arrayToMap } from "@excalidraw/common";
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
-import { elementsOverlappingBBox } from "@excalidraw/utils/withinBounds";
import type {
AppClassProperties,
@@ -18,9 +17,13 @@ import {
getElementLineSegments,
getCommonBounds,
getElementAbsoluteCoords,
+ doBoundsIntersect,
+ getElementBounds,
+ boundsContainBounds,
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
+import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
@@ -100,8 +103,9 @@ export const isElementContainingFrame = (
frame: ExcalidrawFrameLikeElement,
elementsMap: ElementsMap,
) => {
- return getElementsWithinSelection([frame], element, elementsMap).some(
- (e) => e.id === frame.id,
+ return boundsContainBounds(
+ getElementBounds(element, elementsMap),
+ getElementBounds(frame, elementsMap),
);
};
@@ -488,10 +492,44 @@ export const filterElementsEligibleAsFrameChildren = (
return eligibleElements;
};
+export const getCommonFrameId = (elements: readonly ExcalidrawElement[]) => {
+ let commonFrameId: ExcalidrawElement["frameId"] | undefined;
+
+ for (const element of elements) {
+ if (isFrameLikeElement(element) || !element.frameId) {
+ return null;
+ }
+
+ if (commonFrameId === undefined) {
+ commonFrameId = element.frameId;
+ } else if (commonFrameId !== element.frameId) {
+ return null;
+ }
+ }
+
+ return commonFrameId ?? null;
+};
+
+export const getFrameChildrenInsertionIndex = (
+ elements: readonly ExcalidrawElement[],
+ frameId: ExcalidrawFrameLikeElement["id"],
+): number | null => {
+ for (let index = elements.length - 1; index >= 0; index--) {
+ const element = elements[index];
+
+ if (element.id === frameId) {
+ return index;
+ } else if (element.frameId === frameId) {
+ return index + 1;
+ }
+ }
+
+ return null;
+};
+
/**
- * Retains (or repairs for target frame) the ordering invriant where children
- * elements come right before the parent frame:
- * [el, el, child, child, frame, el]
+ * Adds elements and their bound elements to frame. Reorders added elements to
+ * be just below frame, or just above its highest child (whichever is higher).
*
* @returns mutated allElements (same data structure)
*/
@@ -499,19 +537,11 @@ export const addElementsToFrame = (
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
- appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
- const currTargetFrameChildrenMap = new Map();
- for (const element of allElements.values()) {
- if (element.frameId === frame.id) {
- currTargetFrameChildrenMap.set(element.id, true);
- }
- }
+ const commonFrameId = getCommonFrameId(elementsToAdd);
- const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
-
- const finalElementsToAdd: ExcalidrawElement[] = [];
+ const finalElementsToAdd = new Set();
const otherFrames = new Set();
@@ -522,7 +552,8 @@ export const addElementsToFrame = (
}
// - add bound text elements if not already in the array
- // - filter out elements that are already in the frame
+ // - keep elements already in the frame so mixed selections can be reordered
+ // together
for (const element of omitGroupsContainingFrameLikes(
allElements,
elementsToAdd,
@@ -535,38 +566,68 @@ export const addElementsToFrame = (
continue;
}
- // if the element is already in another frame (which is also in elementsToAdd),
- // it means that frame and children are selected at the same time
- // => keep original frame membership, do not add to the target frame
- if (
- element.frameId &&
- appState.selectedElementIds[element.id] &&
- appState.selectedElementIds[element.frameId]
- ) {
+ if (element.frameId && element.frameId !== frame.id) {
continue;
}
- if (!currTargetFrameChildrenMap.has(element.id)) {
- finalElementsToAdd.push(element);
- }
+ finalElementsToAdd.add(element);
const boundTextElement = getBoundTextElement(element, elementsMap);
- if (
- boundTextElement &&
- !suppliedElementsToAddSet.has(boundTextElement.id) &&
- !currTargetFrameChildrenMap.has(boundTextElement.id)
- ) {
- finalElementsToAdd.push(boundTextElement);
+ if (boundTextElement && !finalElementsToAdd.has(boundTextElement)) {
+ finalElementsToAdd.add(boundTextElement);
}
}
for (const element of finalElementsToAdd) {
- mutateElement(element, elementsMap, {
- frameId: frame.id,
- });
+ // we don't always need to update the element if it's already in the frame,
+ // but we still need to accumulate in finalElementsToAdd so we potentially
+ // reorder them if added together
+ if (element.frameId !== frame.id) {
+ mutateElement(element, elementsMap, {
+ frameId: frame.id,
+ });
+ }
}
- return allElements;
+ // (re)order elements to be just below the frame,
+ // or just above the highest child if that is higher
+ // (latter case is denormalized order until we migrate)
+ // ---------------------------------------------------------------------------
+
+ if (
+ !finalElementsToAdd.size ||
+ // if all elements to add already belong to the frame, then we don't want to
+ // reorder (case: we're dragging element children within the frame)
+ commonFrameId === frame.id
+ ) {
+ return allElements;
+ }
+
+ const otherElements = Array.from(allElements.values()).filter(
+ (element) => !finalElementsToAdd.has(element),
+ );
+ const insertionIndex = getFrameChildrenInsertionIndex(
+ otherElements,
+ frame.id,
+ );
+
+ if (insertionIndex === null) {
+ return allElements;
+ }
+
+ const reorderedElements = [
+ ...otherElements.slice(0, insertionIndex),
+ ...finalElementsToAdd,
+ ...otherElements.slice(insertionIndex),
+ ];
+
+ syncMovedIndices(reorderedElements, arrayToMap([...finalElementsToAdd]));
+
+ return (
+ Array.isArray(allElements)
+ ? reorderedElements
+ : new Map(reorderedElements.map((element) => [element.id, element]))
+ ) as T;
};
export const removeElementsFromFrame = (
@@ -620,13 +681,11 @@ export const replaceAllElementsInFrame = (
allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
- app: AppClassProperties,
): T[] => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame,
frame,
- app.state,
).slice();
};
@@ -920,16 +979,17 @@ export const getFrameLikeTitle = (element: ExcalidrawFrameLikeElement) => {
export const getElementsOverlappingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
+ elementsMap: ElementsMap,
) => {
- return (
- elementsOverlappingBBox({
- elements,
- bounds: frame,
- type: "overlap",
- })
- // removes elements who are overlapping, but are in a different frame,
+ return elements.filter(
+ (el) =>
+ // exclude elements which are overlapping, but are in a different frame,
// and thus invisible in target frame
- .filter((el) => !el.frameId || el.frameId === frame.id)
+ (!el.frameId || el.frameId === frame.id) &&
+ doBoundsIntersect(
+ getElementBounds(el, elementsMap),
+ getElementBounds(frame, elementsMap),
+ ),
);
};
diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts
index 1ca1c1a289..c55537d451 100644
--- a/packages/element/src/index.ts
+++ b/packages/element/src/index.ts
@@ -99,3 +99,4 @@ export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
export * from "./arrows/helpers";
+export * from "./arrowheads";
diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts
index f5743b553f..6e54cd59ef 100644
--- a/packages/element/src/linearElementEditor.ts
+++ b/packages/element/src/linearElementEditor.ts
@@ -9,7 +9,6 @@ import {
vectorFromPoint,
curveLength,
curvePointAtLength,
- lineSegment,
} from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -360,11 +359,20 @@ export class LinearElementEditor {
linearElementEditor,
);
- LinearElementEditor.movePoints(element, app.scene, positions, {
- startBinding: updates?.startBinding,
- endBinding: updates?.endBinding,
- moveMidPointsWithElement: updates?.moveMidPointsWithElement,
- });
+ LinearElementEditor.movePoints(
+ element,
+ app.scene,
+ positions,
+ {
+ startBinding: updates?.startBinding,
+ endBinding: updates?.endBinding,
+ moveMidPointsWithElement: updates?.moveMidPointsWithElement,
+ },
+ {
+ isBindingEnabled: app.state.isBindingEnabled,
+ isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
+ },
+ );
// Set the suggested binding from the updates if available
if (isBindingElement(element, false)) {
if (isBindingEnabled(app.state)) {
@@ -419,6 +427,7 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
+ app.state.isMidpointSnappingEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -467,16 +476,22 @@ export class LinearElementEditor {
});
}
- invariant(
- lastClickedPoint > -1 &&
- selectedPointsIndices.includes(lastClickedPoint) &&
- element.points[lastClickedPoint],
- `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
- selectedPointsIndices,
- )}) points(0..${
- element.points.length - 1
- }) lastClickedPoint(${lastClickedPoint})`,
- );
+ if (
+ lastClickedPoint < 0 ||
+ !selectedPointsIndices.includes(lastClickedPoint) ||
+ !element.points[lastClickedPoint]
+ ) {
+ console.error(
+ `There must be a valid lastClickedPoint in order to drag it. selectedPointsIndices(${JSON.stringify(
+ selectedPointsIndices,
+ )}) points(0..${
+ element.points.length - 1
+ }) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
+ );
+
+ // Fall back to the actual last point as a last resort.
+ lastClickedPoint = element.points.length - 1;
+ }
// point that's being dragged (out of all selected points)
const draggingPoint = element.points[lastClickedPoint];
@@ -539,11 +554,20 @@ export class LinearElementEditor {
linearElementEditor,
);
- LinearElementEditor.movePoints(element, app.scene, positions, {
- startBinding: updates?.startBinding,
- endBinding: updates?.endBinding,
- moveMidPointsWithElement: updates?.moveMidPointsWithElement,
- });
+ LinearElementEditor.movePoints(
+ element,
+ app.scene,
+ positions,
+ {
+ startBinding: updates?.startBinding,
+ endBinding: updates?.endBinding,
+ moveMidPointsWithElement: updates?.moveMidPointsWithElement,
+ },
+ {
+ isBindingEnabled: app.state.isBindingEnabled,
+ isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
+ },
+ );
// Set the suggested binding from the updates if available
if (isBindingElement(element, false)) {
@@ -637,6 +661,7 @@ export class LinearElementEditor {
"start",
elementsMap,
app.state.zoom,
+ app.state.isMidpointSnappingEnabled,
)
: linearElementEditor.initialState.altFocusPoint,
},
@@ -775,6 +800,7 @@ export class LinearElementEditor {
element.points[index + 1],
index,
appState.zoom,
+ elementsMap,
)
) {
midpoints.push(null);
@@ -784,6 +810,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
+ elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -871,6 +898,7 @@ export class LinearElementEditor {
endPoint: P,
index: number,
zoom: Zoom,
+ elementsMap: ElementsMap,
) {
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
@@ -885,7 +913,10 @@ export class LinearElementEditor {
let distance = pointDistance(startPoint, endPoint);
if (element.points.length > 2 && element.roundness) {
- const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
+ const [lines, curves] = deconstructLinearOrFreeDrawElement(
+ element,
+ elementsMap,
+ );
invariant(
lines.length === 0 && curves.length > 0,
@@ -905,6 +936,7 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted,
index: number,
+ elementsMap: ElementsMap,
): GlobalPoint {
if (isElbowArrow(element)) {
invariant(
@@ -917,7 +949,10 @@ export class LinearElementEditor {
return pointFrom(element.x + p[0], element.y + p[1]);
}
- const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
+ const [lines, curves] = deconstructLinearOrFreeDrawElement(
+ element,
+ elementsMap,
+ );
invariant(
(lines.length === 0 && curves.length > 0) ||
@@ -1525,6 +1560,10 @@ export class LinearElementEditor {
endBinding?: FixedPointBinding | null;
moveMidPointsWithElement?: boolean | null;
},
+ options?: {
+ isBindingEnabled?: boolean;
+ isMidpointSnappingEnabled?: boolean;
+ },
) {
const { points } = element;
@@ -1593,6 +1632,8 @@ export class LinearElementEditor {
otherUpdates,
{
isDragging: Array.from(pointUpdates.values()).some((t) => t.isDragging),
+ isBindingEnabled: options?.isBindingEnabled,
+ isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
},
);
}
@@ -1707,6 +1748,8 @@ export class LinearElementEditor {
isDragging?: boolean;
zoom?: AppState["zoom"];
sceneElementsMap?: NonDeletedSceneElementsMap;
+ isBindingEnabled?: boolean;
+ isMidpointSnappingEnabled?: boolean;
},
) {
if (isElbowArrow(element)) {
@@ -1727,6 +1770,8 @@ export class LinearElementEditor {
scene.mutateElement(element, updates, {
informMutation: true,
isDragging: options?.isDragging ?? false,
+ isBindingEnabled: options?.isBindingEnabled,
+ isMidpointSnappingEnabled: options?.isMidpointSnappingEnabled,
});
} else {
// TODO do we need to get precise coords here just to calc centers?
@@ -1822,6 +1867,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
+ elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2093,13 +2139,13 @@ const pointDraggingUpdates = (
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
+ // NOTE: Avoid stale point index issue potentially caused by elbow
+ // arrows unpredictably changing the number of points during dragging
+ const point = element.points[pointIndex] ?? element.points.at(-1);
return [
pointIndex,
{
- point: pointFrom(
- element.points[pointIndex][0] + deltaX,
- element.points[pointIndex][1] + deltaY,
- ),
+ point: pointFrom(point[0] + deltaX, point[1] + deltaY),
isDragging: true,
},
];
@@ -2146,14 +2192,16 @@ const pointDraggingUpdates = (
suggestedBinding: suggestedBindingElement
? {
element: suggestedBindingElement,
- midPoint: snapToMid(
- suggestedBindingElement,
- elementsMap,
- pointFrom(
- scenePointerX - linearElementEditor.pointerOffset.x,
- scenePointerY - linearElementEditor.pointerOffset.y,
- ),
- ),
+ midPoint: app.state.isMidpointSnappingEnabled
+ ? snapToMid(
+ suggestedBindingElement,
+ elementsMap,
+ pointFrom(
+ scenePointerX - linearElementEditor.pointerOffset.x,
+ scenePointerY - linearElementEditor.pointerOffset.y,
+ ),
+ )
+ : undefined,
}
: null,
},
@@ -2339,19 +2387,6 @@ const pointDraggingUpdates = (
: updates.endBinding,
};
- // We need to use a custom intersector to ensure that if there is a big "jump"
- // in the arrow's position, we can position it with outline avoidance
- // pixel-perfectly and avoid "dancing" arrows.
- // NOTE: Direction matters here, so we create two intersectors
- const startCustomIntersector =
- start.focusPoint && end.focusPoint
- ? lineSegment(start.focusPoint, end.focusPoint)
- : undefined;
- const endCustomIntersector =
- start.focusPoint && end.focusPoint
- ? lineSegment(end.focusPoint, start.focusPoint)
- : undefined;
-
// Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side
const startIsDraggingOverEndElement =
@@ -2382,14 +2417,12 @@ const pointDraggingUpdates = (
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
- element,
+ nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
elementsMap,
- {
- customIntersector: endCustomIntersector,
- },
+ endIsDragged,
) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1];
@@ -2415,12 +2448,12 @@ const pointDraggingUpdates = (
? endLocalPoint
: startBindable
? updateBoundPoint(
- element,
+ nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
elementsMap,
- { customIntersector: startCustomIntersector },
+ startIsDragged,
) || nextArrow.points[0]
: nextArrow.points[0];
diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts
index c45c6df08c..eb6350c2bf 100644
--- a/packages/element/src/mutateElement.ts
+++ b/packages/element/src/mutateElement.ts
@@ -40,6 +40,8 @@ export const mutateElement = >(
updates: ElementUpdate,
options?: {
isDragging?: boolean;
+ isBindingEnabled?: boolean;
+ isMidpointSnappingEnabled?: boolean;
},
) => {
let didChange = false;
diff --git a/packages/element/src/selection.ts b/packages/element/src/selection.ts
index bb94166dbd..a65d1d3ce3 100644
--- a/packages/element/src/selection.ts
+++ b/packages/element/src/selection.ts
@@ -1,16 +1,34 @@
-import { arrayToMap, isShallowEqual } from "@excalidraw/common";
+import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
+import {
+ lineSegment,
+ pointFrom,
+ pointRotateRads,
+ type GlobalPoint,
+} from "@excalidraw/math";
import type {
AppState,
+ BoxSelectionMode,
InteractiveCanvasAppState,
} from "@excalidraw/excalidraw/types";
-import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
+import {
+ boundsContainBounds,
+ doBoundsIntersect,
+ elementCenterPoint,
+ getElementAbsoluteCoords,
+ getElementBounds,
+ pointInsideBounds,
+} from "./bounds";
+import { intersectElementWithLineSegment } from "./collision";
import { isElementInViewport } from "./sizeHelpers";
import {
+ isArrowElement,
isBoundToContainer,
isFrameLikeElement,
+ isFreeDrawElement,
isLinearElement,
+ isTextElement,
} from "./typeChecks";
import {
elementOverlapsWithFrame,
@@ -20,14 +38,33 @@ import {
import { LinearElementEditor } from "./linearElementEditor";
import { selectGroupsForSelectedElements } from "./groups";
+import { getBoundTextElement } from "./textElement";
import type {
ElementsMap,
ElementsMapOrArray,
ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+ NonDeleted,
NonDeletedExcalidrawElement,
} from "./types";
+const shouldIgnoreElementFromSelection = (
+ element: NonDeletedExcalidrawElement,
+) => element.locked || isBoundToContainer(element);
+
+const excludeElementsFromFrames = (
+ selectedElements: readonly T[],
+ framesInSelection: Set,
+) => {
+ return selectedElements.filter((element) => {
+ if (element.frameId && framesInSelection.has(element.frameId)) {
+ return false;
+ }
+ return true;
+ });
+};
+
/**
* Frames and their containing elements are not to be selected at the same time.
* Given an array of selected elements, if there are frames and their containing elements
@@ -47,68 +84,286 @@ export const excludeElementsInFramesFromSelection = <
}
});
- return selectedElements.filter((element) => {
- if (element.frameId && framesInSelection.has(element.frameId)) {
- return false;
- }
- return true;
- });
+ return excludeElementsFromFrames(selectedElements, framesInSelection);
};
export const getElementsWithinSelection = (
elements: readonly NonDeletedExcalidrawElement[],
selection: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
+ // TODO remove (this flag is effectively unused AFAIK)
excludeElementsInFrames: boolean = true,
-) => {
- const [selectionX1, selectionY1, selectionX2, selectionY2] =
+ boxSelectionMode: BoxSelectionMode = "contain",
+): NonDeletedExcalidrawElement[] => {
+ const [selectionStartX, selectionStartY, selectionEndX, selectionEndY] =
getElementAbsoluteCoords(selection, elementsMap);
+ const selectionX1 = Math.min(selectionStartX, selectionEndX);
+ const selectionY1 = Math.min(selectionStartY, selectionEndY);
+ const selectionX2 = Math.max(selectionStartX, selectionEndX);
+ const selectionY2 = Math.max(selectionStartY, selectionEndY);
+ const selectionBounds = [
+ selectionX1,
+ selectionY1,
+ selectionX2,
+ selectionY2,
+ ] as Bounds;
+ const selectionEdges = [
+ lineSegment(
+ pointFrom(selectionX1, selectionY1),
+ pointFrom(selectionX2, selectionY1),
+ ),
+ lineSegment(
+ pointFrom(selectionX2, selectionY1),
+ pointFrom(selectionX2, selectionY2),
+ ),
+ lineSegment(
+ pointFrom(selectionX2, selectionY2),
+ pointFrom(selectionX1, selectionY2),
+ ),
+ lineSegment(
+ pointFrom(selectionX1, selectionY2),
+ pointFrom(selectionX1, selectionY1),
+ ),
+ ];
- let elementsInSelection = elements.filter((element) => {
- let [elementX1, elementY1, elementX2, elementY2] = getElementBounds(
- element,
- elementsMap,
- );
+ const framesInSelection = excludeElementsInFrames
+ ? new Set()
+ : null;
+ const groups: Record = {};
+ const elementsInSelection: Set = new Set();
- const containingFrame = getContainingFrame(element, elementsMap);
- if (containingFrame) {
- const [fx1, fy1, fx2, fy2] = getElementBounds(
- containingFrame,
+ for (const element of elements) {
+ if (shouldIgnoreElementFromSelection(element)) {
+ continue;
+ }
+
+ // Track only selectable top-level group members, so ignored elements such
+ // as bound text and locked elements don't affect group selection.
+ const groupId = element.groupIds.at(-1);
+ if (groupId) {
+ if (!groups[groupId]) {
+ groups[groupId] = [];
+ }
+ groups[groupId].push(element);
+ }
+
+ const strokeWidth = element.strokeWidth;
+ let labelAABB: Bounds | null = null;
+ let elementAABB = getElementBounds(element, elementsMap);
+
+ elementAABB = [
+ elementAABB[0] - strokeWidth / 2,
+ elementAABB[1] - strokeWidth / 2,
+ elementAABB[2] + strokeWidth / 2,
+ elementAABB[3] + strokeWidth / 2,
+ ] as Bounds;
+
+ // Whether the element bounds should include the bound text element bounds
+ const boundTextElement =
+ isArrowElement(element) && getBoundTextElement(element, elementsMap);
+ if (boundTextElement) {
+ const { x, y } = LinearElementEditor.getBoundTextElementPosition(
+ element,
+ boundTextElement,
elementsMap,
);
-
- elementX1 = Math.max(fx1, elementX1);
- elementY1 = Math.max(fy1, elementY1);
- elementX2 = Math.min(fx2, elementX2);
- elementY2 = Math.min(fy2, elementY2);
+ labelAABB = [
+ x,
+ y,
+ x + boundTextElement.width,
+ y + boundTextElement.height,
+ ] as Bounds;
}
- return (
- element.locked === false &&
- element.type !== "selection" &&
- !isBoundToContainer(element) &&
- selectionX1 <= elementX1 &&
- selectionY1 <= elementY1 &&
- selectionX2 >= elementX2 &&
- selectionY2 >= elementY2
- );
- });
+ // Clip element bounds by its containing frame (if any), since only the
+ // visible (frame-clipped) portion of the element is relevant for selection.
+ const associatedFrame = getContainingFrame(element, elementsMap);
+ if (
+ associatedFrame &&
+ elementOverlapsWithFrame(element, associatedFrame, elementsMap)
+ ) {
+ const frameAABB = getElementBounds(associatedFrame, elementsMap);
+ elementAABB = [
+ Math.max(elementAABB[0], frameAABB[0]),
+ Math.max(elementAABB[1], frameAABB[1]),
+ Math.min(elementAABB[2], frameAABB[2]),
+ Math.min(elementAABB[3], frameAABB[3]),
+ ] as Bounds;
- elementsInSelection = excludeElementsInFrames
- ? excludeElementsInFramesFromSelection(elementsInSelection)
- : elementsInSelection;
-
- elementsInSelection = elementsInSelection.filter((element) => {
- const containingFrame = getContainingFrame(element, elementsMap);
-
- if (containingFrame) {
- return elementOverlapsWithFrame(element, containingFrame, elementsMap);
+ labelAABB = labelAABB
+ ? ([
+ Math.max(labelAABB[0], frameAABB[0]),
+ Math.max(labelAABB[1], frameAABB[1]),
+ Math.min(labelAABB[2], frameAABB[2]),
+ Math.min(labelAABB[3], frameAABB[3]),
+ ] as Bounds)
+ : null;
}
- return true;
- });
+ const commonAABB = labelAABB
+ ? ([
+ Math.min(labelAABB[0], elementAABB[0]),
+ Math.min(labelAABB[1], elementAABB[1]),
+ Math.max(labelAABB[2], elementAABB[2]),
+ Math.max(labelAABB[3], elementAABB[3]),
+ ] as Bounds)
+ : elementAABB;
- return elementsInSelection;
+ // ============== Evaluation ==============
+
+ // 1. If the selection box WRAPs the element's AABB, then add it to the
+ // selection and move on, regardless of the selection mode.
+ //
+ // PERF: This trick only works with axis-aligned box selection and the
+ // current convex element shapes!
+ if (boundsContainBounds(selectionBounds, commonAABB)) {
+ if (framesInSelection && isFrameLikeElement(element)) {
+ framesInSelection.add(element.id);
+ }
+ elementsInSelection.add(element);
+ continue;
+ }
+
+ // 2. Handle the case where the label is overlapped by the selection box
+ if (
+ boxSelectionMode === "overlap" &&
+ labelAABB &&
+ doBoundsIntersect(selectionBounds, labelAABB)
+ ) {
+ elementsInSelection.add(element);
+ continue;
+ }
+
+ // 3. Handle the case where the selection is not wrapping the element, but
+ // it does intersect the element's outline (non-AABB).
+ if (
+ boxSelectionMode === "overlap" &&
+ doBoundsIntersect(selectionBounds, elementAABB)
+ ) {
+ let hasIntersection = false;
+
+ // Preliminary check potential intersection imprecision
+ if (isLinearElement(element) || isFreeDrawElement(element)) {
+ const center = elementCenterPoint(element, elementsMap);
+ hasIntersection = element.points.some((point) => {
+ const rotatedPoint = pointRotateRads(
+ pointFrom(element.x + point[0], element.y + point[1]),
+ center,
+ element.angle,
+ );
+
+ return pointInsideBounds(rotatedPoint, selectionBounds);
+ });
+ } else {
+ const nonRotatedElementBounds = getElementBounds(
+ element,
+ elementsMap,
+ true,
+ );
+ const center = elementCenterPoint(element, elementsMap);
+ hasIntersection = [
+ pointRotateRads(
+ pointFrom(
+ (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
+ nonRotatedElementBounds[1],
+ ),
+ center,
+ element.angle,
+ ),
+ pointRotateRads(
+ pointFrom(
+ nonRotatedElementBounds[2],
+ (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
+ ),
+ center,
+ element.angle,
+ ),
+ pointRotateRads(
+ pointFrom(
+ (nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
+ nonRotatedElementBounds[3],
+ ),
+ center,
+ element.angle,
+ ),
+ pointRotateRads(
+ pointFrom(
+ nonRotatedElementBounds[0],
+ (nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
+ ),
+ center,
+ element.angle,
+ ),
+ ].some((point) => {
+ return pointInsideBounds(
+ pointRotateRads(point, center, element.angle),
+ selectionBounds,
+ );
+ });
+ }
+
+ if (!hasIntersection) {
+ hasIntersection = selectionEdges.some(
+ (selectionEdge) =>
+ intersectElementWithLineSegment(
+ element,
+ elementsMap,
+ selectionEdge,
+ strokeWidth / 2,
+ true, // Stop at first hit for better performance
+ ).length > 0,
+ );
+ }
+
+ if (hasIntersection) {
+ if (framesInSelection && isFrameLikeElement(element)) {
+ framesInSelection.add(element.id);
+ }
+
+ elementsInSelection.add(element);
+ continue;
+ }
+ }
+
+ // 4. We don't need to handle when the selection is inside the element
+ // as it is separately handled in App.
+ }
+
+ if (framesInSelection) {
+ elementsInSelection.forEach((element) => {
+ if (element.frameId && framesInSelection.has(element.frameId)) {
+ elementsInSelection.delete(element);
+ }
+ });
+ }
+
+ if (boxSelectionMode === "overlap") {
+ Array.from(elementsInSelection).forEach((element) => {
+ const groupId = element.groupIds.at(-1);
+ const group = groupId ? groups[groupId] : null;
+
+ group?.forEach((groupElement) => elementsInSelection.add(groupElement));
+ });
+ } else if (boxSelectionMode === "contain") {
+ elementsInSelection.forEach((element) => {
+ // note: currently we only support top-level group handling since
+ // we don't support box selecting while editing the group/subgroup
+ // see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
+ const groupId = element.groupIds.at(-1);
+
+ const group = groupId ? groups[groupId] : null;
+
+ if (
+ group &&
+ !group.every((groupElement) => elementsInSelection.has(groupElement))
+ ) {
+ elementsInSelection.delete(element);
+ }
+ });
+ }
+
+ // to maintain original order elements (namely for group selection)
+ return elements.filter((element) => elementsInSelection.has(element));
};
export const getVisibleAndNonSelectedElements = (
@@ -288,3 +543,19 @@ export const getSelectionStateForElements = (
),
};
};
+
+/**
+ * Returns editing or single-selected text element, if any.
+ */
+export const getActiveTextElement = (
+ selectedElements: readonly NonDeleted[],
+ appState: Pick,
+) => {
+ const activeTextElement =
+ appState.editingTextElement ||
+ (selectedElements.length === 1 &&
+ isTextElement(selectedElements[0]) &&
+ selectedElements[0]);
+
+ return activeTextElement || null;
+};
diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts
index 176455bdf9..158ace7519 100644
--- a/packages/element/src/shape.ts
+++ b/packages/element/src/shape.ts
@@ -57,8 +57,8 @@ import { headingForPointIsHorizontal } from "./heading";
import { canChangeRoundness } from "./comparisons";
import {
+ elementCenterPoint,
getArrowheadPoints,
- getCenterForBounds,
getDiamondPoints,
getElementAbsoluteCoords,
} from "./bounds";
@@ -69,10 +69,10 @@ import type {
NonDeletedExcalidrawElement,
ExcalidrawSelectionElement,
ExcalidrawLinearElement,
- Arrowhead,
ExcalidrawFreeDrawElement,
ElementsMap,
ExcalidrawLineElement,
+ Arrowhead,
} from "./types";
import type { Drawable, Options } from "roughjs/bin/core";
@@ -296,6 +296,82 @@ const modifyIframeLikeForRoughOptions = (
return element;
};
+const generateArrowheadCardinalityOne = (
+ generator: RoughGenerator,
+ arrowheadPoints: number[] | null,
+ lineOptions: Options,
+) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [, , x3, y3, x4, y4] = arrowheadPoints;
+
+ return [generator.line(x3, y3, x4, y4, lineOptions)];
+};
+
+const generateArrowheadLinesToTip = (
+ generator: RoughGenerator,
+ arrowheadPoints: number[] | null,
+ lineOptions: Options,
+) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+
+ return [
+ generator.line(x3, y3, x2, y2, lineOptions),
+ generator.line(x4, y4, x2, y2, lineOptions),
+ ];
+};
+
+const getArrowheadLineOptions = (
+ element: ExcalidrawLinearElement,
+ options: Options,
+) => {
+ const lineOptions = { ...options };
+
+ if (element.strokeStyle === "dotted") {
+ // for dotted arrows caps, reduce gap to make it more legible
+ const dash = getDashArrayDotted(element.strokeWidth - 1);
+ lineOptions.strokeLineDash = [dash[0], dash[1] - 1];
+ } else {
+ // for solid/dashed, keep solid arrow cap
+ delete lineOptions.strokeLineDash;
+ }
+ lineOptions.roughness = Math.min(1, lineOptions.roughness || 0);
+
+ return lineOptions;
+};
+
+const generateArrowheadOutlineCircle = (
+ generator: RoughGenerator,
+ options: Options,
+ strokeColor: string,
+ arrowheadPoints: number[] | null,
+ fill: string,
+ diameterScale = 1,
+) => {
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
+ const [x, y, diameter] = arrowheadPoints;
+ const circleOptions = {
+ ...options,
+ fill,
+ fillStyle: "solid" as const,
+ stroke: strokeColor,
+ roughness: Math.min(0.5, options.roughness || 0),
+ };
+
+ delete circleOptions.strokeLineDash;
+
+ return [generator.circle(x, y, diameter * diameterScale, circleOptions)];
+};
+
const getArrowheadShapes = (
element: ExcalidrawLinearElement,
shape: Drawable[],
@@ -306,63 +382,54 @@ const getArrowheadShapes = (
canvasBackgroundColor: string,
isDarkMode: boolean,
) => {
- const arrowheadPoints = getArrowheadPoints(
- element,
- shape,
- position,
- arrowhead,
- );
-
- if (arrowheadPoints === null) {
+ if (arrowhead === null) {
return [];
}
- const generateCrowfootOne = (
- arrowheadPoints: number[] | null,
- options: Options,
- ) => {
- if (arrowheadPoints === null) {
- return [];
- }
-
- const [, , x3, y3, x4, y4] = arrowheadPoints;
-
- return [generator.line(x3, y3, x4, y4, options)];
- };
-
const strokeColor = isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
+ const backgroundFillColor = isDarkMode
+ ? applyDarkModeFilter(canvasBackgroundColor)
+ : canvasBackgroundColor;
+ const cardinalityOneOrManyOffset = -0.25;
+ const cardinalityZeroCircleScale = 0.8;
switch (arrowhead) {
- case "dot":
case "circle":
case "circle_outline": {
- const [x, y, diameter] = arrowheadPoints;
-
- // always use solid stroke for arrowhead
- delete options.strokeLineDash;
-
- return [
- generator.circle(x, y, diameter, {
- ...options,
- fill:
- arrowhead === "circle_outline"
- ? canvasBackgroundColor
- : strokeColor,
-
- fillStyle: "solid",
- stroke: strokeColor,
- roughness: Math.min(0.5, options.roughness || 0),
- }),
- ];
+ return generateArrowheadOutlineCircle(
+ generator,
+ options,
+ strokeColor,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ arrowhead === "circle_outline" ? backgroundFillColor : strokeColor,
+ );
}
case "triangle":
case "triangle_outline": {
+ const arrowheadPoints = getArrowheadPoints(
+ element,
+ shape,
+ position,
+ arrowhead,
+ );
+
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
const [x, y, x2, y2, x3, y3] = arrowheadPoints;
+ const triangleOptions = {
+ ...options,
+ fill:
+ arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor,
+ fillStyle: "solid" as const,
+ roughness: Math.min(1, options.roughness || 0),
+ };
// always use solid stroke for arrowhead
- delete options.strokeLineDash;
+ delete triangleOptions.strokeLineDash;
return [
generator.polygon(
@@ -372,24 +439,34 @@ const getArrowheadShapes = (
[x3, y3],
[x, y],
],
- {
- ...options,
- fill:
- arrowhead === "triangle_outline"
- ? canvasBackgroundColor
- : strokeColor,
- fillStyle: "solid",
- roughness: Math.min(1, options.roughness || 0),
- },
+ triangleOptions,
),
];
}
case "diamond":
case "diamond_outline": {
+ const arrowheadPoints = getArrowheadPoints(
+ element,
+ shape,
+ position,
+ arrowhead,
+ );
+
+ if (arrowheadPoints === null) {
+ return [];
+ }
+
const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints;
+ const diamondOptions = {
+ ...options,
+ fill:
+ arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor,
+ fillStyle: "solid" as const,
+ roughness: Math.min(1, options.roughness || 0),
+ };
// always use solid stroke for arrowhead
- delete options.strokeLineDash;
+ delete diamondOptions.strokeLineDash;
return [
generator.polygon(
@@ -400,53 +477,117 @@ const getArrowheadShapes = (
[x4, y4],
[x, y],
],
- {
- ...options,
- fill:
- arrowhead === "diamond_outline"
- ? canvasBackgroundColor
- : strokeColor,
- fillStyle: "solid",
- roughness: Math.min(1, options.roughness || 0),
- },
+ diamondOptions,
+ ),
+ ];
+ }
+ case "cardinality_one":
+ return generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ getArrowheadLineOptions(element, options),
+ );
+ case "cardinality_many":
+ return generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ getArrowheadLineOptions(element, options),
+ );
+ case "cardinality_one_or_many": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_many"),
+ lineOptions,
+ ),
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(
+ element,
+ shape,
+ position,
+ "cardinality_one",
+ cardinalityOneOrManyOffset,
+ ),
+ lineOptions,
+ ),
+ ];
+ }
+ case "cardinality_exactly_one": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
+ lineOptions,
+ ),
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_one"),
+ lineOptions,
+ ),
+ ];
+ }
+ case "cardinality_zero_or_one": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadOutlineCircle(
+ generator,
+ options,
+ strokeColor,
+ getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
+ backgroundFillColor,
+ cardinalityZeroCircleScale,
+ ),
+ ...generateArrowheadCardinalityOne(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_one", -0.5),
+ lineOptions,
+ ),
+ ];
+ }
+ case "cardinality_zero_or_many": {
+ const lineOptions = getArrowheadLineOptions(element, options);
+
+ return [
+ ...generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, "cardinality_many"),
+ lineOptions,
+ ),
+ ...generateArrowheadOutlineCircle(
+ generator,
+ options,
+ strokeColor,
+ getArrowheadPoints(element, shape, position, "circle_outline", 1.5),
+ backgroundFillColor,
+ cardinalityZeroCircleScale,
),
];
}
- case "crowfoot_one":
- return generateCrowfootOne(arrowheadPoints, options);
case "bar":
case "arrow":
- case "crowfoot_many":
- case "crowfoot_one_or_many":
default: {
- const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
-
- if (element.strokeStyle === "dotted") {
- // for dotted arrows caps, reduce gap to make it more legible
- const dash = getDashArrayDotted(element.strokeWidth - 1);
- options.strokeLineDash = [dash[0], dash[1] - 1];
- } else {
- // for solid/dashed, keep solid arrow cap
- delete options.strokeLineDash;
- }
- options.roughness = Math.min(1, options.roughness || 0);
- return [
- generator.line(x3, y3, x2, y2, options),
- generator.line(x4, y4, x2, y2, options),
- ...(arrowhead === "crowfoot_one_or_many"
- ? generateCrowfootOne(
- getArrowheadPoints(element, shape, position, "crowfoot_one"),
- options,
- )
- : []),
- ];
+ return generateArrowheadLinesToTip(
+ generator,
+ getArrowheadPoints(element, shape, position, arrowhead),
+ getArrowheadLineOptions(element, options),
+ );
}
}
};
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
-) => {
+ elementsMap: ElementsMap,
+): {
+ op: string;
+ data: number[];
+}[] => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
@@ -455,20 +596,7 @@ export const generateLinearCollisionShape = (
roughness: 0,
preserveVertices: true,
};
- const center = getCenterForBounds(
- // Need a non-rotated center point
- element.points.reduce(
- (acc, point) => {
- return [
- Math.min(element.x + point[0], acc[0]),
- Math.min(element.y + point[1], acc[1]),
- Math.max(element.x + point[0], acc[2]),
- Math.max(element.y + point[1], acc[3]),
- ];
- },
- [Infinity, Infinity, -Infinity, -Infinity],
- ),
- );
+ const center = elementCenterPoint(element, elementsMap);
switch (element.type) {
case "line":
diff --git a/packages/element/src/sortElements.ts b/packages/element/src/sortElements.ts
index c98ff9d523..0f9e8da0f1 100644
--- a/packages/element/src/sortElements.ts
+++ b/packages/element/src/sortElements.ts
@@ -1,59 +1,56 @@
-import { arrayToMapWithIndex } from "@excalidraw/common";
+import { arrayToMap } from "@excalidraw/common";
import type { ExcalidrawElement } from "./types";
-const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
- const origElements: ExcalidrawElement[] = elements.slice();
- const sortedElements = new Set();
-
- 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 defragmentGroups = (elements: readonly ExcalidrawElement[]) => {
+ const groupIdAtLevel = (element: ExcalidrawElement, level: number) => {
+ return element.groupIds[element.groupIds.length - level - 1];
};
- const groupHandledElements = new Map();
+ const orderLevel = (
+ levelElements: readonly ExcalidrawElement[],
+ level: number,
+ ): ExcalidrawElement[] => {
+ const buckets = new Map();
+ // Slots preserve first-occurrence order: a groupId reserves its slot
+ // the first time one of its members is seen; loose elements occupy
+ // their own slot. Groups are then expanded (and recursed into) in place.
+ const slots: (ExcalidrawElement | string)[] = [];
- 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);
+ for (const element of levelElements) {
+ const groupId = groupIdAtLevel(element, level);
+ if (groupId === undefined) {
+ slots.push(element);
+ continue;
}
- } else {
- sortedElements.add(element);
+ let bucket = buckets.get(groupId);
+ if (!bucket) {
+ bucket = [];
+ buckets.set(groupId, bucket);
+ slots.push(groupId);
+ }
+ bucket.push(element);
}
- });
+
+ return slots.flatMap((slot) =>
+ typeof slot === "string"
+ ? orderLevel(buckets.get(slot)!, level + 1)
+ : [slot],
+ );
+ };
+
+ // `groupIds` is stored innermost-first, so the outermost group is the
+ // last entry. We recurse from level 0 (outermost) inward.
+ const sortedElements = orderLevel(elements, 0);
// 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!");
+ if (sortedElements.length !== elements.length) {
+ console.error("defragmentGroups: lost some elements... bailing!");
return elements;
}
- return [...sortedElements];
+ return sortedElements;
};
/**
@@ -68,39 +65,40 @@ const normalizeGroupElementOrder = (elements: readonly ExcalidrawElement[]) => {
const normalizeBoundElementsOrder = (
elements: readonly ExcalidrawElement[],
) => {
- const elementsMap = arrayToMapWithIndex(elements);
+ const elementsMap = arrayToMap(elements);
- const origElements: (ExcalidrawElement | null)[] = elements.slice();
const sortedElements = new Set();
- origElements.forEach((element, idx) => {
- if (!element) {
- return;
+ for (const element of elements) {
+ if (sortedElements.has(element)) {
+ continue;
}
+
if (element.boundElements?.length) {
sortedElements.add(element);
- origElements[idx] = null;
- element.boundElements.forEach((boundElement) => {
+ for (const boundElement of element.boundElements) {
const child = elementsMap.get(boundElement.id);
if (child && boundElement.type === "text") {
- sortedElements.add(child[0]);
- origElements[child[1]] = null;
+ sortedElements.add(child);
}
- });
- } 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;
+ continue;
}
- });
+
+ // if element has a container and container lists it, skip this element
+ // as it'll be taken care of by the container
+ if (
+ element.type === "text" &&
+ element.containerId &&
+ elementsMap
+ .get(element.containerId)
+ ?.boundElements?.some((el) => el.id === element.id)
+ ) {
+ continue;
+ }
+
+ 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
@@ -117,5 +115,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
- return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
+ return normalizeBoundElementsOrder(defragmentGroups(elements));
};
diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts
index 523a8b8804..9ff53d035c 100644
--- a/packages/element/src/textElement.ts
+++ b/packages/element/src/textElement.ts
@@ -347,6 +347,7 @@ export const getContainerCenter = (
midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
container,
index + 1,
+ elementsMap,
);
}
return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
@@ -441,7 +442,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: {
type: ExcalidrawElementType;
-}) => VALID_CONTAINER_TYPES.has(element.type);
+}): element is ExcalidrawTextContainer =>
+ VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = (
dimension: number,
diff --git a/packages/element/src/textWrapping.ts b/packages/element/src/textWrapping.ts
index 5ec9bb42a9..b580f52ed1 100644
--- a/packages/element/src/textWrapping.ts
+++ b/packages/element/src/textWrapping.ts
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types";
+/**
+ * This module approximates browser-like soft wrapping for Excalidraw text.
+ *
+ * The flow is:
+ * 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
+ * 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
+ * records where each visual line came from in the source text.
+ * 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
+ * that is wider than the available width.
+ * 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
+ * whitespace so the rendered text stays consistent with what users see on canvas.
+ *
+ * Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
+ * that need metadata such as mapping visual lines back to `originalText`
+ * for caret placement or future editor features.
+ */
let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/**
* Breaks the line into the tokens based on the found line break opporutnities.
+ *
+ * Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
+ * their composed variants for wrapping. Any code that needs exact source offsets should
+ * keep in mind that this assumes the input text is already NFC-normalized.
*/
export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/**
* Wraps the original text into the lines based on the given width.
+ *
+ * This is a convenience adapter over `getWrappedTextLines()` for call sites
+ * that only need the rendered wrapped string and not the source offsets.
*/
export const wrapText = (
text: string,
font: FontString,
maxWidth: number,
): string => {
+ return getWrappedTextLines(text, font, maxWidth)
+ .map((line) => line.text)
+ .join("\n");
+};
+
+/**
+ * A single rendered visual line produced from the original text.
+ *
+ * `start` and `end` are end-exclusive code-unit offsets into the original text, and do
+ * not include synthetic soft line breaks inserted by this module. If trailing whitespace
+ * was trimmed away at a wrap boundary, `end` points to the last rendered character.
+ */
+export type WrappedTextLine = {
+ text: string;
+ start: number;
+ end: number;
+};
+
+/**
+ * Splits only on existing hard line breaks and preserves original offsets.
+ */
+const getHardLineBreaks = (text: string): WrappedTextLine[] => {
+ let offset = 0;
+
+ return text.split("\n").map((line) => {
+ const start = offset;
+ const end = start + line.length;
+
+ offset = end + 1;
+
+ return {
+ text: line,
+ start,
+ end,
+ };
+ });
+};
+
+/**
+ * Returns the rendered visual lines together with their source offsets.
+ *
+ * This is the source-of-truth wrapping pipeline for callers that need more than the
+ * final wrapped string, for example caret placement or future editor/rich-text mapping.
+ */
+export const getWrappedTextLines = (
+ text: string,
+ font: FontString,
+ maxWidth: number,
+): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up
// in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) {
- return text;
+ return getHardLineBreaks(text);
}
- const lines: Array = [];
- const originalLines = text.split("\n");
+ const lines: WrappedTextLine[] = [];
+ let offset = 0;
- for (const originalLine of originalLines) {
- const currentLineWidth = getLineWidth(originalLine, font);
+ for (const originalLine of text.split("\n")) {
+ const originalLineWidth = getLineWidth(originalLine, font);
- if (currentLineWidth <= maxWidth) {
- lines.push(originalLine);
- continue;
+ if (originalLineWidth <= maxWidth) {
+ lines.push({
+ text: originalLine,
+ start: offset,
+ end: offset + originalLine.length,
+ });
+ } else {
+ lines.push(...wrapLine(originalLine, font, maxWidth, offset));
}
- const wrappedLine = wrapLine(originalLine, font, maxWidth);
- lines.push(...wrappedLine);
+ offset += originalLine.length + 1;
}
- return lines.join("\n");
+ return lines;
};
/**
- * Wraps the original line into the lines based on the given width.
+ * Wraps a single hard line into one or more visual lines.
+ *
+ * The line-local offsets are tracked in original-text code units so
+ * we can map the visual line back to the source.
*/
const wrapLine = (
line: string,
font: FontString,
maxWidth: number,
-): string[] => {
- const lines: Array = [];
+ lineStart: number,
+): WrappedTextLine[] => {
+ const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line);
- const tokenIterator = tokens[Symbol.iterator]();
let currentLine = "";
+ let currentLineStart = lineStart;
+ let currentLineEnd = lineStart;
let currentLineWidth = 0;
+ // Tracks the next token's code-unit position in the original source string.
+ let tokenOffset = lineStart;
+ let tokenIndex = 0;
- let iterator = tokenIterator.next();
-
- while (!iterator.done) {
- const token = iterator.value;
+ while (tokenIndex < tokens.length) {
+ const token = tokens[tokenIndex];
+ const tokenStart = tokenOffset;
+ const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) {
+ if (!currentLine) {
+ currentLineStart = tokenStart;
+ }
currentLine = testLine;
+ currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth;
- iterator = tokenIterator.next();
+ tokenOffset = tokenEnd;
+ tokenIndex++;
continue;
}
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) {
- const wrappedWord = wrapWord(token, font, maxWidth);
- const trailingLine = wrappedWord[wrappedWord.length - 1] ?? "";
+ const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
+ const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
+ text: "",
+ start: tokenStart,
+ end: tokenStart,
+ };
const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s
- currentLine = trailingLine;
- currentLineWidth = getLineWidth(trailingLine, font);
- iterator = tokenIterator.next();
+ currentLine = trailingLine.text;
+ currentLineStart = trailingLine.start;
+ currentLineEnd = trailingLine.end;
+ currentLineWidth = getLineWidth(trailingLine.text, font);
+ tokenOffset = tokenEnd;
+ tokenIndex++;
} else {
// push & reset, but don't iterate on the next token, as we didn't use it yet!
- lines.push(currentLine.trimEnd());
+ lines.push(
+ trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
+ );
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = "";
+ currentLineStart = tokenStart;
+ currentLineEnd = tokenStart;
currentLineWidth = 0;
}
}
// iterator done, push the trailing line if exists
if (currentLine) {
- const trailingLine = trimLine(currentLine, font, maxWidth);
+ const trailingLine = trimLine(
+ currentLine,
+ currentLineStart,
+ currentLineEnd,
+ font,
+ maxWidth,
+ );
lines.push(trailingLine);
}
@@ -467,59 +573,100 @@ const wrapLine = (
};
/**
- * Wraps the word into the lines based on the given width.
+ * Wraps a single word that could not be placed on an empty line as-is.
*/
const wrapWord = (
word: string,
font: FontString,
maxWidth: number,
-): Array => {
+ wordStart: number,
+): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) {
- return [word];
+ return [
+ {
+ text: word,
+ start: wordStart,
+ end: wordStart + word.length,
+ },
+ ];
}
satisfiesWordInvariant(word);
- const lines: Array = [];
+ const lines: WrappedTextLine[] = [];
const chars = Array.from(word);
let currentLine = "";
+ let currentLineStart = wordStart;
+ let currentLineEnd = wordStart;
let currentLineWidth = 0;
+ let offset = wordStart;
for (const char of chars) {
+ const charStart = offset;
+ const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) {
+ if (!currentLine) {
+ currentLineStart = charStart;
+ }
currentLine = currentLine + char;
+ currentLineEnd = charEnd;
currentLineWidth = testLineWidth;
+ offset = charEnd;
continue;
}
if (currentLine) {
- lines.push(currentLine);
+ lines.push({
+ text: currentLine,
+ start: currentLineStart,
+ end: currentLineEnd,
+ });
}
currentLine = char;
+ currentLineStart = charStart;
+ currentLineEnd = charEnd;
currentLineWidth = _charWidth;
+ offset = charEnd;
}
if (currentLine) {
- lines.push(currentLine);
+ lines.push({
+ text: currentLine,
+ start: currentLineStart,
+ end: currentLineEnd,
+ });
}
return lines;
};
/**
- * Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`.
+ * Trims trailing whitespace that is exceeding the `maxWidth`.
+ *
+ * Used for the trailing visual line of a hard line, where some trailing
+ * whitespace may still be visible if it fits into the available width.
*/
-const trimLine = (line: string, font: FontString, maxWidth: number) => {
+const trimLine = (
+ line: string,
+ start: number,
+ end: number,
+ font: FontString,
+ maxWidth: number,
+): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) {
- return line;
+ return {
+ text: line,
+ start,
+ end,
+ };
}
// defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth;
}
- return trimmedLine;
+ return {
+ text: trimmedLine,
+ start,
+ end: end - (line.length - trimmedLine.length),
+ };
+};
+
+/**
+ * Used for internal soft-wrap boundaries, where trailing whitespace should not
+ * survive into the rendered line even though it still exists in the original
+ * text.
+ */
+const trimLineEndAtSoftBreak = (
+ line: string,
+ start: number,
+ end: number,
+): WrappedTextLine => {
+ const trimmedLine = line.trimEnd();
+
+ return {
+ text: trimmedLine,
+ start,
+ end: end - (line.length - trimmedLine.length),
+ };
};
/**
diff --git a/packages/element/src/typeChecks.ts b/packages/element/src/typeChecks.ts
index b609cc3f8a..3a8f5e36ef 100644
--- a/packages/element/src/typeChecks.ts
+++ b/packages/element/src/typeChecks.ts
@@ -392,3 +392,23 @@ export const canBecomePolygon = (
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
);
};
+
+export const isEligibleFrameChildType = (type: ElementOrToolType) => {
+ switch (type) {
+ case "rectangle":
+ case "diamond":
+ case "ellipse":
+ case "arrow":
+ case "line":
+ case "freedraw":
+ case "text":
+ case "image":
+ case "frame":
+ case "embeddable": {
+ return true;
+ }
+ default: {
+ return false;
+ }
+ }
+};
diff --git a/packages/element/src/types.ts b/packages/element/src/types.ts
index 5d39fd7097..138a60e1c7 100644
--- a/packages/element/src/types.ts
+++ b/packages/element/src/types.ts
@@ -15,7 +15,7 @@ import type {
ValueOf,
} from "@excalidraw/common/utility-types";
-export type ChartType = "bar" | "line";
+export type ChartType = "bar" | "line" | "radar";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
export type FontFamilyKeys = keyof typeof FONT_FAMILY;
export type FontFamilyValues = typeof FONT_FAMILY[FontFamilyKeys];
@@ -303,19 +303,32 @@ export type PointsPositionUpdates = Map<
{ point: LocalPoint; isDragging?: boolean }
>;
+export type CardinalityArrowhead =
+ | "cardinality_one"
+ | "cardinality_many"
+ | "cardinality_one_or_many"
+ | "cardinality_exactly_one"
+ | "cardinality_zero_or_one"
+ | "cardinality_zero_or_many";
+
+export type ArrowheadLegacy =
+ | "dot"
+ | "crowfoot_one"
+ | "crowfoot_many"
+ | "crowfoot_one_or_many";
+
export type Arrowhead =
| "arrow"
| "bar"
- | "dot" // legacy. Do not use for new elements.
| "circle"
| "circle_outline"
| "triangle"
| "triangle_outline"
| "diamond"
| "diamond_outline"
- | "crowfoot_one"
- | "crowfoot_many"
- | "crowfoot_one_or_many";
+ | CardinalityArrowhead;
+
+export type AnyArrowhead = Arrowhead | ArrowheadLegacy;
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{
diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts
index ee341b310a..819cad562f 100644
--- a/packages/element/src/utils.ts
+++ b/packages/element/src/utils.ts
@@ -43,6 +43,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding";
+import {
+ getGlobalFixedPointForBindableElement,
+ normalizeFixedPoint,
+} from "./binding";
+
import type {
ElementsMap,
ExcalidrawArrowElement,
@@ -119,6 +124,7 @@ const setElementShapesCacheEntry = (
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+ elementsMap: ElementsMap,
): [LineSegment[], Curve[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -126,10 +132,7 @@ export function deconstructLinearOrFreeDrawElement(
return cachedShape;
}
- const ops = generateLinearCollisionShape(element) as {
- op: string;
- data: number[];
- }[];
+ const ops = generateLinearCollisionShape(element, elementsMap);
const lines = [];
const curves = [];
@@ -654,20 +657,23 @@ export const projectFixedPointOntoDiagonal = (
startOrEnd: "start" | "end",
elementsMap: ElementsMap,
zoom: AppState["zoom"],
+ isMidpointSnappingEnabled: boolean = true,
): GlobalPoint | null => {
invariant(arrow.points.length >= 2, "Arrow must have at least two points");
if (arrow.width < 3 && arrow.height < 3) {
return null;
}
- const sideMidPoint = getSnapOutlineMidPoint(
- point,
- element,
- elementsMap,
- zoom,
- );
- if (sideMidPoint) {
- return sideMidPoint;
+ if (isMidpointSnappingEnabled) {
+ const sideMidPoint = getSnapOutlineMidPoint(
+ point,
+ element,
+ elementsMap,
+ zoom,
+ );
+ if (sideMidPoint) {
+ return sideMidPoint;
+ }
}
// Do the projection onto the diagonals (or center lines
@@ -677,11 +683,35 @@ export const projectFixedPointOntoDiagonal = (
elementsMap,
);
- const a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ // To avoid working with stale arrow state, we use the opposite focus point
+ // of the current endpoint, which will always be unchanged during moving of
+ // the endpoint. This is only needed when the arrow has only two points.
+ let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap,
);
+ if (arrow.points.length === 2) {
+ const otherBinding =
+ startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
+ const otherBindable =
+ otherBinding &&
+ (elementsMap.get(otherBinding.elementId) as
+ | ExcalidrawBindableElement
+ | undefined);
+ const otherFocusPoint =
+ otherBinding &&
+ otherBindable &&
+ getGlobalFixedPointForBindableElement(
+ normalizeFixedPoint(otherBinding.fixedPoint),
+ otherBindable,
+ elementsMap,
+ );
+ if (otherFocusPoint) {
+ a = otherFocusPoint;
+ }
+ }
+
const b = pointFromVector(
vectorScale(
vectorFromPoint(point, a),
diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx
index 4061a16cb6..a44f1f7bb0 100644
--- a/packages/element/tests/collision.test.tsx
+++ b/packages/element/tests/collision.test.tsx
@@ -1,4 +1,4 @@
-import { arrayToMap } from "@excalidraw/common";
+import { arrayToMap, reseed } from "@excalidraw/common";
import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
import { Excalidraw } from "@excalidraw/excalidraw";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
@@ -12,6 +12,7 @@ import { hitElementItself } from "../src/collision";
describe("check rotated elements can be hit:", () => {
beforeEach(async () => {
localStorage.clear();
+ reseed(7);
await render();
});
@@ -56,6 +57,7 @@ describe("hitElementItself cache", () => {
});
localStorage.clear();
+ reseed(7);
await render();
});
diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts
index 7f585e866f..35870a86f2 100644
--- a/packages/element/tests/embeddable.test.ts
+++ b/packages/element/tests/embeddable.test.ts
@@ -1,4 +1,4 @@
-import { getEmbedLink } from "../src/embeddable";
+import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => {
}
});
});
+
+describe("Google Drive video embedding", () => {
+ it.each([
+ {
+ url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
+ expectedLink:
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
+ },
+ {
+ url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
+ expectedLink:
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
+ },
+ {
+ url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
+ expectedLink:
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
+ },
+ ])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
+ const result = getEmbedLink(url);
+
+ expect(result).toBeTruthy();
+ expect(result?.type).toBe("video");
+ if (result?.type === "video" || result?.type === "generic") {
+ expect(result.link).toBe(expectedLink);
+ }
+ expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
+ });
+
+ it("should preserve resourcekey when available", () => {
+ const url =
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
+ const result = getEmbedLink(url);
+
+ expect(result).toBeTruthy();
+ expect(result?.type).toBe("video");
+ if (result?.type === "video" || result?.type === "generic") {
+ expect(result.link).toBe(
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
+ );
+ }
+ });
+
+ it("should preserve timestamp when available", () => {
+ const url =
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
+ const result = getEmbedLink(url);
+
+ expect(result).toBeTruthy();
+ expect(result?.type).toBe("video");
+ if (result?.type === "video" || result?.type === "generic") {
+ expect(result.link).toBe(
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
+ );
+ }
+ });
+
+ it("should preserve resourcekey and timestamp together", () => {
+ const url =
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
+ const result = getEmbedLink(url);
+
+ expect(result).toBeTruthy();
+ expect(result?.type).toBe("video");
+ if (result?.type === "video" || result?.type === "generic") {
+ expect(result.link).toBe(
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
+ );
+ }
+ });
+
+ it("should validate Google Drive domain by default", () => {
+ expect(
+ embeddableURLValidator(
+ "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
+ undefined,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/packages/element/tests/fractionalIndex.test.ts b/packages/element/tests/fractionalIndex.test.ts
index 1cc3ca5af3..2834a831e1 100644
--- a/packages/element/tests/fractionalIndex.test.ts
+++ b/packages/element/tests/fractionalIndex.test.ts
@@ -1,9 +1,8 @@
/* eslint-disable no-lone-blocks */
-import { generateKeyBetween } from "fractional-indexing";
-
import { arrayToMap } from "@excalidraw/common";
import {
+ InvalidFractionalIndexError,
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
@@ -13,13 +12,34 @@ import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
+import {
+ generateKeyBetween,
+ validateOrderKey,
+} from "@excalidraw/fractional-indexing";
+
import type {
ElementsMap,
ExcalidrawElement,
FractionalIndex,
} from "@excalidraw/element/types";
-import { InvalidFractionalIndexError } from "../src/fractionalIndex";
+describe("fractional index format validation", () => {
+ it("should reject malformed base62 order keys", () => {
+ expect(() => validateOrderKey("a!")).toThrow();
+ expect(() => validateOrderKey("a_")).toThrow();
+ expect(() => validateOrderKey("a1!")).toThrow();
+ expect(() => validateOrderKey("a1_")).toThrow();
+ expect(() => validateOrderKey("zd0032")).toThrow();
+ });
+
+ it("should accept valid base62 order keys", () => {
+ expect(() => validateOrderKey("Zz")).not.toThrow();
+ expect(() => validateOrderKey("a0")).not.toThrow();
+ expect(() => validateOrderKey("a1")).not.toThrow();
+ expect(() => validateOrderKey("a1V")).not.toThrow();
+ expect(() => validateOrderKey("z".padEnd(28, "z"))).not.toThrow();
+ });
+});
describe("sync invalid indices with array order", () => {
describe("should NOT sync empty array", () => {
@@ -104,6 +124,46 @@ describe("sync invalid indices with array order", () => {
});
});
+ describe("should sync when fractional index is malformed", () => {
+ // "zd0032" has head "z" which requires length 28 per getIntegerLength,
+ // but the string is far too short, so validateOrderKey throws for it
+ testInvalidIndicesSync({
+ elements: [{ id: "A", index: "zd0032" }],
+ expect: {
+ unchangedElements: [],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "zd0032" },
+ { id: "C", index: "a3" },
+ ],
+ expect: {
+ unchangedElements: ["A", "C"],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [{ id: "A", index: "a!" }],
+ expect: {
+ unchangedElements: [],
+ },
+ });
+
+ testInvalidIndicesSync({
+ elements: [
+ { id: "A", index: "a1" },
+ { id: "B", index: "a!" },
+ { id: "C", index: "a2" },
+ ],
+ expect: {
+ unchangedElements: ["A", "C"],
+ },
+ });
+ });
+
describe("should sync when fractional indices are duplicated", () => {
testInvalidIndicesSync({
elements: [
diff --git a/packages/element/tests/frame.test.tsx b/packages/element/tests/frame.test.tsx
index 47f2160ac3..b419d00f02 100644
--- a/packages/element/tests/frame.test.tsx
+++ b/packages/element/tests/frame.test.tsx
@@ -2,15 +2,24 @@ import {
convertToExcalidrawElements,
Excalidraw,
} from "@excalidraw/excalidraw";
+import { arrayToMap } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
-import { Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
+import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
+import { getTextEditor } from "@excalidraw/excalidraw/tests/queries/dom";
import {
getCloneByOrigId,
render,
} from "@excalidraw/excalidraw/tests/test-utils";
-import type { ExcalidrawElement } from "../src/types";
+import { getSelectedElements } from "@excalidraw/excalidraw/scene";
+
+import { elementOverlapsWithFrame } from "../src/frame";
+
+import type {
+ ExcalidrawElement,
+ ExcalidrawFrameLikeElement,
+} from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -125,6 +134,250 @@ describe("adding elements to frames", () => {
});
});
+ it("should treat an element fully containing a frame as overlapping the frame", () => {
+ const containingRect = API.createElement({
+ type: "rectangle",
+ x: -50,
+ y: -50,
+ width: 250,
+ height: 250,
+ });
+
+ API.setElements([containingRect, frame]);
+
+ expect(
+ elementOverlapsWithFrame(
+ containingRect,
+ frame as ExcalidrawFrameLikeElement,
+ arrayToMap(h.elements),
+ ),
+ ).toBe(true);
+ });
+
+ it("should not add a newly created element to a frame behind a non-frame element", () => {
+ const cover = API.createElement({
+ id: "cover",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ });
+
+ API.setElements([frame, cover]);
+
+ UI.clickTool("rectangle");
+ mouse.downAt(20, 20);
+ mouse.moveTo(40, 40);
+ mouse.upAt(40, 40);
+
+ const createdElement = h.elements.find(
+ (element) => element.id !== frame.id && element.id !== cover.id,
+ );
+
+ expect(createdElement?.frameId).toBe(null);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ frame.id,
+ cover.id,
+ createdElement?.id,
+ ]);
+ });
+
+ it("should add a newly created element to a frame over a non-frame element", () => {
+ const cover = API.createElement({
+ id: "cover",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ });
+
+ API.setElements([cover, frame]);
+
+ UI.clickTool("rectangle");
+ mouse.downAt(20, 20);
+ mouse.moveTo(40, 40);
+ mouse.upAt(40, 40);
+
+ const createdElement = h.elements.find(
+ (element) => element.id !== frame.id && element.id !== cover.id,
+ );
+
+ expect(createdElement?.frameId).toBe(frame.id);
+ });
+
+ it("should highlight the target frame while creating a new element", () => {
+ API.setElements([frame]);
+
+ UI.clickTool("rectangle");
+ mouse.downAt(20, 20);
+ mouse.moveTo(40, 40);
+
+ expect(h.state.frameToHighlight?.id).toBe(frame.id);
+
+ mouse.upAt(40, 40);
+
+ expect(h.state.frameToHighlight).toBe(null);
+ });
+
+ it("should highlight the target frame while hovering with a creation tool", () => {
+ API.setElements([frame]);
+
+ UI.clickTool("rectangle");
+ mouse.moveTo(20, 20);
+
+ expect(h.state.frameToHighlight?.id).toBe(frame.id);
+
+ mouse.moveTo(200, 200);
+
+ expect(h.state.frameToHighlight).toBe(null);
+ });
+
+ it("should not add grid-snapped text outside the frame to the clicked frame", async () => {
+ const offsetFrame = API.createElement({
+ id: "offsetFrame",
+ type: "frame",
+ x: 10,
+ y: 0,
+ width: 150,
+ height: 150,
+ });
+
+ API.setElements([offsetFrame]);
+ API.setAppState({
+ gridModeEnabled: true,
+ });
+
+ UI.clickTool("text");
+ mouse.clickAt(12, 0);
+
+ await getTextEditor();
+
+ const createdText = h.elements.find(
+ (element) => element.id !== offsetFrame.id,
+ );
+
+ expect(createdText?.x).toBe(0);
+ expect(createdText?.y).toBe(0);
+ expect(createdText?.frameId).toBe(null);
+ });
+
+ it("should add a newly created element to a frame behind another frame", () => {
+ const lockedFrame = API.createElement({
+ id: "lockedFrame",
+ type: "frame",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ locked: true,
+ });
+
+ API.setElements([frame, lockedFrame]);
+
+ UI.clickTool("rectangle");
+ mouse.downAt(20, 20);
+ mouse.moveTo(40, 40);
+ mouse.upAt(40, 40);
+
+ const createdElement = h.elements.find(
+ (element) => element.id !== frame.id && element.id !== lockedFrame.id,
+ );
+
+ expect(createdElement?.frameId).toBe(frame.id);
+ });
+
+ it("should insert a newly created frame child just below its frame", () => {
+ const frameChildUnderCursor = API.createElement({
+ id: "frameChildUnderCursor",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ frameId: frame.id,
+ });
+ const otherFrameChild = API.createElement({
+ id: "otherFrameChild",
+ type: "rectangle",
+ x: 100,
+ y: 20,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([frameChildUnderCursor, otherFrameChild, frame]);
+
+ UI.clickTool("rectangle");
+ mouse.downAt(20, 20);
+ mouse.moveTo(40, 40);
+ mouse.upAt(40, 40);
+
+ const createdElement = h.elements.find(
+ (element) =>
+ element.id !== frame.id &&
+ element.id !== frameChildUnderCursor.id &&
+ element.id !== otherFrameChild.id,
+ );
+
+ expect(createdElement?.frameId).toBe(frame.id);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ frameChildUnderCursor.id,
+ otherFrameChild.id,
+ createdElement?.id,
+ frame.id,
+ ]);
+ });
+
+ it("should insert a newly created frame child above the highest frame child", () => {
+ const frameChildUnderCursor = API.createElement({
+ id: "frameChildUnderCursor",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ frameId: frame.id,
+ });
+ const otherFrameChild = API.createElement({
+ id: "otherFrameChild",
+ type: "rectangle",
+ x: 100,
+ y: 20,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([frame, frameChildUnderCursor, otherFrameChild]);
+
+ UI.clickTool("rectangle");
+ mouse.downAt(20, 20);
+ mouse.moveTo(40, 40);
+ mouse.upAt(40, 40);
+
+ const createdElement = h.elements.find(
+ (element) =>
+ element.id !== frame.id &&
+ element.id !== frameChildUnderCursor.id &&
+ element.id !== otherFrameChild.id,
+ );
+
+ expect(createdElement?.frameId).toBe(frame.id);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ frame.id,
+ frameChildUnderCursor.id,
+ otherFrameChild.id,
+ createdElement?.id,
+ ]);
+ });
+
const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
@@ -415,6 +668,345 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
+ it("should add a dragged element fully containing the frame", () => {
+ const containingRect = API.createElement({
+ type: "rectangle",
+ x: 220,
+ y: 20,
+ width: 300,
+ height: 300,
+ });
+
+ API.setElements([frame, containingRect]);
+
+ dragElementIntoFrame(frame, containingRect);
+
+ expect(API.getElement(containingRect).frameId).toBe(frame.id);
+ });
+
+ it("should drag an element into a frame", () => {
+ API.setElements([rect2, frame]);
+
+ dragElementIntoFrame(frame, rect2);
+
+ expect(rect2.frameId).toBe(frame.id);
+ });
+
+ it("should layer a dragged element above the highest frame child", () => {
+ const frameChild = API.createElement({
+ id: "frameChild",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([frame, frameChild, rect2]);
+
+ dragElementIntoFrame(frame, rect2);
+
+ expect(rect2.frameId).toBe(frame.id);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ frame.id,
+ frameChild.id,
+ rect2.id,
+ ]);
+ expect(rect2.index! > frameChild.index!).toBe(true);
+ expect(rect2.index! > frame.index!).toBe(true);
+ });
+
+ it("should preview a dragged element above the highest frame child before pointerup", () => {
+ const frameChild = API.createElement({
+ id: "frameChild",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([rect2, frame, frameChild]);
+ API.setSelectedElements([rect2]);
+ API.updateElement(rect2, {
+ x: 10,
+ y: 10,
+ });
+
+ const getRenderableElementIds = (
+ selectedElementsAreBeingDragged: boolean,
+ ) => {
+ return h.app.renderer
+ .getRenderableElements({
+ zoom: h.state.zoom,
+ offsetLeft: 0,
+ offsetTop: 0,
+ scrollX: 0,
+ scrollY: 0,
+ height: 1000,
+ width: 1000,
+ editingTextElement: h.state.editingTextElement,
+ newElement: h.state.newElement,
+ selectedElements: getSelectedElements(h.elements, h.state),
+ selectedElementsAreBeingDragged,
+ frameToHighlight: frame as ExcalidrawFrameLikeElement,
+ })
+ .visibleElements.map((element) => element.id);
+ };
+
+ expect(h.elements.map((element) => element.id)).toEqual([
+ rect2.id,
+ frame.id,
+ frameChild.id,
+ ]);
+ expect(getRenderableElementIds(false)).toEqual([
+ rect2.id,
+ frame.id,
+ frameChild.id,
+ ]);
+ expect(getRenderableElementIds(true)).toEqual([
+ frame.id,
+ frameChild.id,
+ rect2.id,
+ ]);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ rect2.id,
+ frame.id,
+ frameChild.id,
+ ]);
+ expect(rect2.frameId).toBe(null);
+ });
+
+ it("should not preview reorder dragged elements already in the highlighted frame", () => {
+ const frameChild = API.createElement({
+ id: "frameChild",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+ const otherFrameChild = API.createElement({
+ id: "otherFrameChild",
+ type: "rectangle",
+ x: 40,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([frameChild, frame, otherFrameChild]);
+ API.setSelectedElements([frameChild]);
+
+ const renderableElementIds = h.app.renderer
+ .getRenderableElements({
+ zoom: h.state.zoom,
+ offsetLeft: 0,
+ offsetTop: 0,
+ scrollX: 0,
+ scrollY: 0,
+ height: 1000,
+ width: 1000,
+ editingTextElement: h.state.editingTextElement,
+ newElement: h.state.newElement,
+ selectedElements: getSelectedElements(h.elements, h.state),
+ selectedElementsAreBeingDragged: true,
+ frameToHighlight: frame as ExcalidrawFrameLikeElement,
+ })
+ .visibleElements.map((element) => element.id);
+
+ expect(renderableElementIds).toEqual([
+ frameChild.id,
+ frame.id,
+ otherFrameChild.id,
+ ]);
+ });
+
+ it("should put a dragged mixed selection above the highest frame child", () => {
+ const frameChild = API.createElement({
+ id: "frameChild",
+ type: "rectangle",
+ x: 50,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ boundElements: [{ id: "boundText", type: "text" }],
+ });
+ const boundText = API.createElement({
+ id: "boundText",
+ type: "text",
+ x: 50,
+ y: 10,
+ width: 20,
+ height: 20,
+ containerId: frameChild.id,
+ frameId: frame.id,
+ });
+ const otherFrameChild = API.createElement({
+ id: "otherFrameChild",
+ type: "rectangle",
+ x: 80,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+ const nonFrameElement = API.createElement({
+ id: "nonFrameElement",
+ type: "rectangle",
+ x: 155,
+ y: 10,
+ width: 20,
+ height: 20,
+ });
+
+ API.setElements([
+ frame,
+ frameChild,
+ boundText,
+ otherFrameChild,
+ nonFrameElement,
+ ]);
+ API.setSelectedElements([frameChild, nonFrameElement]);
+
+ mouse.downAt(
+ nonFrameElement.x + nonFrameElement.width / 2,
+ nonFrameElement.y + nonFrameElement.height / 2,
+ );
+ mouse.moveTo(frame.x + frame.width - 5, nonFrameElement.y + 10);
+ mouse.up();
+
+ expect(frameChild.frameId).toBe(frame.id);
+ expect(boundText.frameId).toBe(frame.id);
+ expect(nonFrameElement.frameId).toBe(frame.id);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ frame.id,
+ otherFrameChild.id,
+ frameChild.id,
+ boundText.id,
+ nonFrameElement.id,
+ ]);
+ });
+
+ it("should not reorder dragged elements already in the highlighted frame", () => {
+ const frameChild = API.createElement({
+ id: "frameChild",
+ type: "rectangle",
+ x: 50,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+ const otherFrameChild = API.createElement({
+ id: "otherFrameChild",
+ type: "rectangle",
+ x: 80,
+ y: 10,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([frame, frameChild, otherFrameChild]);
+ API.setSelectedElements([frameChild]);
+
+ mouse.downAt(
+ frameChild.x + frameChild.width / 2,
+ frameChild.y + frameChild.height / 2,
+ );
+ mouse.moveTo(frameChild.x + frameChild.width / 2 + 5, frameChild.y + 10);
+ mouse.up();
+
+ expect(frameChild.frameId).toBe(frame.id);
+ expect(h.elements.map((element) => element.id)).toEqual([
+ frame.id,
+ frameChild.id,
+ otherFrameChild.id,
+ ]);
+ });
+
+ it("should not drag an element into a frame behind a non-frame element", () => {
+ const cover = API.createElement({
+ id: "cover",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ });
+ API.setElements([frame, cover, rect2]);
+
+ mouse.clickAt(rect2.x, rect2.y);
+ mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
+ mouse.moveTo(20, 20);
+ mouse.upAt(20, 20);
+
+ expect(rect2.frameId).toBe(null);
+ });
+
+ it("should drag an element into a frame over a non-frame element", () => {
+ const cover = API.createElement({
+ id: "cover",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ });
+ API.setElements([cover, rect2, frame]);
+
+ mouse.clickAt(rect2.x, rect2.y);
+ mouse.downAt(rect2.x + rect2.width / 2, rect2.y + rect2.height / 2);
+ mouse.moveTo(20, 20);
+ mouse.upAt(20, 20);
+
+ expect(rect2.frameId).toBe(frame.id);
+ });
+
+ it("should keep dragging a frame child over a non-frame element above its frame", () => {
+ const cover = API.createElement({
+ id: "cover",
+ type: "rectangle",
+ x: 10,
+ y: 10,
+ width: 80,
+ height: 80,
+ backgroundColor: "#ffc9c9",
+ });
+ const frameChild = API.createElement({
+ id: "frameChild",
+ type: "rectangle",
+ x: 100,
+ y: 20,
+ width: 20,
+ height: 20,
+ frameId: frame.id,
+ });
+
+ API.setElements([frameChild, frame, cover]);
+ API.setSelectedElements([frameChild]);
+
+ mouse.downAt(
+ frameChild.x + frameChild.width / 2,
+ frameChild.y + frameChild.height / 2,
+ );
+ mouse.moveTo(20, 20);
+
+ expect(h.state.frameToHighlight?.id).toBe(frame.id);
+
+ mouse.upAt(20, 20);
+
+ expect(frameChild.frameId).toBe(frame.id);
+ });
+
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
API.setElements([frame, rect2]);
diff --git a/packages/element/tests/sortElements.test.ts b/packages/element/tests/sortElements.test.ts
index 0928b84f29..0554d38e35 100644
--- a/packages/element/tests/sortElements.test.ts
+++ b/packages/element/tests/sortElements.test.ts
@@ -326,19 +326,59 @@ describe("normalizeElementsOrder", () => {
]),
[
"BA_rect1",
+ "CBA_rect3",
+ "CBA_rect7",
"BA_rect5",
"BA_rect6",
"A_rect2",
"A_rect5",
- "CBA_rect3",
- "CBA_rect7",
"rect4",
"X_rect8",
- "X_rect11",
"YX_rect10",
+ "X_rect11",
"rect9",
],
);
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "A_rect1",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ API.createElement({
+ id: "CBA_rect2",
+ type: "rectangle",
+ groupIds: ["C", "B", "A"],
+ }),
+ API.createElement({
+ id: "A_rect3",
+ type: "rectangle",
+ groupIds: ["A"],
+ }),
+ ]),
+ ["A_rect1", "CBA_rect2", "A_rect3"],
+ );
+ assertOrder(
+ normalizeElementOrder([
+ API.createElement({
+ id: "abcT_rect1",
+ type: "rectangle",
+ groupIds: ["ab", "c", "T"],
+ }),
+ API.createElement({
+ id: "abcT_rect2",
+ type: "rectangle",
+ groupIds: ["a", "bc", "T"],
+ }),
+ API.createElement({
+ id: "abcT_rect3",
+ type: "rectangle",
+ groupIds: ["ab", "c", "T"],
+ }),
+ ]),
+ ["abcT_rect1", "abcT_rect3", "abcT_rect2"],
+ );
});
// TODO
diff --git a/packages/element/tests/textWrapping.test.ts b/packages/element/tests/textWrapping.test.ts
index 87c96a4c91..2149dd5622 100644
--- a/packages/element/tests/textWrapping.test.ts
+++ b/packages/element/tests/textWrapping.test.ts
@@ -1,4 +1,8 @@
-import { wrapText, parseTokens } from "../src/textWrapping";
+import {
+ getWrappedTextLines,
+ parseTokens,
+ wrapText,
+} from "../src/textWrapping";
import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
});
+ it("should retain original text offsets for wrapped lines", () => {
+ expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
+ {
+ text: "Hello",
+ start: 0,
+ end: 5,
+ },
+ {
+ text: "World!",
+ start: 6,
+ end: 12,
+ },
+ ]);
+ });
+
+ it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
+ expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
+ {
+ text: " Hello",
+ start: 0,
+ end: 7,
+ },
+ {
+ text: "World",
+ start: 9,
+ end: 14,
+ },
+ ]);
+ });
+
+ it("should retain offsets when wrapping a single long token", () => {
+ expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
+ {
+ text: "Excal",
+ start: 0,
+ end: 5,
+ },
+ {
+ text: "idraw",
+ start: 5,
+ end: 10,
+ },
+ ]);
+ });
+
+ it("should preserve empty hard lines in metadata", () => {
+ expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
+ {
+ text: "A",
+ start: 0,
+ end: 1,
+ },
+ {
+ text: "",
+ start: 2,
+ end: 2,
+ },
+ {
+ text: "B",
+ start: 3,
+ end: 4,
+ },
+ ]);
+ });
+
describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md
index e4a98d19e8..16a98243ee 100644
--- a/packages/excalidraw/CHANGELOG.md
+++ b/packages/excalidraw/CHANGELOG.md
@@ -11,6 +11,87 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section.
-->
+## Unreleased
+
+## Excalidraw API
+
+### Breaking changes
+
+- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
+ - `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
+
+### Features
+
+- Added `ExcalidrawAPI.isDestroyed` flag. Set to `true` once the editor unmounts. Calling any `get*` method, `onStateChange`, or `onEvent` on a destroyed API instance will throw in development and `console.error` in production. The `ExcalidrawAPI` will be reset to `null` on umount, but to be extra safe, you should check `ExcalidrawAPI.isDestroyed` before calling these methods to guard against subtle race conditions in your code.
+
+- Added `onMount`, `onInitialize`, and `onUnmount` props. `onMount` receives `{ excalidrawAPI, container }` once the editor root is mounted. `onInitialize` fires once the initial scene has loaded. `onUnmount` fires just before unmounting.
+
+- Same events are also accessible imperatively through `api.onEvent(...)`.
+
+ ```tsx
+ {
+ api.onEvent("editor:mount", ({ excalidrawAPI, container }) => {
+ console.log(container);
+ });
+
+ api.onEvent("editor:initialize").then((readyApi) => {
+ readyApi.scrollToContent();
+ });
+ }}
+ />
+ ```
+
+ Note that in future releases, most, if not all, `excalidrawAPI.on*` subscriptions will be removed in favor of `excalidrawAPI.onEvent(name)`.
+
+- Also added `"editor:unmount"` lifecycle event, only accessible via `api.onEvent("editor:unmount")`.
+
+- Exported ``, `useExcalidrawAPI()`, `useAppStateValue(prop | props | selectorFunction)`, and `useOnExcalidrawStateChange(prop | props | selectorFunction, callback)` from the package. The imperative API also now exposes `onStateChange(prop | props | selectorFunction, callback?)`, and `onEvent(name, callback)`.
+
+ ```tsx
+
+
+
+ ;
+
+ function Logger() {
+ // initially null before the ExcalidrawAPIProvider initializes ater
+ // renders
+ // When unmounts, is reset back to null
+ const api = useExcalidrawAPI();
+
+ useAppStateValue("viewModeEnabled", (viewModeEnabled) => {
+ console.log("view mode changed:", viewModeEnabled);
+ });
+
+ React.useEffect(() => {
+ if (api) {
+ console.log("editor instance id:", api.id);
+ }
+ }, [api]);
+
+ return null;
+ }
+ ```
+
+- Added `onExport` so host apps can delay JSON export until async work completes. The handler receives the export data plus an `AbortSignal`, and may return a `Promise` or an async generator that yields progress updates for the built-in toast UI.
+
+ ```tsx
+
+ ```
+
## Excalidraw Library
## 0.18.0 (2025-03-11)
diff --git a/packages/excalidraw/README.md b/packages/excalidraw/README.md
index 5185185534..f7cec95f77 100644
--- a/packages/excalidraw/README.md
+++ b/packages/excalidraw/README.md
@@ -1,10 +1,10 @@
# Excalidraw
-**Excalidraw** is exported as a component to be directly embedded in your project.
+**Excalidraw** is exported as a React component that you can embed directly in your app.
## Installation
-Use `npm` or `yarn` to install the package.
+Install the package together with its React peer dependencies.
```bash
npm install react react-dom @excalidraw/excalidraw
@@ -12,34 +12,131 @@ npm install react react-dom @excalidraw/excalidraw
yarn add react react-dom @excalidraw/excalidraw
```
-> **Note**: If you don't want to wait for the next stable release and try out the unreleased changes, use `@excalidraw/excalidraw@next`.
+> **Note**: If you want to try unreleased changes, use `@excalidraw/excalidraw@next`.
-#### Self-hosting fonts
+## Quick start
-By default, Excalidraw will try to download all the used fonts from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
+The minimum working setup has two easy-to-miss requirements:
-For self-hosting purposes, you'll have to copy the content of the folder `node_modules/@excalidraw/excalidraw/dist/prod/fonts` to the path where your assets should be served from (i.e. `public/` directory in your project). In that case, you should also set `window.EXCALIDRAW_ASSET_PATH` to the very same path, i.e. `/` in case it's in the root:
+1. Import the package CSS:
-```js
-
+```ts
+import "@excalidraw/excalidraw/index.css";
```
-### Dimensions of Excalidraw
+2. Render Excalidraw inside a container with a non-zero height.
-Excalidraw takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Excalidraw has non zero dimensions.
+```tsx
+import { Excalidraw } from "@excalidraw/excalidraw";
+import "@excalidraw/excalidraw/index.css";
+
+export default function App() {
+ return (
+
+
+
+ );
+}
+```
+
+Excalidraw fills `100%` of the width and height of its parent. If the parent has no height, the canvas will not be visible.
+
+## Next.js / SSR frameworks
+
+Excalidraw should be rendered on the client. In SSR frameworks such as Next.js, use a client component and load it dynamically with SSR disabled.
+
+```tsx
+// app/components/ExcalidrawClient.tsx
+"use client";
+
+import { Excalidraw } from "@excalidraw/excalidraw";
+import "@excalidraw/excalidraw/index.css";
+
+export default function ExcalidrawClient() {
+ return (
+
+
+
+ );
+}
+```
+
+```tsx
+// app/page.tsx
+import dynamic from "next/dynamic";
+
+const ExcalidrawClient = dynamic(
+ () => import("./components/ExcalidrawClient"),
+ { ssr: false },
+);
+
+export default function Page() {
+ return ;
+}
+```
+
+See the local examples for complete setups:
+
+- [examples/with-nextjs](https://github.com/excalidraw/excalidraw/tree/master/examples/with-nextjs)
+- [examples/with-script-in-browser](https://github.com/excalidraw/excalidraw/tree/master/examples/with-script-in-browser)
+
+## LLM / agent tips
+
+If an LLM or coding agent is setting up Excalidraw, these shortcuts usually save more time than re-prompting:
+
+- Start with a plain `` in a `100vh` container. Add refs, `initialData`, persistence, or custom UI only after the base embed works.
+- If the canvas is blank, check the CSS import and parent height first. Those are the two most common integration failures.
+- In Next.js or other SSR frameworks, assume client-only rendering first. Use `"use client"` and `dynamic(..., { ssr: false })` before debugging hydration or `window is not defined` errors.
+- If imports or entrypoints are unclear, inspect `node_modules/@excalidraw/excalidraw/package.json`. The installed package exports are the source of truth.
+- Do not set `window.EXCALIDRAW_ASSET_PATH` unless you are intentionally self-hosting fonts/assets.
+- When docs and generated code drift, copy the nearest working example from this repo, especially `examples/with-nextjs` or `examples/with-script-in-browser`.
+
+## Migrating to `@excalidraw/excalidraw@0.18.x`
+
+Version `0.18.x` removes the old `types/`-prefixed deep import paths. If you were importing types from `@excalidraw/excalidraw/types/...`, switch to the new type-only subpaths below.
+
+| Old path | New path |
+| --- | --- |
+| `@excalidraw/excalidraw/types/data/transform.js` | `@excalidraw/excalidraw/element/transform` |
+| `@excalidraw/excalidraw/types/data/types.js` | `@excalidraw/excalidraw/data/types` |
+| `@excalidraw/excalidraw/types/element/types.js` | `@excalidraw/excalidraw/element/types` |
+| `@excalidraw/excalidraw/types/utility-types.js` | `@excalidraw/excalidraw/common/utility-types` |
+| `@excalidraw/excalidraw/types/types.js` | `@excalidraw/excalidraw/types` |
+
+Drop the `.js` extension. The new package `exports` map resolves these paths without it.
+
+These deep subpaths are for `import type` only. Runtime imports should come from the package root, plus `@excalidraw/excalidraw/index.css` for styles.
+
+For example:
+
+```ts
+import { exportToSvg } from "@excalidraw/excalidraw";
+```
+
+## Self-hosting fonts
+
+By default, Excalidraw downloads the fonts it needs from the [CDN](https://esm.run/@excalidraw/excalidraw/dist/prod).
+
+For self-hosting, copy the contents of `node_modules/@excalidraw/excalidraw/dist/prod/fonts` into the path where your app serves static assets, for example `public/`. Then set `window.EXCALIDRAW_ASSET_PATH` to that same path:
+
+```html
+
+```
## Demo
-Go to [CodeSandbox](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser) example.
+Try the [CodeSandbox example](https://codesandbox.io/p/sandbox/github/excalidraw/excalidraw/tree/master/examples/with-script-in-browser).
## Integration
-Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
+Read the [integration docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/integration).
## API
-Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
+Read the [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api).
## Contributing
-Head over to the [docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
+Read the [contributing docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/contributing).
diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx
index f9c57a2851..d6986a17af 100644
--- a/packages/excalidraw/actions/actionCanvas.tsx
+++ b/packages/excalidraw/actions/actionCanvas.tsx
@@ -118,7 +118,6 @@ export const actionClearCanvas = register({
gridStep: appState.gridStep,
gridModeEnabled: appState.gridModeEnabled,
stats: appState.stats,
- pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? {
diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx
index 9821abadc8..c25712eb94 100644
--- a/packages/excalidraw/actions/actionDeleteSelected.tsx
+++ b/packages/excalidraw/actions/actionDeleteSelected.tsx
@@ -30,7 +30,7 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { TrashIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
-import { useStylesPanelMode } from "..";
+import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
diff --git a/packages/excalidraw/actions/actionDeselect.ts b/packages/excalidraw/actions/actionDeselect.ts
new file mode 100644
index 0000000000..c9a92c7138
--- /dev/null
+++ b/packages/excalidraw/actions/actionDeselect.ts
@@ -0,0 +1,147 @@
+import {
+ getElementsInGroup,
+ isSomeElementSelected,
+ makeNextSelectedElementIds,
+ selectGroupsForSelectedElements,
+} from "@excalidraw/element";
+import { CaptureUpdateAction } from "@excalidraw/element";
+import { KEYS, isWritableElement, updateActiveTool } from "@excalidraw/common";
+
+import type { GroupId } from "@excalidraw/element/types";
+
+import { register } from "./register";
+
+import type { AppClassProperties, AppState } from "../types";
+
+const getNextActiveTool = (
+ appState: Readonly,
+ app: AppClassProperties,
+) => {
+ if (appState.activeTool.type === "eraser") {
+ return updateActiveTool(appState, {
+ ...(appState.activeTool.lastActiveTool || {
+ type: app.state.preferredSelectionTool.type,
+ }),
+ lastActiveToolBeforeEraser: null,
+ });
+ }
+
+ return updateActiveTool(appState, {
+ type: app.state.preferredSelectionTool.type,
+ });
+};
+
+const getParentEditingGroupId = (
+ appState: Readonly,
+ app: AppClassProperties,
+ selectedElementIds: AppState["selectedElementIds"],
+): GroupId | null => {
+ if (!appState.editingGroupId) {
+ return null;
+ }
+
+ const nonDeletedElements = app.scene.getNonDeletedElements();
+ const selectedElements = app.scene.getSelectedElements({
+ selectedElementIds,
+ elements: nonDeletedElements,
+ });
+ const candidateElements = selectedElements.length
+ ? selectedElements
+ : getElementsInGroup(nonDeletedElements, appState.editingGroupId);
+
+ for (const element of candidateElements) {
+ const editingGroupIndex = element.groupIds.indexOf(appState.editingGroupId);
+ if (editingGroupIndex !== -1 && element.groupIds[editingGroupIndex + 1]) {
+ return element.groupIds[editingGroupIndex + 1] as GroupId;
+ }
+ }
+
+ return null;
+};
+
+export const actionDeselect = register({
+ name: "deselect",
+ label: "",
+ trackEvent: false,
+ perform: (_elements, appState, _, app) => {
+ const activeTool = getNextActiveTool(appState, app);
+
+ if (appState.editingGroupId) {
+ const nonDeletedElements = app.scene.getNonDeletedElements();
+ const selectedElementIds =
+ Object.keys(appState.selectedElementIds).length > 0
+ ? appState.selectedElementIds
+ : getElementsInGroup(
+ nonDeletedElements,
+ appState.editingGroupId,
+ ).reduce((acc, element) => {
+ acc[element.id] = true;
+ return acc;
+ }, {} as Record);
+
+ return {
+ appState: {
+ ...appState,
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: getParentEditingGroupId(
+ appState,
+ app,
+ selectedElementIds,
+ ),
+ selectedElementIds,
+ },
+ nonDeletedElements,
+ appState,
+ app,
+ ),
+ activeEmbeddable: null,
+ activeTool,
+ selectedLinearElement: null,
+ selectionElement: null,
+ showHyperlinkPopup: false,
+ suggestedBinding: null,
+ frameToHighlight: null,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ }
+
+ return {
+ appState: {
+ ...appState,
+ activeEmbeddable: null,
+ activeTool,
+ editingGroupId: null,
+ selectedElementIds: makeNextSelectedElementIds({}, appState),
+ selectedGroupIds: {},
+ selectedLinearElement: null,
+ selectionElement: null,
+ showHyperlinkPopup: false,
+ suggestedBinding: null,
+ frameToHighlight: null,
+ },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ };
+ },
+ keyTest: (event, appState, _, app) => {
+ if (event.key !== KEYS.ESCAPE) {
+ return false;
+ }
+
+ if (isWritableElement(event.target)) {
+ return false;
+ }
+
+ return (
+ !appState.newElement &&
+ appState.multiElement === null &&
+ !appState.selectedLinearElement?.isEditing &&
+ (appState.activeEmbeddable !== null ||
+ appState.activeTool.type !== app.state.preferredSelectionTool.type ||
+ !!appState.editingGroupId ||
+ !!appState.selectedLinearElement ||
+ isSomeElementSelected(app.scene.getNonDeletedElements(), appState))
+ );
+ },
+});
diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx
index 462803d205..4c22a80ee7 100644
--- a/packages/excalidraw/actions/actionDuplicateSelection.tsx
+++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx
@@ -27,7 +27,7 @@ import { t } from "../i18n";
import { isSomeElementSelected } from "../scene";
import { getShortcutKey } from "../shortcut";
-import { useStylesPanelMode } from "..";
+import { useStylesPanelMode } from "../components/App";
import { register } from "./register";
diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx
index e47a5bb84c..dceeee0e4c 100644
--- a/packages/excalidraw/actions/actionExport.tsx
+++ b/packages/excalidraw/actions/actionExport.tsx
@@ -9,18 +9,20 @@ import { getNonDeletedElements } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
-import type { Theme } from "@excalidraw/element/types";
+import type { ExcalidrawElement, Theme } from "@excalidraw/element/types";
import { useEditorInterface } from "../components/App";
import { CheckboxItem } from "../components/CheckboxItem";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { ProjectName } from "../components/ProjectName";
+import { Toast } from "../components/Toast";
import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { loadFromJSON, saveAsJSON } from "../data";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
+
import { resaveAsImageWithScene } from "../data/resave";
import { t } from "../i18n";
@@ -31,7 +33,15 @@ import "../components/ToolIcon.scss";
import { register } from "./register";
-import type { AppState } from "../types";
+import type { JSONExportData } from "../data/json";
+
+import type {
+ AppClassProperties,
+ AppState,
+ BinaryFiles,
+ ExcalidrawProps,
+ OnExportProgress,
+} from "../types";
export const actionChangeProjectName = register({
name: "changeProjectName",
@@ -150,6 +160,143 @@ export const actionChangeExportEmbedScene = register<
),
});
+// ---------------------------------------------------------------------------
+// onExport interception helpers
+// ---------------------------------------------------------------------------
+
+let onExportInProgress = false;
+
+const onProgressToast = (
+ app: AppClassProperties,
+ progress: {
+ message?: OnExportProgress["message"];
+ progress?: number | null;
+ },
+) => {
+ const message = progress.message ?? t("progressDialog.defaultMessage");
+ app.setAppState({
+ toast: {
+ message:
+ progress.progress != null ? (
+ <>
+ {message}
+
+ >
+ ) : (
+ message
+ ),
+ duration: Infinity,
+ },
+ });
+};
+
+/** awaits host app's onExport result, and renders progress to the UI */
+async function handleOnExportResult(
+ onExportResult: ReturnType>,
+ opts: {
+ signal: AbortSignal;
+ app: AppClassProperties;
+ },
+): Promise {
+ if (opts.app.state.isLoading) {
+ onProgressToast(opts.app, { progress: null });
+ await opts.app.onStateChange({ predicate: (state) => !state.isLoading });
+ }
+
+ if (
+ onExportResult != null &&
+ typeof onExportResult === "object" &&
+ Symbol.asyncIterator in onExportResult
+ ) {
+ for await (const value of onExportResult) {
+ if (opts.signal.aborted) {
+ onExportResult.return();
+ return;
+ }
+ if (value.type === "progress") {
+ onProgressToast(opts.app, {
+ message: value.message,
+ progress: value.progress ?? null,
+ });
+ } else if (value.type === "done") {
+ return;
+ }
+ }
+
+ // Generator completed without explicit "done" message
+ return;
+ }
+
+ if (onExportResult instanceof Promise) {
+ onProgressToast(opts.app, { progress: null });
+ await onExportResult;
+ }
+}
+
+function prepareDataForJSONExport(
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ app: AppClassProperties,
+): { abortController: AbortController; data: Promise } {
+ const abortController = new AbortController();
+ const signal = abortController.signal;
+
+ const dataPromise = new Promise(async (resolve) => {
+ try {
+ if (app.props.onExport) {
+ await handleOnExportResult(
+ app.props.onExport(
+ "json",
+ {
+ elements,
+ appState,
+ files,
+ },
+ {
+ signal,
+ },
+ ),
+ {
+ app,
+ signal,
+ },
+ );
+ }
+ } catch (error: any) {
+ if (error?.name === "AbortError") {
+ // if abort error, assume it's a reaction on the signal being aborted
+ console.warn(
+ `onExport() aborted by host app (signal aborted: ${signal.aborted})`,
+ );
+ } else {
+ // non-abort error
+ //
+ console.error("Error during props.onExport() handling", error);
+ }
+
+ // either way, we currently don't allow host apps to cancel save actions
+ // so we resolve to orig data
+ }
+
+ resolve({
+ elements,
+ appState,
+ // return latest files in case they finished loading during onExport
+ files: app.files,
+ });
+ });
+
+ return {
+ abortController,
+ data: dataPromise,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Save actions
+// ---------------------------------------------------------------------------
+
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
label: "buttons.save",
@@ -163,42 +310,62 @@ export const actionSaveToActiveFile = register({
);
},
perform: async (elements, appState, value, app) => {
- const fileHandleExists = !!appState.fileHandle;
+ if (onExportInProgress) {
+ return false;
+ }
+ onExportInProgress = true;
+
+ const previousFileHandle = appState.fileHandle;
+ const filename = app.getName();
+
+ const { abortController, data: exportedDataPromise } =
+ prepareDataForJSONExport(elements, appState, app.files, app);
try {
- const { fileHandle } = isImageFileHandle(appState.fileHandle)
+ const { fileHandle } = isImageFileHandle(previousFileHandle)
? await resaveAsImageWithScene(
- elements,
- appState,
- app.files,
- app.getName(),
+ exportedDataPromise,
+ previousFileHandle,
+ filename,
)
- : await saveAsJSON(elements, appState, app.files, app.getName());
+ : await saveAsJSON({
+ data: exportedDataPromise,
+ filename,
+ fileHandle: previousFileHandle,
+ });
return {
- captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ captureUpdate: CaptureUpdateAction.NEVER,
appState: {
- ...appState,
fileHandle,
- toast: fileHandleExists
- ? {
- message: fileHandle?.name
- ? t("toast.fileSavedToFilename").replace(
- "{filename}",
- `"${fileHandle.name}"`,
- )
- : t("toast.fileSaved"),
- }
- : null,
+ toast: {
+ message:
+ previousFileHandle && fileHandle?.name
+ ? t("toast.fileSavedToFilename").replace(
+ "{filename}",
+ `"${fileHandle.name}"`,
+ )
+ : t("toast.fileSaved"),
+ duration: 1500,
+ },
},
};
} catch (error: any) {
+ abortController.abort();
+
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
- return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
+ return {
+ captureUpdate: CaptureUpdateAction.NEVER,
+ appState: {
+ toast: null,
+ },
+ };
+ } finally {
+ onExportInProgress = false;
}
},
keyTest: (event) =>
@@ -212,36 +379,50 @@ export const actionSaveFileToDisk = register({
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
+ if (onExportInProgress) {
+ return false;
+ }
+ onExportInProgress = true;
+
+ const { abortController, data: exportedDataPromise } =
+ prepareDataForJSONExport(elements, appState, app.files, app);
+
try {
- const { fileHandle } = await saveAsJSON(
- elements,
- {
- ...appState,
- fileHandle: null,
- },
- app.files,
- app.getName(),
- );
+ const { fileHandle: savedFileHandle } = await saveAsJSON({
+ data: exportedDataPromise,
+ filename: app.getName(),
+ fileHandle: null,
+ });
+
return {
- captureUpdate: CaptureUpdateAction.EVENTUALLY,
+ captureUpdate: CaptureUpdateAction.NEVER,
appState: {
- ...appState,
openDialog: null,
- fileHandle,
- toast: { message: t("toast.fileSaved") },
+ fileHandle: savedFileHandle,
+ toast: { message: t("toast.fileSaved"), duration: 3000 },
},
};
} catch (error: any) {
+ abortController.abort();
if (error?.name !== "AbortError") {
console.error(error);
} else {
console.warn(error);
}
- return { captureUpdate: CaptureUpdateAction.EVENTUALLY };
+ return {
+ captureUpdate: CaptureUpdateAction.NEVER,
+ appState: {
+ toast: null,
+ },
+ };
+ } finally {
+ onExportInProgress = false;
}
},
keyTest: (event) =>
- event.key === KEYS.S && event.shiftKey && event[KEYS.CTRL_OR_CMD],
+ event.key.toLowerCase() === KEYS.S &&
+ event.shiftKey &&
+ event[KEYS.CTRL_OR_CMD],
PanelComponent: ({ updateData }) => (
{
+ perform: (_elements, appState, value, app) => {
+ app.sessionExportThemeOverride = value ? THEME.DARK : THEME.LIGHT;
return {
appState: { ...appState, exportWithDarkMode: value },
captureUpdate: CaptureUpdateAction.EVENTUALLY,
diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx
index 9e529621a7..f2e5db5613 100644
--- a/packages/excalidraw/actions/actionFinalize.tsx
+++ b/packages/excalidraw/actions/actionFinalize.tsx
@@ -329,8 +329,8 @@ export const actionFinalize = register({
selectionElement: null,
multiElement: null,
editingTextElement: null,
- startBoundElement: null,
suggestedBinding: null,
+ frameToHighlight: null,
selectedElementIds:
element &&
!appState.activeTool.locked &&
@@ -348,9 +348,7 @@ export const actionFinalize = register({
};
},
keyTest: (event, appState) =>
- (event.key === KEYS.ESCAPE &&
- (appState.selectedLinearElement?.isEditing ||
- (!appState.newElement && appState.multiElement === null))) ||
+ (event.key === KEYS.ESCAPE && appState.selectedLinearElement?.isEditing) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),
PanelComponent: ({ appState, updateData, data }) => (
diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts
index 3a1b3635d3..81a43e243e 100644
--- a/packages/excalidraw/actions/actionFrame.ts
+++ b/packages/excalidraw/actions/actionFrame.ts
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
- appState,
);
return {
diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx
index c72216b761..c9bb8cf21d 100644
--- a/packages/excalidraw/actions/actionGroup.tsx
+++ b/packages/excalidraw/actions/actionGroup.tsx
@@ -277,7 +277,6 @@ export const actionUngroup = register({
elementsMap,
),
frame,
- app,
);
}
});
diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx
index 0232bb33e3..5db66670b7 100644
--- a/packages/excalidraw/actions/actionHistory.tsx
+++ b/packages/excalidraw/actions/actionHistory.tsx
@@ -1,5 +1,4 @@
import {
- isWindows,
KEYS,
matchKey,
arrayToMap,
@@ -18,7 +17,7 @@ import { HistoryChangedEvent } from "../history";
import { useEmitter } from "../hooks/useEmitter";
import { t } from "../i18n";
-import { useStylesPanelMode } from "..";
+import { useStylesPanelMode } from "../components/App";
import type { History } from "../history";
import type { AppClassProperties, AppState } from "../types";
@@ -114,7 +113,7 @@ export const createRedoAction: ActionCreator = (history) => ({
),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
- (isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
+ (event[KEYS.CTRL_OR_CMD] && !event.shiftKey && matchKey(event, KEYS.Y)),
PanelComponent: ({ appState, updateData, data, app }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx
index 07e680ef7b..fe222fe6e7 100644
--- a/packages/excalidraw/actions/actionProperties.tsx
+++ b/packages/excalidraw/actions/actionProperties.tsx
@@ -36,6 +36,7 @@ import {
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
+import { getArrowheadForPicker } from "@excalidraw/element";
import {
getBoundTextElement,
@@ -124,9 +125,12 @@ import {
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
- ArrowheadCrowfootIcon,
- ArrowheadCrowfootOneIcon,
- ArrowheadCrowfootOneOrManyIcon,
+ ArrowheadCardinalityExactlyOneIcon,
+ ArrowheadCardinalityManyIcon,
+ ArrowheadCardinalityOneIcon,
+ ArrowheadCardinalityOneOrManyIcon,
+ ArrowheadCardinalityZeroOrManyIcon,
+ ArrowheadCardinalityZeroOrOneIcon,
} from "../components/icons";
import { Fonts } from "../fonts";
@@ -187,7 +191,7 @@ export const getFormValue = function (
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T,
- isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
+ elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingTextElement = app.state.editingTextElement;
@@ -205,9 +209,9 @@ export const getFormValue = function (
if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements =
- isRelevantElement === true
+ elementPredicate === true
? selectedElements
- : selectedElements.filter((el) => isRelevantElement(el));
+ : selectedElements.filter((el) => elementPredicate(el));
ret =
reduceToCommonValue(targetElements, getAttribute) ??
@@ -726,9 +730,28 @@ export const actionChangeOpacity = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
- PanelComponent: ({ app, updateData }) => (
-
- ),
+ PanelComponent: ({ elements, appState, app, updateData }) => {
+ const opacity = getFormValue(
+ elements,
+ app,
+ (element) => element.opacity,
+ true,
+ (hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
+ );
+
+ return (
+
+ );
+ },
});
export const actionChangeFontSize = register(
@@ -1550,80 +1573,117 @@ export const actionChangeRoundness = register<"sharp" | "round">({
});
const getArrowheadOptions = (flip: boolean) => {
- return [
- {
- value: null,
- text: t("labels.arrowhead_none"),
- keyBinding: "q",
- icon: ArrowheadNoneIcon,
- },
- {
- value: "arrow",
- text: t("labels.arrowhead_arrow"),
- keyBinding: "w",
- icon: ,
- },
- {
- value: "triangle",
- text: t("labels.arrowhead_triangle"),
- icon: ,
- keyBinding: "e",
- },
- {
- value: "triangle_outline",
- text: t("labels.arrowhead_triangle_outline"),
- icon: ,
- keyBinding: "r",
- },
- {
- value: "circle",
- text: t("labels.arrowhead_circle"),
- keyBinding: "a",
- icon: ,
- },
- {
- value: "circle_outline",
- text: t("labels.arrowhead_circle_outline"),
- keyBinding: "s",
- icon: ,
- },
- {
- value: "diamond",
- text: t("labels.arrowhead_diamond"),
- icon: ,
- keyBinding: "d",
- },
- {
- value: "diamond_outline",
- text: t("labels.arrowhead_diamond_outline"),
- icon: ,
- keyBinding: "f",
- },
- {
- value: "bar",
- text: t("labels.arrowhead_bar"),
- keyBinding: "z",
- icon: ,
- },
- {
- value: "crowfoot_one",
- text: t("labels.arrowhead_crowfoot_one"),
- icon: ,
- keyBinding: "x",
- },
- {
- value: "crowfoot_many",
- text: t("labels.arrowhead_crowfoot_many"),
- icon: ,
- keyBinding: "c",
- },
- {
- value: "crowfoot_one_or_many",
- text: t("labels.arrowhead_crowfoot_one_or_many"),
- icon: ,
- keyBinding: "v",
- },
- ] as const;
+ return {
+ visibleSections: [
+ {
+ name: "default",
+ options: [
+ {
+ value: null,
+ text: t("labels.arrowhead_none"),
+ keyBinding: "q",
+ icon: ,
+ },
+ {
+ value: "arrow",
+ text: t("labels.arrowhead_arrow"),
+ keyBinding: "w",
+ icon: ,
+ },
+ {
+ value: "triangle",
+ text: t("labels.arrowhead_triangle"),
+ icon: ,
+ keyBinding: "e",
+ },
+ {
+ value: "triangle_outline",
+ text: t("labels.arrowhead_triangle_outline"),
+ icon: ,
+ keyBinding: "r",
+ },
+ ],
+ },
+ ],
+ hiddenSections: [
+ {
+ name: "default",
+ options: [
+ {
+ value: "circle",
+ text: t("labels.arrowhead_circle"),
+ keyBinding: "a",
+ icon: ,
+ },
+ {
+ value: "circle_outline",
+ text: t("labels.arrowhead_circle_outline"),
+ keyBinding: "s",
+ icon: ,
+ },
+ {
+ value: "diamond",
+ text: t("labels.arrowhead_diamond"),
+ icon: ,
+ keyBinding: "d",
+ },
+ {
+ value: "diamond_outline",
+ text: t("labels.arrowhead_diamond_outline"),
+ icon: ,
+ keyBinding: "f",
+ },
+ {
+ value: "bar",
+ text: t("labels.arrowhead_bar"),
+ keyBinding: "z",
+ icon: ,
+ },
+ ],
+ },
+ {
+ name: t("labels.cardinality"),
+ options: [
+ {
+ value: "cardinality_one",
+ text: t("labels.arrowhead_cardinality_one"),
+ icon: ,
+ keyBinding: "x",
+ },
+ {
+ value: "cardinality_many",
+ text: t("labels.arrowhead_cardinality_many"),
+ icon: ,
+ keyBinding: "c",
+ },
+ {
+ value: "cardinality_one_or_many",
+ text: t("labels.arrowhead_cardinality_one_or_many"),
+ icon: ,
+ keyBinding: "v",
+ },
+ {
+ value: "cardinality_exactly_one",
+ text: t("labels.arrowhead_cardinality_exactly_one"),
+ icon: ,
+ keyBinding: null,
+ },
+ {
+ value: "cardinality_zero_or_one",
+ text: t("labels.arrowhead_cardinality_zero_or_one"),
+ icon: ,
+ keyBinding: null,
+ },
+ {
+ value: "cardinality_zero_or_many",
+ text: t("labels.arrowhead_cardinality_zero_or_many"),
+ icon: ,
+ keyBinding: null,
+ },
+ ],
+ },
+ ],
+ } as const;
};
export const actionChangeArrowhead = register<{
@@ -1667,43 +1727,52 @@ export const actionChangeArrowhead = register<{
},
PanelComponent: ({ elements, appState, updateData, app }) => {
const isRTL = getLanguage().rtl;
+ const startArrowheadOptions = useMemo(
+ () => getArrowheadOptions(!isRTL),
+ [isRTL],
+ );
+ const endArrowheadOptions = useMemo(
+ () => getArrowheadOptions(!!isRTL),
+ [isRTL],
+ );
return (
@@ -1828,6 +1897,7 @@ export const actionChangeArrowType = register({
startElement,
"start",
elementsMap,
+ appState.isBindingEnabled,
),
}
: null;
@@ -1841,6 +1911,7 @@ export const actionChangeArrowType = register({
endElement,
"end",
elementsMap,
+ appState.isBindingEnabled,
),
}
: null;
diff --git a/packages/excalidraw/actions/actionTextAutoResize.ts b/packages/excalidraw/actions/actionTextAutoResize.ts
index d4338e7797..dcc0f16955 100644
--- a/packages/excalidraw/actions/actionTextAutoResize.ts
+++ b/packages/excalidraw/actions/actionTextAutoResize.ts
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common";
-import { newElementWith } from "@excalidraw/element";
+import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
+import type { ExcalidrawElement } from "@excalidraw/element/types";
+
import { getSelectedElements } from "../scene";
import { register } from "./register";
-import type { AppClassProperties } from "../types";
-
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
- predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
+ predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize
);
},
- perform: (elements, appState, _, app) => {
+ perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState);
+ const targetTextElement =
+ isExcalidrawElement(targetElement) && isTextElement(targetElement)
+ ? targetElement
+ : (selectedElements[0] as ExcalidrawElement | undefined);
+
return {
appState,
elements: elements.map((element) => {
- if (element.id === selectedElements[0].id && isTextElement(element)) {
+ if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
diff --git a/packages/excalidraw/actions/actionToggleArrowBinding.tsx b/packages/excalidraw/actions/actionToggleArrowBinding.tsx
new file mode 100644
index 0000000000..a4e6e50ed2
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleArrowBinding.tsx
@@ -0,0 +1,26 @@
+import { CaptureUpdateAction } from "@excalidraw/element";
+
+import { register } from "./register";
+
+export const actionToggleArrowBinding = register({
+ name: "arrowBinding",
+ label: "labels.arrowBinding",
+ viewMode: false,
+ trackEvent: {
+ category: "canvas",
+ predicate: (appState) => appState.bindingPreference === "disabled",
+ },
+ perform(elements, appState) {
+ const newPreference =
+ appState.bindingPreference === "enabled" ? "disabled" : "enabled";
+ return {
+ appState: {
+ ...appState,
+ bindingPreference: newPreference,
+ isBindingEnabled: newPreference === "enabled",
+ },
+ captureUpdate: CaptureUpdateAction.NEVER,
+ };
+ },
+ checked: (appState) => appState.bindingPreference === "enabled",
+});
diff --git a/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx b/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx
new file mode 100644
index 0000000000..eca9df7e27
--- /dev/null
+++ b/packages/excalidraw/actions/actionToggleMidpointSnapping.tsx
@@ -0,0 +1,23 @@
+import { CaptureUpdateAction } from "@excalidraw/element";
+
+import { register } from "./register";
+
+export const actionToggleMidpointSnapping = register({
+ name: "midpointSnapping",
+ label: "labels.midpointSnapping",
+ viewMode: false,
+ trackEvent: {
+ category: "canvas",
+ predicate: (appState) => !appState.isMidpointSnappingEnabled,
+ },
+ perform(elements, appState) {
+ return {
+ appState: {
+ ...appState,
+ isMidpointSnappingEnabled: !this.checked!(appState),
+ },
+ captureUpdate: CaptureUpdateAction.NEVER,
+ };
+ },
+ checked: (appState) => appState.isMidpointSnappingEnabled,
+});
diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts
index 6b888e92d3..d630c6ecaa 100644
--- a/packages/excalidraw/actions/index.ts
+++ b/packages/excalidraw/actions/index.ts
@@ -34,6 +34,7 @@ export {
export { actionSetEmbeddableAsActiveTool } from "./actionEmbeddable";
export { actionFinalize } from "./actionFinalize";
+export { actionDeselect } from "./actionDeselect";
export {
actionChangeProjectName,
@@ -79,6 +80,8 @@ export {
export { actionToggleGridMode } from "./actionToggleGridMode";
export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
+export { actionToggleArrowBinding } from "./actionToggleArrowBinding";
+export { actionToggleMidpointSnapping } from "./actionToggleMidpointSnapping";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
index c85b0639ef..02c67d34f3 100644
--- a/packages/excalidraw/actions/types.ts
+++ b/packages/excalidraw/actions/types.ts
@@ -59,6 +59,8 @@ export type ActionName =
| "gridMode"
| "zenMode"
| "objectsSnapMode"
+ | "arrowBinding"
+ | "midpointSnapping"
| "stats"
| "changeStrokeColor"
| "changeBackgroundColor"
@@ -112,6 +114,7 @@ export type ActionName =
| "distributeVertically"
| "flipHorizontal"
| "flipVertical"
+ | "deselect"
| "viewMode"
| "exportWithDarkMode"
| "toggleTheme"
diff --git a/packages/excalidraw/animated-trail.ts b/packages/excalidraw/animatedTrail.ts
similarity index 83%
rename from packages/excalidraw/animated-trail.ts
rename to packages/excalidraw/animatedTrail.ts
index 2cf5540e08..e98e6e50ee 100644
--- a/packages/excalidraw/animated-trail.ts
+++ b/packages/excalidraw/animatedTrail.ts
@@ -1,5 +1,4 @@
import { LaserPointer } from "@excalidraw/laser-pointer";
-
import {
SVG_NS,
getSvgPathFromStroke,
@@ -8,7 +7,8 @@ import {
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
-import type { AnimationFrameHandler } from "./animation-frame-handler";
+import { AnimationController } from "./renderer/animation";
+
import type App from "./components/App";
import type { AppState } from "./types";
@@ -34,15 +34,16 @@ export class AnimatedTrail implements Trail {
private container?: SVGSVGElement;
private trailElement: SVGPathElement;
private trailAnimation?: SVGAnimateElement;
+ private key: string;
+
+ private static counter = 0;
constructor(
- private animationFrameHandler: AnimationFrameHandler,
protected app: App,
private options: Partial &
Partial,
) {
- this.animationFrameHandler.register(this, this.onFrame.bind(this));
-
+ this.key = `animated-trail-${AnimatedTrail.counter++}`;
this.trailElement = document.createElementNS(SVG_NS, "path");
if (this.options.animateTrail) {
this.trailAnimation = document.createElementNS(SVG_NS, "animate");
@@ -73,6 +74,15 @@ export class AnimatedTrail implements Trail {
return false;
}
+ private cleanup() {
+ this.pastTrails = [];
+ this.currentTrail = undefined;
+
+ if (this.trailElement.parentNode === this.container) {
+ this.container?.removeChild(this.trailElement);
+ }
+ }
+
start(container?: SVGSVGElement) {
if (container) {
this.container = container;
@@ -82,15 +92,23 @@ export class AnimatedTrail implements Trail {
this.container.appendChild(this.trailElement);
}
- this.animationFrameHandler.start(this);
+ if (!AnimationController.running(this.key)) {
+ AnimationController.start(this.key, () => {
+ const needsNext = this.onFrame();
+ if (needsNext) {
+ return { keep: true };
+ }
+
+ this.cleanup();
+
+ return null;
+ });
+ }
}
stop() {
- this.animationFrameHandler.stop(this);
-
- if (this.trailElement.parentNode === this.container) {
- this.container?.removeChild(this.trailElement);
- }
+ AnimationController.cancel(this.key);
+ this.cleanup();
}
startPath(x: number, y: number) {
@@ -145,21 +163,25 @@ export class AnimatedTrail implements Trail {
if (this.currentTrail) {
const currentPath = this.drawTrail(this.currentTrail, this.app.state);
-
paths.push(currentPath);
}
- this.pastTrails = this.pastTrails.filter((trail) => {
- return trail.getStrokeOutline().length !== 0;
- });
+ this.pastTrails = this.pastTrails.filter(
+ (t) =>
+ t.getStrokeOutline(t.options.size / this.app.state.zoom.value)
+ .length !== 0,
+ );
if (paths.length === 0) {
- this.stop();
+ // Clean up the SVG path if there are no trails to render
+ this.trailElement.setAttribute("d", "");
+
+ return false;
}
const svgPaths = paths.join(" ").trim();
-
this.trailElement.setAttribute("d", svgPaths);
+
if (this.trailAnimation) {
this.trailElement.setAttribute(
"fill",
@@ -175,6 +197,8 @@ export class AnimatedTrail implements Trail {
(this.options.fill ?? (() => "black"))(this),
);
}
+
+ return true;
}
private drawTrail(trail: LaserPointer, state: AppState): string {
diff --git a/packages/excalidraw/animation-frame-handler.ts b/packages/excalidraw/animation-frame-handler.ts
deleted file mode 100644
index b1a9844669..0000000000
--- a/packages/excalidraw/animation-frame-handler.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-export type AnimationCallback = (timestamp: number) => void | boolean;
-
-export type AnimationTarget = {
- callback: AnimationCallback;
- stopped: boolean;
-};
-
-export class AnimationFrameHandler {
- private targets = new WeakMap