diff --git a/dev-docs/docusaurus.config.js b/dev-docs/docusaurus.config.js
index 4e8d75800a..d2b34843ee 100644
--- a/dev-docs/docusaurus.config.js
+++ b/dev-docs/docusaurus.config.js
@@ -97,8 +97,8 @@ const config = {
href: "https://discord.gg/UexuTaE",
},
{
- label: "Twitter",
- href: "https://twitter.com/excalidraw",
+ label: "𝕏",
+ href: "https://x.com/excalidraw",
},
{
label: "Linkedin",
diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts
index 634fdc4f1c..d09d8aa88e 100644
--- a/excalidraw-app/data/LocalData.ts
+++ b/excalidraw-app/data/LocalData.ts
@@ -26,7 +26,6 @@ import {
get,
} from "idb-keyval";
-import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import { getNonDeletedElements } from "@excalidraw/element";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
@@ -39,6 +38,7 @@ import type {
} from "@excalidraw/excalidraw/types";
import type { MaybePromise } from "@excalidraw/common/utility-types";
+import { appJotaiStore, atom } from "../app-jotai";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts
index fa8d63d956..dfb213ef3a 100644
--- a/excalidraw-app/vite.config.mts
+++ b/excalidraw-app/vite.config.mts
@@ -75,6 +75,13 @@ export default defineConfig(({ mode }) => {
find: /^@excalidraw\/utils\/(.*?)/,
replacement: path.resolve(__dirname, "../packages/utils/src/$1"),
},
+ {
+ find: /^@excalidraw\/fractional-indexing$/,
+ replacement: path.resolve(
+ __dirname,
+ "../packages/fractional-indexing/src/index.ts",
+ ),
+ },
],
},
build: {
diff --git a/package.json b/package.json
index 65ff221a49..df5c4e8a79 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,8 @@
"build:element": "yarn --cwd ./packages/element build:esm",
"build:excalidraw": "yarn --cwd ./packages/excalidraw build:esm",
"build:math": "yarn --cwd ./packages/math build:esm",
- "build:packages": "yarn build:common && yarn build:math && yarn build:element && yarn build:excalidraw",
+ "build:fractional-indexing": "yarn --cwd ./packages/fractional-indexing build:esm",
+ "build:packages": "yarn build:common && yarn build:fractional-indexing && yarn build:math && yarn build:element && yarn build:excalidraw",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"build:preview": "yarn --cwd ./excalidraw-app build:preview",
diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts
index 4ff50335ef..0e94df5af6 100644
--- a/packages/common/src/constants.ts
+++ b/packages/common/src/constants.ts
@@ -337,9 +337,10 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
export const EXPORT_SCALES = [1, 2, 3];
export const DEFAULT_EXPORT_PADDING = 10; // px
-export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
-
-export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
+export const DEFAULT_IMAGE_OPTIONS: AppProps["imageOptions"] = {
+ maxWidthOrHeight: 1440,
+ maxFileSizeBytes: 4 * 1024 * 1024,
+};
export const SVG_NS = "http://www.w3.org/2000/svg";
export const SVG_DOCUMENT_PREAMBLE = `
diff --git a/packages/element/package.json b/packages/element/package.json
index 7dff00a750..408f2c9d10 100644
--- a/packages/element/package.json
+++ b/packages/element/package.json
@@ -64,6 +64,7 @@
},
"dependencies": {
"@excalidraw/common": "0.18.0",
- "@excalidraw/math": "0.18.0"
+ "@excalidraw/math": "0.18.0",
+ "@excalidraw/fractional-indexing": "3.3.0"
}
}
diff --git a/packages/element/src/Scene.ts b/packages/element/src/Scene.ts
index 4ba663ceba..35b0cf4b95 100644
--- a/packages/element/src/Scene.ts
+++ b/packages/element/src/Scene.ts
@@ -338,29 +338,20 @@ export class Scene {
this.callbacks.clear();
}
- insertElementAtIndex(element: ExcalidrawElement, index: number) {
- if (!Number.isFinite(index) || index < 0) {
- throw new Error(
- "insertElementAtIndex can only be called with index >= 0",
- );
- }
-
- const nextElements = [
- ...this.elements.slice(0, index),
- element,
- ...this.elements.slice(index),
- ];
-
- syncMovedIndices(nextElements, arrayToMap([element]));
-
- this.replaceAllElements(nextElements);
- }
-
- insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
+ /** low-level - generally use app.insertNewElements() */
+ insertElementsAtIndex(
+ elements: ExcalidrawElement[],
+ /** null indicates end of the array */
+ index: number | null,
+ ) {
if (!elements.length) {
return;
}
+ if (index === null) {
+ index = this.elements.length;
+ }
+
if (!Number.isFinite(index) || index < 0) {
throw new Error(
"insertElementAtIndex can only be called with index >= 0",
@@ -378,24 +369,9 @@ export class Scene {
this.replaceAllElements(nextElements);
}
+ /** low-level - generally use app.insertNewElement() */
insertElement = (element: ExcalidrawElement) => {
- const index = element.frameId
- ? this.getElementIndex(element.frameId)
- : this.elements.length;
-
- this.insertElementAtIndex(element, index);
- };
-
- insertElements = (elements: ExcalidrawElement[]) => {
- if (!elements.length) {
- return;
- }
-
- const index = elements[0]?.frameId
- ? this.getElementIndex(elements[0].frameId)
- : this.elements.length;
-
- this.insertElementsAtIndex(elements, index);
+ this.insertElementsAtIndex([element], null);
};
getElementIndex(elementId: string) {
diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts
index 5c98e81b3d..9a320d556c 100644
--- a/packages/element/src/binding.ts
+++ b/packages/element/src/binding.ts
@@ -747,12 +747,11 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
});
// Handle outside-outside binding to the same element
- if (otherBinding && otherBinding.elementId === hit?.id) {
- invariant(
- !opts?.newArrow || appState.selectedLinearElement?.initialState.origin,
- "appState.selectedLinearElement.initialState.origin must be defined for new arrows",
- );
-
+ if (
+ otherBinding &&
+ otherBinding.elementId === hit?.id &&
+ (!opts?.newArrow || appState.selectedLinearElement?.initialState.origin)
+ ) {
return {
start: {
mode: "inside",
@@ -2063,9 +2062,9 @@ export const calculateFixedPointForElbowArrowBinding = (
return {
fixedPoint: normalizeFixedPoint([
(nonRotatedSnappedGlobalPoint[0] - hoveredElement.x) /
- hoveredElement.width,
+ Math.max(hoveredElement.width, PRECISION),
(nonRotatedSnappedGlobalPoint[1] - hoveredElement.y) /
- hoveredElement.height,
+ Math.max(hoveredElement.height, PRECISION),
]),
};
};
@@ -2096,9 +2095,11 @@ export const calculateFixedPointForNonElbowArrowBinding = (
// Calculate the ratio relative to the element's bounds
const fixedPointX =
- (nonRotatedPoint[0] - hoveredElement.x) / hoveredElement.width;
+ (nonRotatedPoint[0] - hoveredElement.x) /
+ Math.max(hoveredElement.width, PRECISION);
const fixedPointY =
- (nonRotatedPoint[1] - hoveredElement.y) / hoveredElement.height;
+ (nonRotatedPoint[1] - hoveredElement.y) /
+ Math.max(hoveredElement.height, PRECISION);
return {
fixedPoint: normalizeFixedPoint([fixedPointX, fixedPointY]),
diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts
index a73a7d28bb..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 = (
@@ -1261,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,
@@ -1275,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 46d2dbd46a..6999512b44 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 9543b4182f..024b846b12 100644
--- a/packages/element/src/elbowArrow.ts
+++ b/packages/element/src/elbowArrow.ts
@@ -2124,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(
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/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts
index 5895b71d43..afabb3747e 100644
--- a/packages/element/src/linearElementEditor.ts
+++ b/packages/element/src/linearElementEditor.ts
@@ -482,16 +482,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];
@@ -807,6 +813,7 @@ export class LinearElementEditor {
element.points[index + 1],
index,
appState.zoom,
+ elementsMap,
)
) {
midpoints.push(null);
@@ -816,6 +823,7 @@ export class LinearElementEditor {
const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
+ elementsMap,
);
midpoints.push(segmentMidPoint);
index++;
@@ -903,6 +911,7 @@ export class LinearElementEditor {
endPoint: P,
index: number,
zoom: Zoom,
+ elementsMap: ElementsMap,
) {
if (isElbowArrow(element)) {
if (index >= 0 && index < element.points.length) {
@@ -917,7 +926,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,
@@ -937,6 +949,7 @@ export class LinearElementEditor {
static getSegmentMidPoint(
element: NonDeleted,
index: number,
+ elementsMap: ElementsMap,
): GlobalPoint {
if (isElbowArrow(element)) {
invariant(
@@ -949,7 +962,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) ||
@@ -1864,6 +1880,7 @@ export class LinearElementEditor {
const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
element,
index + 1,
+ elementsMap,
);
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
@@ -2135,13 +2152,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,
},
];
@@ -2414,7 +2431,7 @@ const pointDraggingUpdates = (
? nextArrow.points[0]
: endBindable
? updateBoundPoint(
- element,
+ nextArrow,
"endBinding",
nextArrow.endBinding,
endBindable,
@@ -2445,7 +2462,7 @@ const pointDraggingUpdates = (
? endLocalPoint
: startBindable
? updateBoundPoint(
- element,
+ nextArrow,
"startBinding",
nextArrow.startBinding,
startBindable,
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 9bef1329af..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";
@@ -583,7 +583,11 @@ const getArrowheadShapes = (
export const generateLinearCollisionShape = (
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
-) => {
+ elementsMap: ElementsMap,
+): {
+ op: string;
+ data: number[];
+}[] => {
const generator = new RoughGenerator();
const options: Options = {
seed: element.seed,
@@ -592,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/utils.ts b/packages/element/src/utils.ts
index 96e09bcbf1..819cad562f 100644
--- a/packages/element/src/utils.ts
+++ b/packages/element/src/utils.ts
@@ -124,6 +124,7 @@ const setElementShapesCacheEntry = (
*/
export function deconstructLinearOrFreeDrawElement(
element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+ elementsMap: ElementsMap,
): [LineSegment[], Curve[]] {
const cachedShape = getElementShapesCacheEntry(element, 0);
@@ -131,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 = [];
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/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/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/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx
index e7a0d873bd..aa28e99d7f 100644
--- a/packages/excalidraw/actions/actionFinalize.tsx
+++ b/packages/excalidraw/actions/actionFinalize.tsx
@@ -337,8 +337,8 @@ export const actionFinalize = register({
selectionElement: null,
multiElement: null,
editingTextElement: null,
- startBoundElement: null,
suggestedBinding: null,
+ frameToHighlight: null,
selectedElementIds:
element &&
!appState.activeTool.locked &&
@@ -356,9 +356,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 1b530adeaf..5db66670b7 100644
--- a/packages/excalidraw/actions/actionHistory.tsx
+++ b/packages/excalidraw/actions/actionHistory.tsx
@@ -1,5 +1,4 @@
import {
- isWindows,
KEYS,
matchKey,
arrayToMap,
@@ -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 bca2237940..a046db6ea8 100644
--- a/packages/excalidraw/actions/actionProperties.tsx
+++ b/packages/excalidraw/actions/actionProperties.tsx
@@ -191,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;
@@ -209,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) ??
@@ -730,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(
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/index.ts b/packages/excalidraw/actions/index.ts
index cc9ca1789c..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,
diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts
index ae80e4107c..02c67d34f3 100644
--- a/packages/excalidraw/actions/types.ts
+++ b/packages/excalidraw/actions/types.ts
@@ -114,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