Compare commits

...

11 Commits

Author SHA1 Message Date
dwelle 2dc7fe15f2 feat: support google maps 2026-05-07 22:06:21 +02:00
dwelle 133b9a7277 feat: support wikipedia embeds and allow scrolling 2026-05-07 15:10:33 +02:00
Praneeth Kodumagulla b2b2815954 fix(editor): prevent duplicate lasso toolbar item (#11286) 2026-05-06 23:10:50 +02:00
alechulkin d992c10bc1 fix(app): resolve app-jotai import path in LocalData (#11290)
Co-authored-by: chulkin-mdb <oleksandr.chulkin@mongodb.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:03:10 +02:00
melvin 091b9053a3 fix(editor): enabled ctrl+y redo shortcut on linux and mac (#11179)
* enabled ctrl+y redo shortcut on linux and mac

* Apply suggestion from @dwelle

---------

Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-05-06 22:59:28 +02:00
David Luzar 97274a74b2 ci(repo): enforce scopes (#11292) 2026-05-06 19:54:30 +02:00
Márk Tolmács c59fb8dcbc fix: LocalStorage is empty object on node@25 which breaks tests (#11240)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 17:42:36 +02:00
Márk Tolmács 7f56cc0cf3 fix: Speculative fixes for arrow invariant failures (#11241)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 14:39:45 +00:00
Márk Tolmács 974b338b7e fix: Group selection (#11234)
* fix: Group selection

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Frames and overlap

Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove unnecessary crust

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* revert unused Set signature

* skip ignored elements from group condition

when wrap-mode selecting grouped elements, we should not require to select those we ignore (bound elements or locked ones), else it's impossible to select grouped text containers

unclear whether locked elements should also be excluded - but it feels like a good heuristic on the whole

* apply exclusion

* simplify

* feat: return all elements in group for overlap selection

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-06 15:27:34 +02:00
David Luzar d2557474e2 fix(editor): fix target element index when creating/adding elements to frames (#11257) 2026-05-05 21:35:32 +02:00
Márk Tolmács 3004c642da fix: Fractional index validation (#11258)
- Vendored fractional-indexing and converted to TypeScript
- Stricter index format validation in fractional-indexing
- Added format validation to fractional index validation

---
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-05-04 11:37:17 +02:00
52 changed files with 2633 additions and 328 deletions
+86
View File
@@ -6,11 +6,97 @@ on:
- opened
- edited
- synchronize
- labeled
- unlabeled
jobs:
semantic:
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
with:
requireScope: true
scopes: |
app
editor
packages/excalidraw
packages/utils
docker
repo
ignoreLabels: |
skip-semantic-title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
label-scope:
needs: semantic
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Label scoped PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
scope_labels=(s-app s-editor s-package)
readarray -t desired_labels < <(
node <<'NODE'
const title = process.env.PR_TITLE;
const match = title.match(/^[a-z]+(?:\(([^)]+)\))?!?:/i);
const scopes = match?.[1]?.split(",").map((scope) => scope.trim()) ?? [];
const labels = new Set();
for (const scope of scopes) {
if (scope === "app") {
labels.add("s-app");
} else if (scope === "editor") {
labels.add("s-editor");
} else if (scope.startsWith("packages/")) {
labels.add("s-package");
}
}
process.stdout.write([...labels].join("\n"));
NODE
)
should_apply_label() {
local label="$1"
for desired_label in "${desired_labels[@]}"; do
if [[ "$desired_label" == "$label" ]]; then
return 0
fi
done
return 1
}
for label in "${scope_labels[@]}"; do
if ! should_apply_label "$label"; then
gh api \
--method DELETE \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels/${label}" \
--silent 2>/dev/null || true
fi
done
for label in "${desired_labels[@]}"; do
if ! gh api \
--method POST \
"repos/${REPOSITORY}/issues/${PR_NUMBER}/labels" \
--field "labels[]=${label}" \
--silent; then
echo "::warning::Could not apply ${label}. The workflow token likely does not have issues:write permission for this PR."
fi
done
+1 -1
View File
@@ -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";
+7
View File
@@ -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: {
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
}
}
+12 -36
View File
@@ -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) {
+5 -6
View File
@@ -734,12 +734,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",
+7 -2
View File
@@ -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);
@@ -324,7 +326,10 @@ export const getAllHoveredElementAtPoint = (
) {
candidateElements.push(element);
if (!isTransparent(element.backgroundColor)) {
if (
hasBackground(element.type) &&
!isTransparent(element.backgroundColor)
) {
break;
}
}
+19 -2
View File
@@ -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") {
@@ -277,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],
);
@@ -293,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) => {
+105 -1
View File
@@ -130,6 +130,86 @@ const parseGoogleDriveVideoLink = (
}
};
const isGoogleMapsURL = (url: string): boolean => {
try {
const { hostname, pathname } = new URL(
url.startsWith("http") ? url : `https://${url}`,
);
const bareHostname = hostname.replace(/^www\./, "");
return (
(bareHostname === "google.com" || bareHostname === "maps.google.com") &&
(pathname === "/maps" || pathname.startsWith("/maps/"))
);
} catch (error) {
return false;
}
};
const getGoogleMapsZoom = (zoomOrDistance: string): string | null => {
const match = zoomOrDistance.match(/^(\d+(?:\.\d+)?)(z|km|m)$/);
if (!match) {
return null;
}
const value = Number(match[1]);
if (match[2] === "z") {
return `${Math.round(value)}`;
}
const meters = value * (match[2] === "km" ? 1000 : 1);
return `${Math.max(
3,
Math.min(21, Math.round(16 - Math.log2(meters / 500))),
)}`;
};
const parseGoogleMapsLink = (url: string): string | null => {
if (!isGoogleMapsURL(url)) {
return null;
}
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
if (
urlObj.pathname.startsWith("/maps/embed") ||
urlObj.searchParams.get("output") === "embed"
) {
return urlObj.toString();
}
const [, lat, lng, zoomOrDistance] =
urlObj.pathname.match(
/@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),([^/?#,]+)/,
) || [];
const place = urlObj.pathname.match(/^\/maps\/place\/([^/]+)/)?.[1];
const query =
urlObj.searchParams.get("q") ||
(place ? decodeURIComponent(place).replace(/\+/g, " ") : null) ||
(lat && lng ? `${lat},${lng}` : null);
if (!query) {
return null;
}
const embedURL = new URL("https://www.google.com/maps");
embedURL.searchParams.set("q", query);
embedURL.searchParams.set("output", "embed");
if (lat && lng) {
embedURL.searchParams.set("ll", `${lat},${lng}`);
}
const zoom = zoomOrDistance ? getGoogleMapsZoom(zoomOrDistance) : null;
if (zoom) {
embedURL.searchParams.set("z", zoom);
}
return embedURL.toString();
} catch (error) {
return null;
}
};
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
@@ -147,6 +227,8 @@ const ALLOWED_DOMAINS = new Set([
"giphy.com",
"reddit.com",
"forms.microsoft.com",
"wikipedia.org",
"*.wikipedia.org",
]);
const ALLOW_SAME_ORIGIN = new Set([
@@ -278,6 +360,27 @@ export const getEmbedLink = (
};
}
if (isGoogleMapsURL(link)) {
const googleMapsLink = parseGoogleMapsLink(link);
if (googleMapsLink) {
link = googleMapsLink;
aspectRatio = { w: 600, h: 450 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
return null;
}
const figmaLink = link.match(RE_FIGMA);
if (figmaLink) {
type = "generic";
@@ -506,6 +609,7 @@ export const embeddableURLValidator = (
if (!url) {
return false;
}
if (validateEmbeddable != null) {
if (typeof validateEmbeddable === "function") {
const ret = validateEmbeddable(url);
@@ -531,5 +635,5 @@ export const embeddableURLValidator = (
}
}
return !!matchHostname(url, ALLOWED_DOMAINS);
return isGoogleMapsURL(url) || !!matchHostname(url, ALLOWED_DOMAINS);
};
+12 -2
View File
@@ -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;
}
+93 -37
View File
@@ -23,6 +23,7 @@ import {
} from "./bounds";
import { mutateElement } from "./mutateElement";
import { getBoundTextElement, getContainerElement } from "./textElement";
import { syncMovedIndices } from "./fractionalIndex";
import {
isFrameElement,
isFrameLikeElement,
@@ -491,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)
*/
@@ -502,19 +537,11 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
allElements: T,
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
appState: AppState,
): T => {
const elementsMap = arrayToMap(allElements);
const currTargetFrameChildrenMap = new Map<ExcalidrawElement["id"], true>();
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<ExcalidrawElement>();
const otherFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
@@ -525,7 +552,8 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
}
// - 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,
@@ -538,38 +566,68 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
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 = (
@@ -623,13 +681,11 @@ export const replaceAllElementsInFrame = <T extends ExcalidrawElement>(
allElements: readonly T[],
nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameLikeElement,
app: AppClassProperties,
): T[] => {
return addElementsToFrame(
removeAllElementsFromFrame(allElements, frame),
nextElementsInFrame,
frame,
app.state,
).slice();
};
+5 -5
View File
@@ -486,7 +486,7 @@ export class LinearElementEditor {
selectedPointsIndices,
)}) points(0..${
element.points.length - 1
}) lastClickedPoint(${lastClickedPoint})`,
}) lastClickedPoint(${lastClickedPoint}) isElbowArrow: ${elbowed}`,
);
// Fall back to the actual last point as a last resort.
@@ -2139,13 +2139,13 @@ const pointDraggingUpdates = (
} => {
const naiveDraggingPoints = new Map(
selectedPointsIndices.map((pointIndex) => {
// NOTE: Avoid stale point index issue potentially caused by elbow
// arrows unpredictably changing the number of points during dragging
const point = element.points[pointIndex] ?? element.points.at(-1);
return [
pointIndex,
{
point: pointFrom<LocalPoint>(
element.points[pointIndex][0] + deltaX,
element.points[pointIndex][1] + deltaY,
),
point: pointFrom<LocalPoint>(point[0] + deltaX, point[1] + deltaY),
isDragging: true,
},
];
+46 -15
View File
@@ -129,13 +129,24 @@ export const getElementsWithinSelection = (
const framesInSelection = excludeElementsInFrames
? new Set<NonDeletedExcalidrawElement["id"]>()
: null;
let elementsInSelection: NonDeletedExcalidrawElement[] = [];
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
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);
@@ -209,7 +220,7 @@ export const getElementsWithinSelection = (
if (framesInSelection && isFrameLikeElement(element)) {
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
elementsInSelection.add(element);
continue;
}
@@ -219,7 +230,7 @@ export const getElementsWithinSelection = (
labelAABB &&
doBoundsIntersect(selectionBounds, labelAABB)
) {
elementsInSelection.push(element);
elementsInSelection.add(element);
continue;
}
@@ -309,7 +320,7 @@ export const getElementsWithinSelection = (
framesInSelection.add(element.id);
}
elementsInSelection.push(element);
elementsInSelection.add(element);
continue;
}
}
@@ -318,21 +329,41 @@ export const getElementsWithinSelection = (
// as it is separately handled in App.
}
elementsInSelection = framesInSelection
? excludeElementsFromFrames(elementsInSelection, framesInSelection)
: elementsInSelection;
if (framesInSelection) {
elementsInSelection.forEach((element) => {
if (element.frameId && framesInSelection.has(element.frameId)) {
elementsInSelection.delete(element);
}
});
}
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element, elementsMap);
if (boxSelectionMode === "overlap") {
Array.from(elementsInSelection).forEach((element) => {
const groupId = element.groupIds.at(-1);
const group = groupId ? groups[groupId] : null;
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
}
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);
return true;
});
const group = groupId ? groups[groupId] : null;
return elementsInSelection;
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 = (
+20
View File
@@ -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;
}
}
};
+64 -1
View File
@@ -1,4 +1,8 @@
import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
import {
embeddableURLValidator,
getEmbedLink,
maybeParseEmbedSrc,
} from "../src/embeddable";
describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -231,3 +235,62 @@ describe("Google Drive video embedding", () => {
).toBe(true);
});
});
describe("Google Maps embedding", () => {
const regularUrl =
"https://www.google.com/maps/place/26-432+Jab%C5%82onica,+Poland/@51.356302,20.797168,1921m/data=!3m2!1e3!4b1!4m15!1m8!3m7!1s0x47186c0e0e7578fd:0xe80d19a1ef6ad853!2zMjctMTAwIEnFgsW8YSwgUG9sYW5k!3b1!8m2!3d51.16305!4d21.23991!16zL20vMGM1ZnJ3!3m5!1s0x47184db43a4a5df9:0x6a2b8e648f9dc694!8m2!3d51.3562959!4d20.8023178!16s%2Fm%2F04q6t9r?entry=ttu";
const officialEmbedSrc =
"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d8363.540754033738!2d20.79716795156659!3d51.356301987021546!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x47184db43a4a5df9%3A0x6a2b8e648f9dc694!2s26-432%20Jab%C5%82onica%2C%20Poland!5e1!3m2!1sen!2scz!4v1778159513974!5m2!1sen!2scz";
it("should preserve official Google Maps embed links", () => {
const parsedSrc = maybeParseEmbedSrc(
`<iframe src="${officialEmbedSrc}" width="600" height="450"></iframe>`,
);
const result = getEmbedLink(parsedSrc);
expect(embeddableURLValidator(parsedSrc, undefined)).toBe(true);
expect(result).toBeTruthy();
expect(result?.type).toBe("generic");
if (result?.type === "generic") {
expect(result.link).toBe(officialEmbedSrc);
}
expect(result?.intrinsicSize).toEqual({ w: 600, h: 450 });
});
it("should normalize regular Google Maps place links", () => {
const result = getEmbedLink(regularUrl);
expect(embeddableURLValidator(regularUrl, undefined)).toBe(true);
expect(result).toBeTruthy();
expect(result?.type).toBe("generic");
if (result?.type !== "generic") {
return;
}
const embedURL = new URL(result.link);
expect(embedURL.origin).toBe("https://www.google.com");
expect(embedURL.pathname).toBe("/maps");
expect(embedURL.searchParams.get("q")).toBe(
decodeURIComponent("26-432%20Jab%C5%82onica%2C%20Poland"),
);
expect(embedURL.searchParams.get("output")).toBe("embed");
expect(embedURL.searchParams.get("ll")).toBe("51.356302,20.797168");
expect(embedURL.searchParams.get("z")).toBe("14");
expect(result.intrinsicSize).toEqual({ w: 600, h: 450 });
});
it("should reject non-Maps Google pages and fail closed for unsupported Maps pages", () => {
expect(
embeddableURLValidator("https://www.google.com/search?q=maps", undefined),
).toBe(false);
const unsupportedMapsUrl = "https://www.google.com/maps/about/";
expect(embeddableURLValidator(unsupportedMapsUrl, undefined)).toBe(true);
expect(getEmbedLink(unsupportedMapsUrl)).toBe(null);
const malformedMapsUrl = `https://www.google.com/maps/@${"0,0,".repeat(
1000,
)}`;
expect(embeddableURLValidator(malformedMapsUrl, undefined)).toBe(true);
});
});
+63 -3
View File
@@ -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: [
+551 -1
View File
@@ -5,12 +5,15 @@ import {
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 { getSelectedElements } from "@excalidraw/excalidraw/scene";
import { elementOverlapsWithFrame } from "../src/frame";
import type {
@@ -151,6 +154,230 @@ describe("adding elements to frames", () => {
).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,
) => {
@@ -457,6 +684,329 @@ describe("adding elements to frames", () => {
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]);
@@ -101,6 +101,7 @@ export const actionDeselect = register({
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -118,6 +119,7 @@ export const actionDeselect = register({
selectionElement: null,
showHyperlinkPopup: false,
suggestedBinding: null,
frameToHighlight: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@@ -330,6 +330,7 @@ export const actionFinalize = register<FormData>({
multiElement: null,
editingTextElement: null,
suggestedBinding: null,
frameToHighlight: null,
selectedElementIds:
element &&
!appState.activeTool.locked &&
@@ -205,7 +205,6 @@ export const actionWrapSelectionInFrame = register({
[...app.scene.getElementsIncludingDeleted(), frame],
selectedElements,
frame,
appState,
);
return {
@@ -277,7 +277,6 @@ export const actionUngroup = register({
elementsMap,
),
frame,
app,
);
}
});
@@ -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,
+267 -66
View File
@@ -176,7 +176,9 @@ import {
isValidTextContainer,
redrawTextBoundingBox,
hasBoundingBox,
getCommonFrameId,
getFrameChildren,
getFrameChildrenInsertionIndex,
isCursorInFrame,
addElementsToFrame,
replaceAllElementsInFrame,
@@ -259,6 +261,7 @@ import {
maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight,
getActiveTextElement,
isEligibleFrameChildType,
} from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -1837,8 +1840,6 @@ class App extends React.Component<AppProps, AppState> {
src={
src?.type !== "document" ? src?.link ?? "" : undefined
}
// https://stackoverflow.com/q/18470015
scrolling="no"
referrerPolicy="no-referrer-when-downgrade"
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@@ -2092,20 +2093,30 @@ class App extends React.Component<AppProps, AppState> {
const selectedElements = this.scene.getSelectedElements(this.state);
const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
const sceneNonce = this.scene.getSceneNonce();
const { elementsMap, visibleElements } =
this.renderer.getRenderableElements({
sceneNonce,
zoom: this.state.zoom,
offsetLeft: this.state.offsetLeft,
offsetTop: this.state.offsetTop,
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
height: this.state.height,
width: this.state.width,
editingTextElement: this.state.editingTextElement,
newElementId: this.state.newElement?.id,
});
const {
elementsMap,
visibleElements,
canvasNonce,
/**
* element to draw on the <NewElementCanvas> for optimization purposes.
* Can be null even if this.state.newElement defined
* (e.g. when its zIndex isn't on top) */
newElementCanvasElement,
} = this.renderer.getRenderableElements({
zoom: this.state.zoom,
offsetLeft: this.state.offsetLeft,
offsetTop: this.state.offsetTop,
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
height: this.state.height,
width: this.state.width,
editingTextElement: this.state.editingTextElement,
newElement: this.state.newElement,
selectedElements,
selectedElementsAreBeingDragged:
this.state.selectedElementsAreBeingDragged,
frameToHighlight: this.state.frameToHighlight,
});
this.visibleElements = visibleElements;
const allElementsMap = this.scene.getNonDeletedElementsMap();
@@ -2324,7 +2335,7 @@ class App extends React.Component<AppProps, AppState> {
elementsMap={elementsMap}
allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
canvasNonce={canvasNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
@@ -2345,9 +2356,10 @@ class App extends React.Component<AppProps, AppState> {
theme: this.state.theme,
}}
/>
{this.state.newElement && (
{newElementCanvasElement && (
<NewElementCanvas
appState={this.state}
newElement={newElementCanvasElement}
scale={window.devicePixelRatio}
rc={this.rc}
elementsMap={elementsMap}
@@ -2375,7 +2387,7 @@ class App extends React.Component<AppProps, AppState> {
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
canvasNonce={canvasNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
@@ -2687,7 +2699,7 @@ class App extends React.Component<AppProps, AppState> {
locked: false,
});
this.scene.insertElement(frame);
this.insertNewElement(frame);
for (const child of selectedElements) {
this.scene.mutateElement(child, { frameId: frame.id });
@@ -3765,6 +3777,7 @@ class App extends React.Component<AppProps, AppState> {
position:
this.editorInterface.formFactor === "desktop" ? "cursor" : "center",
retainSeed: isPlainPaste,
preserveFrameChildrenOrder: true,
});
return;
}
@@ -3908,6 +3921,7 @@ class App extends React.Component<AppProps, AppState> {
position: { clientX: number; clientY: number } | "cursor" | "center";
retainSeed?: boolean;
fitToContent?: boolean;
preserveFrameChildrenOrder?: boolean;
}) => {
const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true,
@@ -3949,6 +3963,7 @@ class App extends React.Component<AppProps, AppState> {
});
}),
randomizeSeed: !opts.retainSeed,
preserveFrameChildrenOrder: opts.preserveFrameChildrenOrder,
});
const prevElements = this.scene.getElementsIncludingDeleted();
@@ -3970,11 +3985,10 @@ class App extends React.Component<AppProps, AppState> {
duplicatedElements,
topLayerFrame,
);
addElementsToFrame(
nextElements = addElementsToFrame(
nextElements,
eligibleElements,
topLayerFrame,
this.state,
);
}
@@ -4200,7 +4214,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
this.scene.insertElements(textElements);
this.insertNewElements(textElements);
this.store.scheduleCapture();
this.setState({
selectedElementIds: makeNextSelectedElementIds(
@@ -5456,7 +5470,7 @@ class App extends React.Component<AppProps, AppState> {
if (!event[KEYS.CTRL_OR_CMD]) {
if (this.flowChartCreator.isCreatingChart) {
if (this.flowChartCreator.pendingNodes?.length) {
this.scene.insertElements(this.flowChartCreator.pendingNodes);
this.insertNewElements(this.flowChartCreator.pendingNodes);
}
const firstNode = this.flowChartCreator.pendingNodes?.[0];
@@ -5554,6 +5568,7 @@ class App extends React.Component<AppProps, AppState> {
selectedLinearElement: isSelectionLikeTool(nextActiveTool.type)
? prevState.selectedLinearElement
: null,
frameToHighlight: null,
} as const;
if (nextActiveTool.type === "freedraw") {
@@ -6151,6 +6166,12 @@ class App extends React.Component<AppProps, AppState> {
hitElement = elements[index];
break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
// to allow binding to containers within frames,
// ignore frames in hit testing
if (isFrameLikeElement(elements[index])) {
continue;
}
hitElement = elements[index];
break;
}
@@ -6240,11 +6261,6 @@ class App extends React.Component<AppProps, AppState> {
}
}
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x: sceneX,
y: sceneY,
});
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
const newTextElementPosition = parentCenterPosition
@@ -6266,6 +6282,20 @@ class App extends React.Component<AppProps, AppState> {
y: sceneY,
};
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x: newTextElementPosition.x,
y: newTextElementPosition.y,
});
// container has higher priority. Only add to frame if container is in the same frame.
const frameId =
topLayerFrame &&
(!shouldBindToContainer ||
!container ||
container.frameId === topLayerFrame.id)
? topLayerFrame.id
: null;
const element =
existingTextElement ||
newTextElement({
@@ -6295,7 +6325,7 @@ class App extends React.Component<AppProps, AppState> {
? (0 as Radians)
: container.angle
: (0 as Radians),
frameId: topLayerFrame ? topLayerFrame.id : null,
frameId,
});
if (!existingTextElement && shouldBindToContainer && container) {
@@ -6311,9 +6341,12 @@ class App extends React.Component<AppProps, AppState> {
if (!existingTextElement) {
if (container && shouldBindToContainer) {
const containerIndex = this.scene.getElementIndex(container.id);
this.scene.insertElementAtIndex(element, containerIndex + 1);
// TODO should use insertNewElement, after we update it to handle
// elements with containerId + frameId at the same time (containerId
// should take precedence when it comes to z-index)
this.scene.insertElementsAtIndex([element], containerIndex + 1);
} else {
this.scene.insertElement(element);
this.insertNewElement(element);
}
}
@@ -6695,19 +6728,161 @@ class App extends React.Component<AppProps, AppState> {
}
};
private getTopLayerFrameAtSceneCoords = (sceneCoords: {
x: number;
y: number;
}) => {
/**
* finds candidate frame under cursor (when dragging frame children/elements
* inside frames)
*/
private getTopLayerFrameAtSceneCoords = (
/**
* should be already grid aligned (basically should be what the call site
* sets the element's coords to, if applicable)
*/
sceneCoords: {
x: number;
y: number;
},
opts?: {
/** to exclude selected elements when dragging, etc. */
excludeElementIds?: AppState["selectedElementIds"];
currentFrameId?: ExcalidrawElement["frameId"];
},
) => {
const elementsMap = this.scene.getNonDeletedElementsMap();
const frames = this.scene
const framesUnderCursor = this.scene
.getNonDeletedFramesLikes()
.filter(
(frame): frame is ExcalidrawFrameLikeElement =>
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
);
return frames.length ? frames[frames.length - 1] : null;
if (!framesUnderCursor.length) {
return null;
}
const topLayerFrame = framesUnderCursor.at(-1)!;
const hitElement = this.getElementsAtPosition(
sceneCoords.x,
sceneCoords.y,
{
includeLockedElements: true,
},
).findLast((element) => !opts?.excludeElementIds?.[element.id]);
if (hitElement) {
if (
isFrameLikeElement(hitElement) &&
// case: we're hitting a locked frame itself (frame's outline
// or later its bg once implemented)
!hitElement.locked
) {
return topLayerFrame;
}
const hitElementIndex = this.scene.getElementIndex(hitElement.id);
const topLayerFrameIndex = this.scene.getElementIndex(topLayerFrame.id);
if (
hitElementIndex !== -1 &&
topLayerFrameIndex !== -1 &&
hitElementIndex <= topLayerFrameIndex
) {
return topLayerFrame;
}
// to support a case of dragging a pre-existing frame child underneath
// a non-frame element covering the cursor
const currentFrame = opts?.currentFrameId
? framesUnderCursor.find((frame) => frame.id === opts.currentFrameId) ??
null
: null;
if (currentFrame) {
return currentFrame;
}
return hitElement.frameId
? framesUnderCursor.find((frame) => frame.id === hitElement.frameId) ??
null
: null;
}
return topLayerFrame;
};
private updateFrameToHighlight = (
frameToHighlight: AppState["frameToHighlight"],
) => {
if (this.state.frameToHighlight !== frameToHighlight) {
this.setState({ frameToHighlight });
}
};
private maybeUpdateFrameToHighlightOnPointerMove = (
sceneCoords: { x: number; y: number },
isOverScrollBar: boolean,
) => {
// currently this function is being called even during pointerdown so we
// need to make sure we don't re-set the state when dragging and similar
//
// But, we still want to reset on pointermove in case the state is stale
// so we updte even for non-eligible tool types
if (
this.state.newElement ||
this.state.multiElement ||
this.state.selectionElement ||
this.state.selectedElementsAreBeingDragged
) {
return;
}
this.updateFrameToHighlight(
!isOverScrollBar && isEligibleFrameChildType(this.state.activeTool.type)
? this.getTopLayerFrameAtSceneCoords(sceneCoords)
: null,
);
};
private insertNewElements = (elements: readonly ExcalidrawElement[]) => {
if (!elements.length) {
return;
}
const chunkedElements: ExcalidrawElement[][] = [];
for (const element of elements) {
const currentChunk = chunkedElements[chunkedElements.length - 1];
if (currentChunk?.[0].frameId === element.frameId) {
currentChunk.push(element);
} else {
chunkedElements.push([element]);
}
}
for (const chunk of chunkedElements) {
const frameId = chunk[0].frameId;
const insertionIndex = frameId
? getFrameChildrenInsertionIndex(
this.scene.getElementsIncludingDeleted(),
frameId,
)
: null;
this.scene.insertElementsAtIndex(chunk, insertionIndex);
}
};
private insertNewElement = (element: ExcalidrawElement) => {
this.insertNewElements([element]);
const frame = element.frameId
? this.scene.getNonDeletedElement(element.frameId)
: null;
this.updateFrameToHighlight(
frame && isFrameLikeElement(frame) ? frame : null,
);
};
private handleCanvasPointerMove = (
@@ -6809,6 +6984,14 @@ class App extends React.Component<AppProps, AppState> {
}
}
this.maybeUpdateFrameToHighlightOnPointerMove(
{
x: scenePointerX,
y: scenePointerY,
},
isOverScrollBar,
);
if (
!this.state.newElement &&
isActiveToolNonLinearSnappable(this.state.activeTool.type)
@@ -8863,7 +9046,7 @@ class App extends React.Component<AppProps, AppState> {
pressures: simulatePressure ? [] : [event.pressure],
});
this.scene.insertElement(element);
this.insertNewElement(element);
this.setState((prevState) => {
const nextSelectedElementIds = {
@@ -8920,7 +9103,7 @@ class App extends React.Component<AppProps, AppState> {
height,
});
this.scene.insertElement(element);
this.insertNewElement(element);
return element;
};
@@ -8974,7 +9157,7 @@ class App extends React.Component<AppProps, AppState> {
link,
});
this.scene.insertElement(element);
this.insertNewElement(element);
return element;
};
@@ -9218,7 +9401,7 @@ class App extends React.Component<AppProps, AppState> {
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(0, 0)],
});
this.scene.insertElement(element);
this.insertNewElement(element);
if (isBindingElement(element)) {
// Do the initial binding so the binding strategy has the initial state
@@ -9376,7 +9559,7 @@ class App extends React.Component<AppProps, AppState> {
selectionElement: element,
});
} else {
this.scene.insertElement(element);
this.insertNewElement(element);
this.setState({
multiElement: null,
newElement: element,
@@ -9409,7 +9592,7 @@ class App extends React.Component<AppProps, AppState> {
? newMagicFrameElement(constructorOpts)
: newFrameElement(constructorOpts);
this.scene.insertElement(frame);
this.insertNewElement(frame);
this.setState({
multiElement: null,
@@ -9777,18 +9960,17 @@ class App extends React.Component<AppProps, AppState> {
return;
}
const selectedElementsHasAFrame = selectedElements.find((e) =>
const selectedElementsHasAFrame = selectedElements.some((e) =>
isFrameLikeElement(e),
);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords);
const frameToHighlight =
topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null;
const frameToHighlight = selectedElementsHasAFrame
? null
: this.getTopLayerFrameAtSceneCoords(pointerCoords, {
currentFrameId: getCommonFrameId(selectedElements),
excludeElementIds: this.state.selectedElementIds,
});
// Only update the state if there is a difference
if (this.state.frameToHighlight !== frameToHighlight) {
flushSync(() => {
this.setState({ frameToHighlight });
});
}
this.updateFrameToHighlight(frameToHighlight);
// Marking that click was used for dragging to check
// if elements should be deselected on pointerup
@@ -10224,20 +10406,38 @@ class App extends React.Component<AppProps, AppState> {
);
let linearElementEditor = this.state.selectedLinearElement;
if (!linearElementEditor) {
if (
!linearElementEditor ||
linearElementEditor.elementId !== newElement.id
) {
linearElementEditor = new LinearElementEditor(
newElement,
this.scene.getNonDeletedElementsMap(),
);
}
const lastClickedPointOutOfBounds =
linearElementEditor &&
(linearElementEditor.initialState.lastClickedPoint < 0 ||
linearElementEditor.initialState.lastClickedPoint >=
points.length);
if (lastClickedPointOutOfBounds) {
console.warn(
"Last clicked point is out of bounds. Attempting to fix it.",
);
linearElementEditor = {
...linearElementEditor,
selectedPointsIndices: [1],
selectedPointsIndices: [points.length - 1],
initialState: {
...linearElementEditor.initialState,
lastClickedPoint: 1,
prevSelectedPointsIndices: null,
lastClickedPoint: points.length - 1,
},
hoverPointIndex: points.length - 1,
};
}
this.setState({
newElement,
...LinearElementEditor.handlePointDragging(
@@ -10817,7 +11017,6 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementsMapIncludingDeleted(),
elementsInsideFrame,
newElement,
this.state,
),
);
}
@@ -10877,9 +11076,14 @@ class App extends React.Component<AppProps, AppState> {
}
} else {
// update the relationships between selected elements and frames
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
const selectedElements = this.scene.getSelectedElements(this.state);
const topLayerFrame = this.getTopLayerFrameAtSceneCoords(
sceneCoords,
{
currentFrameId: getCommonFrameId(selectedElements),
excludeElementIds: this.state.selectedElementIds,
},
);
let nextElements = this.scene.getElementsMapIncludingDeleted();
const updateGroupIdsAfterEditingGroup = (
@@ -10928,10 +11132,8 @@ class App extends React.Component<AppProps, AppState> {
topLayerFrame &&
!this.state.selectedElementIds[topLayerFrame.id]
) {
const elementsToAdd = selectedElements.filter(
(element) =>
element.frameId !== topLayerFrame.id &&
isElementInFrame(element, nextElements, this.state),
const elementsToAdd = selectedElements.filter((element) =>
isElementInFrame(element, nextElements, this.state),
);
if (this.state.editingGroupId) {
@@ -10942,7 +11144,6 @@ class App extends React.Component<AppProps, AppState> {
nextElements,
elementsToAdd,
topLayerFrame,
this.state,
);
} else if (!topLayerFrame) {
if (this.state.editingGroupId) {
@@ -11004,7 +11205,6 @@ class App extends React.Component<AppProps, AppState> {
elementsMap,
),
frame,
this,
);
}
@@ -11820,7 +12020,7 @@ class App extends React.Component<AppProps, AppState> {
sceneY,
gridPadding,
);
placeholders.forEach((el) => this.scene.insertElement(el));
this.insertNewElements(placeholders);
// Create, position, insert and select initialized (replacing placeholders)
const initialized = await Promise.all(
@@ -11948,6 +12148,7 @@ class App extends React.Component<AppProps, AppState> {
type: "everything",
elements: item.elements,
randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements,
}));
@@ -199,6 +199,7 @@ export default function LibraryMenuItems({
type: "everything",
elements: item.elements,
randomizeSeed: true,
preserveFrameChildrenOrder: true,
}).duplicatedElements,
};
});
@@ -206,7 +206,6 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
@@ -302,7 +301,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
@@ -261,7 +261,6 @@ const handleDimensionChange: DragInputCallbackType<
scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
scene.replaceAllElements(updatedElements);
@@ -416,7 +415,6 @@ const handleDragFinished: DragFinishedCallbackType = ({
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
@@ -39,7 +39,7 @@ type InteractiveCanvasProps = {
visibleElements: readonly NonDeletedExcalidrawElement[];
selectedElements: readonly NonDeletedExcalidrawElement[];
allElementsMap: NonDeletedSceneElementsMap;
sceneNonce: number | undefined;
canvasNonce: string;
selectionNonce: number | undefined;
scale: number;
appState: InteractiveCanvasAppState;
@@ -279,10 +279,10 @@ const areEqual = (
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
if (
prevProps.selectionNonce !== nextProps.selectionNonce ||
prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.canvasNonce !== nextProps.canvasNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed
// even if sceneNonce didn't change (e.g. we filter elements out based
// even if canvasNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements ||
@@ -14,6 +14,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
interface NewElementCanvasProps {
appState: AppState;
newElement: NonNullable<AppState["newElement"]>;
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
scale: number;
@@ -31,7 +32,7 @@ const NewElementCanvas = (props: NewElementCanvasProps) => {
{
canvas: canvasRef.current,
scale: props.scale,
newElement: props.appState.newElement,
newElement: props.newElement,
elementsMap: props.elementsMap,
allElementsMap: props.allElementsMap,
rc: props.rc,
@@ -23,7 +23,7 @@ type StaticCanvasProps = {
elementsMap: RenderableElementsMap;
allElementsMap: NonDeletedSceneElementsMap;
visibleElements: readonly NonDeletedExcalidrawElement[];
sceneNonce: number | undefined;
canvasNonce: string;
selectionNonce: number | undefined;
scale: number;
appState: StaticCanvasAppState;
@@ -110,10 +110,10 @@ const areEqual = (
nextProps: StaticCanvasProps,
) => {
if (
prevProps.sceneNonce !== nextProps.sceneNonce ||
prevProps.canvasNonce !== nextProps.canvasNonce ||
prevProps.scale !== nextProps.scale ||
// we need to memoize on elementsMap because they may have renewed
// even if sceneNonce didn't change (e.g. we filter elements out based
// even if canvasNonce didn't change (e.g. we filter elements out based
// on appState)
prevProps.elementsMap !== nextProps.elementsMap ||
prevProps.visibleElements !== nextProps.visibleElements
+3 -6
View File
@@ -119,15 +119,12 @@ export const SHAPES = [
export const getToolbarTools = (app: AppClassProperties) => {
return app.state.preferredSelectionTool.type === "lasso"
? ([
SHAPES[0],
{
...SHAPES[1],
value: "lasso",
icon: SelectionIcon,
key: KEYS.V,
numericKey: KEYS["1"],
fillable: true,
toolbar: true,
},
...SHAPES.slice(1),
...SHAPES.slice(2),
] as const)
: SHAPES;
};
-1
View File
@@ -95,7 +95,6 @@
"clsx": "1.1.1",
"cross-env": "7.0.3",
"es6-promise-pool": "2.5.0",
"fractional-indexing": "3.2.0",
"fuzzy": "0.1.3",
"image-blob-reduce": "3.0.1",
"jotai": "2.11.0",
+88
View File
@@ -23,6 +23,94 @@ const polyfill = () => {
});
}
if (!Array.prototype.findLast) {
Object.defineProperty(Array.prototype, "findLast", {
value: function <T>(
this: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
) {
return this
.slice()
.reverse()
.find((value, index) =>
predicate.call(thisArg, value, this.length - index - 1, this),
);
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.findIndex) {
Object.defineProperty(Array.prototype, "findIndex", {
value: function <T>(
this: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
) {
for (let index = 0; index < this.length; index++) {
if (predicate.call(thisArg, this[index], index, this)) {
return index;
}
}
return -1;
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.findLastIndex) {
Object.defineProperty(Array.prototype, "findLastIndex", {
value: function <T>(
this: T[],
predicate: (value: T, index: number, array: T[]) => unknown,
thisArg?: unknown,
) {
const index = this
.slice()
.reverse()
.findIndex((value, index) =>
predicate.call(thisArg, value, this.length - index - 1, this),
);
return index === -1 ? -1 : this.length - index - 1;
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.toReversed) {
Object.defineProperty(Array.prototype, "toReversed", {
value: function <T>(this: T[]) {
return this.slice().reverse();
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Array.prototype.toSorted) {
Object.defineProperty(Array.prototype, "toSorted", {
value: function <T>(
this: T[],
compareFn?: (a: T, b: T) => number,
) {
return this.slice().sort(compareFn);
},
writable: true,
enumerable: false,
configurable: true,
});
}
if (!Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function (...nodes) {
this.innerHTML = "";
+210 -103
View File
@@ -1,9 +1,15 @@
import { isElementInViewport } from "@excalidraw/element";
import {
getCommonFrameId,
getFrameChildrenInsertionIndex,
isElementInViewport,
} from "@excalidraw/element";
import { memoize, toBrandedType } from "@excalidraw/common";
import { arrayToMap, memoize, toBrandedType } from "@excalidraw/common";
import type {
ExcalidrawElement,
ExcalidrawFrameLikeElement,
NonDeleted,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
@@ -16,6 +22,21 @@ import type { RenderableElementsMap } from "./types";
import type { AppState } from "../types";
type GetRenderableElementsOpts = {
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
editingTextElement: AppState["editingTextElement"];
newElement: AppState["newElement"];
selectedElements: readonly NonDeletedExcalidrawElement[];
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
frameToHighlight: AppState["frameToHighlight"];
};
export class Renderer {
private scene: Scene;
@@ -23,9 +44,121 @@ export class Renderer {
this.scene = scene;
}
public getRenderableElements = (() => {
const getVisibleCanvasElements = ({
elementsMap,
private getVisibleCanvasElements({
elementsMap,
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
}: {
elementsMap: NonDeletedElementsMap;
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
}): readonly NonDeletedExcalidrawElement[] {
const visibleElements: NonDeletedExcalidrawElement[] = [];
for (const element of elementsMap.values()) {
if (
isElementInViewport(
element,
width,
height,
{
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
},
elementsMap,
)
) {
visibleElements.push(element);
}
}
return visibleElements;
}
private getRenderableElementsMap({
elements,
editingTextElement,
newElement,
}: {
elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"];
newElement: AppState["newElement"];
}) {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
const newElementCanvasElement = newElement?.frameId ? null : newElement;
for (const element of elements) {
if (newElementCanvasElement?.id === element.id) {
continue;
}
// we don't want to render text element that's being currently edited
// (it's rendered on remote only)
if (
!editingTextElement ||
editingTextElement.type !== "text" ||
element.id !== editingTextElement.id
) {
elementsMap.set(element.id, element);
}
}
return { elementsMap, newElementCanvasElement };
}
private sortSelectedElementsIntoHighlightedFrame<
T extends ExcalidrawElement,
>({
visibleElements,
selectedElements,
frameToHighlight,
}: {
selectedElements: readonly NonDeletedExcalidrawElement[];
visibleElements: readonly T[];
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement>;
}): readonly T[] {
if (!selectedElements.length) {
return visibleElements;
}
// we assume all selected elements are eligible frame children if
// frameToHighlight is defined
const selectedElementsMap = arrayToMap(selectedElements);
// thus, all deselected elements are the ones we won't reorder
const deselectedElements = visibleElements.filter(
(element) => !selectedElementsMap.has(element.id),
);
const insertionIndex = getFrameChildrenInsertionIndex(
deselectedElements,
frameToHighlight.id,
);
if (insertionIndex === null) {
return visibleElements;
}
return [
...deselectedElements.slice(0, insertionIndex),
...selectedElements,
...deselectedElements.slice(insertionIndex),
] as readonly T[];
}
private _getRenderableElements = memoize(
({
canvasNonce,
zoom,
offsetLeft,
offsetTop,
@@ -33,70 +166,27 @@ export class Renderer {
scrollY,
height,
width,
}: {
elementsMap: NonDeletedElementsMap;
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
}): readonly NonDeletedExcalidrawElement[] => {
const visibleElements: NonDeletedExcalidrawElement[] = [];
for (const element of elementsMap.values()) {
if (
isElementInViewport(
element,
width,
height,
{
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
},
elementsMap,
)
) {
visibleElements.push(element);
}
}
return visibleElements;
};
const getRenderableElements = ({
elements,
editingTextElement,
newElementId,
}: {
elements: readonly NonDeletedExcalidrawElement[];
editingTextElement: AppState["editingTextElement"];
newElementId: ExcalidrawElement["id"] | undefined;
newElement,
}: Omit<
GetRenderableElementsOpts,
| "selectedElements"
| "selectedElementsAreBeingDragged"
| "frameToHighlight"
> & {
canvasNonce: string;
}) => {
const elementsMap = toBrandedType<RenderableElementsMap>(new Map());
const elements = this.scene.getNonDeletedElements();
for (const element of elements) {
if (newElementId === element.id) {
continue;
}
const { elementsMap, newElementCanvasElement } =
this.getRenderableElementsMap({
elements,
editingTextElement,
newElement,
});
// we don't want to render text element that's being currently edited
// (it's rendered on remote only)
if (
!editingTextElement ||
editingTextElement.type !== "text" ||
element.id !== editingTextElement.id
) {
elementsMap.set(element.id, element);
}
}
return elementsMap;
};
return memoize(
({
const visibleElements = this.getVisibleCanvasElements({
elementsMap,
zoom,
offsetLeft,
offsetTop,
@@ -104,52 +194,69 @@ export class Renderer {
scrollY,
height,
width,
editingTextElement,
newElementId,
// cache-invalidation nonce
sceneNonce: _sceneNonce,
}: {
zoom: AppState["zoom"];
offsetLeft: AppState["offsetLeft"];
offsetTop: AppState["offsetTop"];
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
height: AppState["height"];
width: AppState["width"];
editingTextElement: AppState["editingTextElement"];
/** note: first render of newElement will always bust the cache
* (we'd have to prefilter elements outside of this function) */
newElementId: ExcalidrawElement["id"] | undefined;
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
}) => {
const elements = this.scene.getNonDeletedElements();
});
const elementsMap = getRenderableElements({
elements,
editingTextElement,
newElementId,
return {
elementsMap,
visibleElements,
newElementCanvasElement,
canvasNonce,
};
},
);
public getRenderableElements = (opts: GetRenderableElementsOpts) => {
const { newElement } = opts;
const canvasNonce = `${this.scene.getSceneNonce()}${
newElement?.frameId ? `:${newElement.versionNonce}` : ""
}`;
const ret = this._getRenderableElements({
canvasNonce,
// don't spread `opts` because we don't want to memoize on some props
zoom: opts.zoom,
offsetLeft: opts.offsetLeft,
offsetTop: opts.offsetTop,
scrollX: opts.scrollX,
scrollY: opts.scrollY,
height: opts.height,
width: opts.width,
editingTextElement: opts.editingTextElement,
newElement: opts.newElement,
});
// if we're dragging elements over a frame, reorder the selected elements
// inside the frame during render (we don't set the `element.frameId` until
// pointerup else we'd have to painstainly restore the orig index if user
// didn't end up adding elements to the frame)
if (
opts.frameToHighlight &&
opts.selectedElementsAreBeingDragged &&
// if all dragged elements are already in the frame, don't reorder
getCommonFrameId(opts.selectedElements) !== opts.frameToHighlight.id
) {
const reorderedVisibleElements =
this.sortSelectedElementsIntoHighlightedFrame({
visibleElements: ret.visibleElements,
selectedElements: opts.selectedElements,
frameToHighlight: opts.frameToHighlight,
});
const visibleElements = getVisibleCanvasElements({
elementsMap,
zoom,
offsetLeft,
offsetTop,
scrollX,
scrollY,
height,
width,
});
return {
...ret,
visibleElements: reorderedVisibleElements,
};
}
return { elementsMap, visibleElements };
},
);
})();
return ret;
};
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
// safe to break TS contract here (for upstream cases)
public destroy() {
renderStaticSceneThrottled.cancel();
this.getRenderableElements.clear();
this._getRenderableElements.clear();
}
}
+90 -7
View File
@@ -305,8 +305,88 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(2);
expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].id).toBe(frame.id);
expect(h.elements[0].index! < frame.index!).toBe(true);
});
});
it("should layer pasted elements above the highest frame child", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
id: "frameChild",
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const rect = API.createElement({ type: "rectangle" });
API.setElements([frameChild, frame]);
const clipboardJSON = await serializeAsClipboardJSON({
elements: [rect],
files: null,
});
mouse.moveTo(50, 50);
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements.map((element) => element.id)).toEqual([
frameChild.id,
h.elements[1].id,
frame.id,
]);
expect(h.elements[1].index! > frameChild.index!).toBe(true);
expect(h.elements[1].index! < frame.index!).toBe(true);
});
});
it("should preserve denormalized pasted frame child order", async () => {
const frame = API.createElement({
type: "frame",
width: 100,
height: 100,
x: 0,
y: 0,
});
const frameChild = API.createElement({
type: "rectangle",
x: 10,
y: 10,
width: 20,
height: 20,
frameId: frame.id,
});
const clipboardJSON = await serializeAsClipboardJSON({
elements: [frame, frameChild],
files: null,
});
mouse.moveTo(200, 200);
pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => {
expect(h.elements.length).toBe(2);
expect(h.elements[0].type).toBe(frame.type);
expect(h.elements[1].type).toBe(frameChild.type);
expect(h.elements[1].frameId).toBe(h.elements[0].id);
});
});
@@ -379,8 +459,9 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].id).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(null);
});
@@ -422,10 +503,11 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(3);
expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].type).toBe(rect2.type);
expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(frame.id);
expect(h.elements[2].id).toBe(frame.id);
});
});
@@ -473,8 +555,9 @@ describe("pasting & frames", () => {
await waitFor(() => {
expect(h.elements.length).toBe(4);
expect(h.elements[1].type).toBe(rect.type);
expect(h.elements[1].frameId).toBe(frame.id);
expect(h.elements[0].type).toBe(rect.type);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].id).toBe(frame.id);
expect(h.elements[2].type).toBe(rect2.type);
expect(h.elements[2].frameId).toBe(h.elements[3].id);
expect(h.elements[3].type).toBe(frame2.type);
@@ -94,3 +94,42 @@ export const testPolyfills = {
// https://github.com/vitest-dev/vitest/pull/4164#issuecomment-2172729965
URL,
};
export const PolyfillLocalStorage = () => {
// Node.js 25+ provides a native localStorage global that shadows jsdom's,
// and jsdom's own localStorage also uses the native one -- both are broken
// (empty objects without Storage methods). On older Node versions, jsdom
// provides a working localStorage. This polyfill replaces localStorage on
// all supported versions with a standard Storage implementation backed by
// a Map, ensuring consistent behavior regardless of the Node.js version.
const storage = new Map<string, string>();
const storagePolyfill: Storage = {
get length() {
return storage.size;
},
clear() {
storage.clear();
},
key(index) {
return Array.from(storage.keys())[index] ?? null;
},
getItem(key) {
return storage.get(key) ?? null;
},
setItem(key, value) {
storage.set(key, value);
},
removeItem(key) {
storage.delete(key);
},
*[Symbol.iterator]() {
yield* storage.entries();
},
};
Object.defineProperty(window, "localStorage", {
value: storagePolyfill,
writable: true,
configurable: true,
});
};
+310 -3
View File
@@ -1,8 +1,12 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, ROUNDNESS, reseed } from "@excalidraw/common";
import { getElementBounds, getElementLineSegments } from "@excalidraw/element";
import { KEYS, ROUNDNESS, arrayToMap, reseed } from "@excalidraw/common";
import {
getElementBounds,
getElementLineSegments,
getElementsWithinSelection,
} from "@excalidraw/element";
import { pointFrom, pointRotateRads, type LocalPoint } from "@excalidraw/math";
import { SHAPES } from "../components/shapes";
@@ -269,6 +273,145 @@ describe("box-selection overlap mode", () => {
assertSelectedElements([rect1.id]);
});
it("should select the whole group when overlapping one group member", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
const rect2 = API.createElement({
type: "rectangle",
x: 100,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
API.setElements([rect1, rect2]);
boxSelect(25, -20, 75, 70);
assertSelectedElements([rect1.id, rect2.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
});
it("should return all group elements when overlapping one group member", () => {
const rect1 = API.createElement({
type: "rectangle",
id: "rect1",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
const rect2 = API.createElement({
type: "rectangle",
id: "rect2",
x: 100,
y: 0,
width: 50,
height: 50,
groupIds: ["A"],
});
const rect3 = API.createElement({
type: "rectangle",
id: "rect3",
x: 200,
y: 0,
width: 50,
height: 50,
});
const selection = API.createElement({
type: "rectangle",
x: 125,
y: -10,
width: 10,
height: 70,
});
const elements = [rect1, rect2, rect3];
expect(
getElementsWithinSelection(
elements,
selection,
arrayToMap([...elements, selection]),
false,
"overlap",
).map((element) => element.id),
).toEqual([rect1.id, rect2.id]);
});
it("should retain nested and interleaved group element order", () => {
const outerNested1 = API.createElement({
type: "rectangle",
id: "outerNested1",
x: 0,
y: 0,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const other1 = API.createElement({
type: "rectangle",
id: "other1",
x: 70,
y: 0,
width: 50,
height: 50,
groupIds: ["other"],
});
const outerOnly = API.createElement({
type: "rectangle",
id: "outerOnly",
x: 140,
y: 0,
width: 50,
height: 50,
groupIds: ["outer"],
});
const other2 = API.createElement({
type: "rectangle",
id: "other2",
x: 210,
y: 0,
width: 50,
height: 50,
groupIds: ["other"],
});
const outerNested2 = API.createElement({
type: "rectangle",
id: "outerNested2",
x: 280,
y: 0,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const selection = API.createElement({
type: "rectangle",
x: 295,
y: -10,
width: 10,
height: 70,
});
const elements = [outerNested1, other1, outerOnly, other2, outerNested2];
expect(
getElementsWithinSelection(
elements,
selection,
arrayToMap([...elements, selection]),
false,
"overlap",
).map((element) => element.id),
).toEqual([outerNested1.id, outerOnly.id, outerNested2.id]);
});
it("should not select a transparent rectangle when the selection box stays inside it", () => {
const rect1 = API.createElement({
type: "rectangle",
@@ -716,11 +859,175 @@ describe("inner box-selection", () => {
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.up();
assertSelectedElements([rect1.id]);
expect(h.state.selectedGroupIds).toEqual({});
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10);
mouse.up();
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
});
});
it("does not select a nested outer group until all members are contained", async () => {
const innerRect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const innerRect2 = API.createElement({
type: "rectangle",
x: 120,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const outerRect = API.createElement({
type: "rectangle",
x: 190,
y: 50,
width: 50,
height: 50,
groupIds: ["outer"],
});
API.setElements([innerRect1, innerRect2, outerRect]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(0, 0);
mouse.move(-1000, -1000);
mouse.moveTo(
innerRect2.x + innerRect2.width + 10,
innerRect2.y + innerRect2.height + 10,
);
mouse.up();
assertSelectedElements([]);
expect(h.state.selectedGroupIds).toEqual({});
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(0, 0);
mouse.move(-1000, -1000);
mouse.moveTo(
outerRect.x + outerRect.width + 10,
outerRect.y + outerRect.height + 10,
);
mouse.up();
assertSelectedElements([innerRect1.id, innerRect2.id, outerRect.id]);
expect(h.state.selectedGroupIds).toEqual({ outer: true });
});
});
it.skip("checks nested containment against the current editing depth", async () => {
const innerRect1 = API.createElement({
type: "rectangle",
x: 50,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const innerRect2 = API.createElement({
type: "rectangle",
x: 120,
y: 50,
width: 50,
height: 50,
groupIds: ["inner", "outer"],
});
const outerRect = API.createElement({
type: "rectangle",
x: 190,
y: 50,
width: 50,
height: 50,
groupIds: ["outer"],
});
const selection = API.createElement({
type: "rectangle",
x: 40,
y: 40,
width: 140,
height: 70,
});
const elements = [innerRect1, innerRect2, outerRect];
const elementsMap = arrayToMap([...elements, selection]);
expect(
getElementsWithinSelection(
elements,
selection,
elementsMap,
false,
"contain",
).map((element) => element.id),
).toEqual([]);
expect(
getElementsWithinSelection(
elements,
selection,
elementsMap,
false,
"contain",
// "outer", /* editingGroupId - add as param once we implement nested group handling */
).map((element) => element.id),
).toEqual([innerRect1.id, innerRect2.id]);
});
it("ignores grouped bound text when checking box-selection containment", async () => {
const container = API.createElement({
type: "rectangle",
id: "container",
x: 50,
y: 50,
width: 50,
height: 50,
groupIds: ["A"],
boundElements: [{ type: "text", id: "bound-text" }],
});
const boundText = API.createElement({
type: "text",
id: "bound-text",
x: 50,
y: 50,
width: 50,
height: 20,
containerId: container.id,
groupIds: ["A"],
});
const rect = API.createElement({
type: "rectangle",
x: 150,
y: 150,
width: 50,
height: 50,
groupIds: ["A"],
});
API.setElements([container, boundText, rect]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(40, 40);
mouse.move(-1000, -1000);
mouse.moveTo(rect.x + rect.width + 10, rect.y + rect.height + 10);
mouse.up();
expect(h.state.selectedElementIds[container.id]).toBe(true);
expect(h.state.selectedElementIds[rect.id]).toBe(true);
expect(h.state.selectedGroupIds).toEqual({ A: true });
});
});
it("selecting & deselecting grouped elements visually nested inside another", async () => {
const rect1 = API.createElement({
type: "rectangle",
@@ -751,7 +1058,7 @@ describe("inner box-selection", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.downAt(rect2.x - 20, rect2.y - 20);
mouse.move(-1000, -1000);
mouse.moveTo(rect2.x + rect2.width + 10, rect2.y + rect2.height + 10);
mouse.moveTo(rect3.x + rect3.width + 10, rect3.y + rect3.height + 10);
assertSelectedElements([rect2.id, rect3.id]);
expect(h.state.selectedGroupIds).toEqual({ A: true });
mouse.moveTo(rect2.x - 10, rect2.y - 10);
+27 -1
View File
@@ -4,10 +4,12 @@ import { resolvablePromise } from "@excalidraw/common";
import { Excalidraw } from "../index";
import { getToolbarTools } from "../components/shapes";
import { Pointer } from "./helpers/ui";
import { act, render } from "./test-utils";
import type { ExcalidrawImperativeAPI } from "../types";
import type { AppClassProperties, ExcalidrawImperativeAPI } from "../types";
describe("setActiveTool()", () => {
const h = window.h;
@@ -66,3 +68,27 @@ describe("setActiveTool()", () => {
expect(h.state.activeTool.customType).toBe("comment");
});
});
describe("getToolbarTools()", () => {
const getToolValues = (preferredSelectionTool: "selection" | "lasso") =>
getToolbarTools({
state: {
preferredSelectionTool: {
type: preferredSelectionTool,
},
},
} as AppClassProperties).map((tool) => tool.value);
it("does not include lasso when selection is preferred", () => {
const toolValues = getToolValues("selection");
expect(toolValues.filter((value) => value === "selection")).toHaveLength(1);
expect(toolValues.filter((value) => value === "lasso")).toHaveLength(0);
});
it("replaces selection with lasso when lasso is preferred", () => {
const toolValues = getToolValues("lasso");
expect(toolValues.filter((value) => value === "lasso")).toHaveLength(1);
expect(toolValues.filter((value) => value === "selection")).toHaveLength(0);
});
});
@@ -769,6 +769,63 @@ describe("textWysiwyg", () => {
]);
});
it("should not add bound text to a frame when its container is not a frame child", async () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 200,
height: 200,
});
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "red",
});
API.setElements([frame, rectangle]);
mouse.doubleClickAt(rectangle.x + 10, rectangle.y + 10);
const text = h.elements[2] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(text.frameId).toBe(null);
});
it("should bind text to a frame child container when single clicking its center", async () => {
const frame = API.createElement({
type: "frame",
x: 0,
y: 0,
width: 200,
height: 200,
});
const rectangle = API.createElement({
type: "rectangle",
x: 10,
y: 20,
width: 90,
height: 75,
backgroundColor: "red",
frameId: frame.id,
});
API.setElements([rectangle, frame]);
UI.clickTool("text");
mouse.clickAt(
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
expect(text.containerId).toBe(rectangle.id);
expect(text.frameId).toBe(frame.id);
});
it("should set the text element angle to same as container angle when binding to rotated container", async () => {
const rectangle = API.createElement({
type: "rectangle",
View File
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@excalidraw/fractional-indexing",
"version": "3.3.0",
"description": "Provides functions for generating ordering strings",
"type": "module",
"types": "./dist/types/fractional-indexing/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
},
"keywords": [
"fractional",
"indexing",
"ordering",
"order"
],
"homepage": "https://github.com/rocicorp/fractional-indexing#readme",
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"author": "arv@rocicorp.dev",
"license": "CC0-1.0",
"devDependencies": {
"prettier": "^2.6.0",
"typescript": "5.9.3"
},
"exports": {
".": {
"types": "./dist/types/fractional-indexing/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"publishConfig": {
"access": "public"
},
"files": [
"dist/*"
]
}
+322
View File
@@ -0,0 +1,322 @@
// Vendored from https://www.npmjs.com/package/fractional-indexing
// License: CC0 (no rights reserved).
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
export const BASE_62_DIGITS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// `a` may be empty string, `b` is null or non-empty string.
// `a < b` lexicographically if `b` is non-null.
// no trailing zeros allowed.
// digits is a string such as '0123456789' for base 10. Digits must be in
// ascending character code order!
/**
* @param {string} a
* @param {string | null | undefined} b
* @param {string} digits
* @returns {string}
*/
function midpoint(
a: string,
b: string | null | undefined,
digits: string,
): string {
const zero = digits[0];
if (b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
throw new Error("trailing zero");
}
if (b) {
// remove longest common prefix. pad `a` with 0s as we
// go. note that we don't need to pad `b`, because it can't
// end before `a` while traversing the common prefix.
let n = 0;
while ((a[n] || zero) === b[n]) {
n++;
}
if (n > 0) {
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
}
}
// first digits (or lack of digit) are different
const digitA = a ? digits.indexOf(a[0]) : 0;
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
if (digitB - digitA > 1) {
const midDigit = Math.round(0.5 * (digitA + digitB));
return digits[midDigit];
}
// first digits are consecutive
if (b && b.length > 1) {
return b.slice(0, 1);
}
// `b` is null or has length 1 (a single digit).
// the first digit of `a` is the previous digit to `b`,
// or 9 if `b` is null.
// given, for example, midpoint('49', '5'), return
// '4' + midpoint('9', null), which will become
// '4' + '9' + midpoint('', null), which is '495'
return digits[digitA] + midpoint(a.slice(1), null, digits);
}
/**
* @param {string} int
* @return {void}
*/
function validateInteger(int: string): void {
if (int.length !== getIntegerLength(int[0])) {
throw new Error(`invalid integer part of order key: ${int}`);
}
}
/**
* @param {string} head
* @return {number}
*/
function getIntegerLength(head: string): number {
if (head >= "a" && head <= "z") {
return head.charCodeAt(0) - "a".charCodeAt(0) + 2;
} else if (head >= "A" && head <= "Z") {
return "Z".charCodeAt(0) - head.charCodeAt(0) + 2;
}
throw new Error(`invalid order key head: ${head}`);
}
/**
* @param {string} key
* @return {string}
*/
function getIntegerPart(key: string): string {
const integerPartLength = getIntegerLength(key[0]);
if (integerPartLength > key.length) {
throw new Error(`invalid order key: ${key}`);
}
return key.slice(0, integerPartLength);
}
/**
* @param {string} key
* @param {string} digits
* @return {void}
*/
export function validateOrderKey(
key: string,
digits: string = BASE_62_DIGITS,
): void {
const validChars = key.split("").every((char) => digits.includes(char));
if (key === `A${digits[0].repeat(26)}` || !validChars) {
throw new Error(`invalid order key: ${key}`);
}
// getIntegerPart will throw if the first character is bad,
// or the key is too short. we'd call it to check these things
// even if we didn't need the result
const i = getIntegerPart(key);
const f = key.slice(i.length);
if (f.slice(-1) === digits[0]) {
throw new Error(`invalid order key: ${key}`);
}
}
// note that this may return null, as there is a largest integer
/**
* @param {string} x
* @param {string} digits
* @return {string | null}
*/
function incrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split("");
let carry = true;
for (let i = digs.length - 1; carry && i >= 0; i--) {
const d = digits.indexOf(digs[i]) + 1;
if (d === digits.length) {
digs[i] = digits[0];
} else {
digs[i] = digits[d];
carry = false;
}
}
if (carry) {
if (head === "Z") {
return `a${digits[0]}`;
}
if (head === "z") {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) + 1);
if (h > "a") {
digs.push(digits[0]);
} else {
digs.pop();
}
return h + digs.join("");
}
return head + digs.join("");
}
// note that this may return null, as there is a smallest integer
/**
* @param {string} x
* @param {string} digits
* @return {string | null}
*/
function decrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split("");
let borrow = true;
for (let i = digs.length - 1; borrow && i >= 0; i--) {
const d = digits.indexOf(digs[i]) - 1;
if (d === -1) {
digs[i] = digits.slice(-1);
} else {
digs[i] = digits[d];
borrow = false;
}
}
if (borrow) {
if (head === "a") {
return `Z${digits.slice(-1)}`;
}
if (head === "A") {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) - 1);
if (h < "Z") {
digs.push(digits.slice(-1));
} else {
digs.pop();
}
return h + digs.join("");
}
return head + digs.join("");
}
// `a` is an order key or null (START).
// `b` is an order key or null (END).
// `a < b` lexicographically if both are non-null.
// digits is a string such as '0123456789' for base 10. Digits must be in
// ascending character code order!
/**
* @param {string | null | undefined} a
* @param {string | null | undefined} b
* @param {string=} digits
* @return {string}
*/
export function generateKeyBetween(
a: string | null | undefined,
b: string | null | undefined,
digits = BASE_62_DIGITS,
): string {
if (a != null) {
validateOrderKey(a, digits);
}
if (b != null) {
validateOrderKey(b, digits);
}
if (a != null && b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a == null) {
if (b == null) {
return `a${digits[0]}`;
}
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ib === `A${digits[0].repeat(26)}`) {
return ib + midpoint("", fb, digits);
}
if (ib < b) {
return ib;
}
const res = decrementInteger(ib, digits);
if (res == null) {
throw new Error("cannot decrement any more");
}
return res;
}
if (b == null) {
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const i = incrementInteger(ia, digits);
return i == null ? ia + midpoint(fa, null, digits) : i;
}
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ia === ib) {
return ia + midpoint(fa, fb, digits);
}
const i = incrementInteger(ia, digits);
if (i == null) {
throw new Error("cannot increment any more");
}
if (i < b) {
return i;
}
return ia + midpoint(fa, null, digits);
}
/**
* same preconditions as generateKeysBetween.
* n >= 0.
* Returns an array of n distinct keys in sorted order.
* If a and b are both null, returns [a0, a1, ...]
* If one or the other is null, returns consecutive "integer"
* keys. Otherwise, returns relatively short keys between
* a and b.
* @param {string | null | undefined} a
* @param {string | null | undefined} b
* @param {number} n
* @param {string} digits
* @return {string[]}
*/
export function generateNKeysBetween(
a: string | null | undefined,
b: string | null | undefined,
n: number,
digits = BASE_62_DIGITS,
): string[] {
if (n === 0) {
return [];
}
if (n === 1) {
return [generateKeyBetween(a, b, digits)];
}
if (b == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(c, b, digits);
result.push(c);
}
return result;
}
if (a == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(a, c, digits);
result.push(c);
}
result.reverse();
return result;
}
const mid = Math.floor(n / 2);
const c = generateKeyBetween(a, b, digits);
return [
...generateNKeysBetween(a, c, mid, digits),
c,
...generateNKeysBetween(c, b, n - mid - 1, digits),
];
}
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}
+3 -1
View File
@@ -20,7 +20,9 @@
"@excalidraw/math": ["./math/src/index.ts"],
"@excalidraw/math/*": ["./math/src/*"],
"@excalidraw/utils": ["./utils/src/index.ts"],
"@excalidraw/utils/*": ["./utils/src/*"]
"@excalidraw/utils/*": ["./utils/src/*"],
"@excalidraw/fractional-indexing": ["./fractional-indexing/src/index.ts"],
"@excalidraw/fractional-indexing/*": ["./fractional-indexing/src/*"]
}
}
}
+6 -1
View File
@@ -13,7 +13,12 @@ const getConfig = (outdir) => ({
alias: {
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
external: [
"@excalidraw/common",
"@excalidraw/element",
"@excalidraw/math",
"@excalidraw/fractional-indexing",
],
});
function buildDev(config) {
+6 -1
View File
@@ -74,7 +74,12 @@ const getConfig = (outdir) => ({
alias: {
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
external: ["@excalidraw/common", "@excalidraw/element", "@excalidraw/math"],
external: [
"@excalidraw/common",
"@excalidraw/element",
"@excalidraw/math",
"@excalidraw/fractional-indexing",
],
loader: {
".woff2": "file",
},
+4
View File
@@ -18,6 +18,10 @@ const getConfig = (outdir) => ({
"@excalidraw/element": path.resolve(__dirname, "../packages/element/src"),
"@excalidraw/excalidraw": path.resolve(__dirname, "../packages/excalidraw"),
"@excalidraw/math": path.resolve(__dirname, "../packages/math/src"),
"@excalidraw/fractional-indexing": path.resolve(
__dirname,
"../packages/fractional-indexing/src",
),
"@excalidraw/utils": path.resolve(__dirname, "../packages/utils/src"),
},
});
+7 -1
View File
@@ -6,7 +6,13 @@ const { execSync } = require("child_process");
const updateChangelog = require("./updateChangelog");
// skipping utils for now, as it has independent release process
const PACKAGES = ["common", "math", "element", "excalidraw"];
const PACKAGES = [
"common",
"fractional-indexing",
"math",
"element",
"excalidraw",
];
const PACKAGES_DIR = path.resolve(__dirname, "../packages");
/**
+7 -3
View File
@@ -8,7 +8,13 @@ import { vi } from "vitest";
import polyfill from "./packages/excalidraw/polyfill";
import { mockThrottleRAF } from "./packages/excalidraw/tests/helpers/mocks";
import { yellow } from "./packages/excalidraw/tests/helpers/colorize";
import { testPolyfills } from "./packages/excalidraw/tests/helpers/polyfills";
import {
PolyfillLocalStorage,
testPolyfills,
} from "./packages/excalidraw/tests/helpers/polyfills";
Object.assign(globalThis, testPolyfills);
PolyfillLocalStorage();
vi.mock("@excalidraw/common", async (importOriginal) => {
const module = await importOriginal<typeof import("@excalidraw/common")>();
@@ -22,8 +28,6 @@ vi.mock("@excalidraw/common", async (importOriginal) => {
// mock for pep.js not working with setPointerCapture()
HTMLElement.prototype.setPointerCapture = vi.fn();
Object.assign(globalThis, testPolyfills);
require("fake-indexeddb/auto");
polyfill();
+2
View File
@@ -25,6 +25,8 @@
"@excalidraw/excalidraw/*": ["./packages/excalidraw/*"],
"@excalidraw/element": ["./packages/element/src/index.ts"],
"@excalidraw/element/*": ["./packages/element/src/*"],
"@excalidraw/fractional-indexing": ["./packages/fractional-indexing/src/index.ts"],
"@excalidraw/fractional-indexing/*": ["./packages/fractional-indexing/src/*"],
"@excalidraw/math": ["./packages/math/src/index.ts"],
"@excalidraw/math/*": ["./packages/math/src/*"],
"@excalidraw/utils": ["./packages/utils/src/index.ts"],
+14
View File
@@ -45,6 +45,20 @@ export default defineConfig({
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",
),
},
{
find: /^@excalidraw\/fractional-indexing\/(.*?)/,
replacement: path.resolve(
__dirname,
"./packages/fractional-indexing/src/$1",
),
},
],
},
//@ts-ignore
+5 -5
View File
@@ -6572,11 +6572,6 @@ fraction.js@^4.2.0:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
fractional-indexing@3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fractional-indexing/-/fractional-indexing-3.2.0.tgz#1193e63d54ff4e0cbe0c79a9ed6cfbab25d91628"
integrity sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==
fs-constants@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
@@ -8525,6 +8520,11 @@ prettier@2.6.2:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.6.2.tgz#e26d71a18a74c3d0f0597f55f01fb6c06c206032"
integrity sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==
prettier@^2.6.0:
version "2.8.8"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
pretty-bytes@^5.3.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"