Compare commits

..

12 Commits

Author SHA1 Message Date
Mark Tolmacs
42acd426a3 fix: Direct binding manipulation 2025-08-21 14:46:02 +02:00
Mark Tolmacs
a2cd3f0e77 Fix missing babel transformer 2025-08-21 14:43:49 +02:00
Mark Tolmacs
0c5e420812 Add custom binding check as plugin 2025-08-21 14:27:46 +02:00
Ryan Di
b4903a7eab feat: drag, resize, and rotate after selecting in lasso (#9732)
* feat: drag, resize, and rotate after selecting in lasso

* alternative ux: drag with lasso right away

* fix: lasso dragging should snap too

* fix: alt+cmd getting stuck

* test: snapshots

* alternatvie: keep lasso drag to only mobile

* alternative: drag after selection on PCs

* improve mobile dection

* add mobile lasso icon

* add default selection tool

* render according to default selection tool

* return to default selection tool after deletion

* reset to default tool after clearing out the canvas

* return to default tool after eraser toggle

* if default lasso, close lasso toggle

* finalize to default selection tool

* toggle between laser and default selection

* return to default selection tool after creation

* double click to add text when using default selection tool

* set to default selection tool after unlocking tool

* paste to center on touch screen

* switch to default selection tool after pasting

* lint

* fix tests

* show welcome screen when using default selection tool

* fix tests

* fix snapshots

* fix context menu not opening

* prevent potential displacement issue

* prevent element jumping during lasso selection

* fix dragging on mobile

* use same selection icon

* fix alt+cmd lasso getting cut off

* fix: shortcut handling

* lint

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-08-20 00:03:02 +02:00
zsviczian
c6f8ef9ad2 fix: Scene deleted after pica image resize failure (#9879)
Revert change in private updateImageCache
2025-08-18 11:45:05 +02:00
Marcel Mraz
2535d73054 feat: apply deltas API (#9869) 2025-08-15 15:25:56 +02:00
David Luzar
dda3affcb0 fix: do not strip invisible elements from array (#9844) 2025-08-12 11:56:11 +02:00
Marcel Mraz
54c148f390 fix: text restore & deletion issues (#9853) 2025-08-12 09:27:04 +02:00
zsviczian
cc8e490c75 fix: do not auto-add elements to locked frame (#9851)
* Do not return locked frames when filtering for top level frame

* lint

* lint

* lint
2025-08-11 11:52:44 +02:00
Marcel Mraz
9036812b6d fix: editing linear element (#9839) 2025-08-08 09:30:11 +02:00
Marcel Mraz
df25de7e68 feat: fix delta apply to issues (#9830) 2025-08-07 15:38:58 +02:00
David Luzar
a3763648fe chore: update title (#9814)
* chore: update title

* update meta tag

* lint
2025-08-01 17:17:42 +02:00
38 changed files with 2078 additions and 808 deletions

View File

@@ -1,5 +1,6 @@
{
"extends": ["@excalidraw/eslint-config", "react-app"],
"plugins": ["excalidraw"],
"rules": {
"import/order": [
"warn",
@@ -38,6 +39,7 @@
{
"allowReferrer": true
}
]
],
"excalidraw/no-binding-direct-mod": "error"
}
}

View File

@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
}
const storedScene = docSnap.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
restoreElements(await decryptElements(storedScene, roomKey), null, {
deleteInvisibleElements: true,
}),
);
if (socket) {

View File

@@ -258,11 +258,16 @@ export const loadScene = async (
await importFromBackend(id, privateKey),
localDataState?.appState,
localDataState?.elements,
{ repairBindings: true, refreshDimensions: false },
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
);
} else {
data = restore(localDataState || null, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
}

View File

@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<title>
Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw
</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
@@ -14,7 +16,7 @@
<!-- Primary Meta Tags -->
<meta
name="title"
content="Excalidraw — Collaborative whiteboarding made easy"
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
/>
<meta
name="description"

View File

@@ -8,6 +8,7 @@
"examples/*"
],
"devDependencies": {
"@babel/plugin-transform-explicit-resource-management": "7.28.0",
"@babel/preset-env": "7.26.9",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
@@ -24,6 +25,7 @@
"dotenv": "16.0.1",
"eslint-config-prettier": "8.5.0",
"eslint-config-react-app": "7.0.1",
"eslint-plugin-eslint": "file:packages/eslint",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "3.3.1",
"http-server": "14.1.1",

View File

@@ -18,13 +18,22 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/.test(navigator.platform) ||
/iPad|iPhone/i.test(navigator.platform) ||
// iPadOS 13+
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
// keeping function so it can be mocked in test
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const isMobile =
isIOS ||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
navigator.userAgent.toLowerCase(),
) ||
/android|ios|ipod|blackberry|windows phone/i.test(
navigator.platform.toLowerCase(),
);
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;

View File

@@ -164,9 +164,14 @@ export class Scene {
return this.frames;
}
constructor(elements: ElementsMapOrArray | null = null) {
constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
if (elements) {
this.replaceAllElements(elements);
this.replaceAllElements(elements, options);
}
}
@@ -263,12 +268,19 @@ export class Scene {
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray) {
replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
const _nextElements = toArray(nextElements);
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
validateIndicesThrottled(_nextElements);
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();

View File

@@ -2,7 +2,6 @@ import {
arrayToMap,
arrayToObject,
assertNever,
invariant,
isDevEnv,
isShallowEqual,
isTestEnv,
@@ -56,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import { Scene } from "./Scene";
import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement";
@@ -151,13 +150,27 @@ export class Delta<T> {
);
}
/**
* Merges two deltas into a new one.
*/
public static merge<T>(
delta1: Delta<T>,
delta2: Delta<T>,
delta3: Delta<T> = Delta.empty(),
) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
);
}
/**
* Merges deleted and inserted object partials.
*/
public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T,
added: T,
removed: T,
removed: T = {} as T,
) {
const cloned = { ...prev };
@@ -497,6 +510,11 @@ export interface DeltaContainer<T> {
*/
applyTo(previous: T, ...options: unknown[]): [T, boolean];
/**
* Squashes the current delta with the given one.
*/
squash(delta: DeltaContainer<T>): this;
/**
* Checks whether all `Delta`s are empty.
*/
@@ -504,7 +522,11 @@ export interface DeltaContainer<T> {
}
export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public readonly delta: Delta<ObservedAppState>) {}
private constructor(public delta: Delta<ObservedAppState>) {}
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
return new AppStateDelta(delta);
}
public static calculate<T extends ObservedAppState>(
prevAppState: T,
@@ -535,76 +557,137 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return new AppStateDelta(inversedDelta);
}
public squash(delta: AppStateDelta): this {
if (delta.isEmpty()) {
return this;
}
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
this.delta.deleted.selectedElementIds ?? {},
delta.delta.deleted.selectedElementIds ?? {},
);
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
this.delta.inserted.selectedElementIds ?? {},
delta.delta.inserted.selectedElementIds ?? {},
);
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
this.delta.deleted.selectedGroupIds ?? {},
delta.delta.deleted.selectedGroupIds ?? {},
);
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
this.delta.inserted.selectedGroupIds ?? {},
delta.delta.inserted.selectedGroupIds ?? {},
);
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
this.delta.deleted.lockedMultiSelections ?? {},
delta.delta.deleted.lockedMultiSelections ?? {},
);
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
this.delta.inserted.lockedMultiSelections ?? {},
delta.delta.inserted.lockedMultiSelections ?? {},
);
const mergedInserted: Partial<ObservedAppState> = {};
const mergedDeleted: Partial<ObservedAppState> = {};
if (
Object.keys(mergedDeletedSelectedElementIds).length ||
Object.keys(mergedInsertedSelectedElementIds).length
) {
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
}
if (
Object.keys(mergedDeletedSelectedGroupIds).length ||
Object.keys(mergedInsertedSelectedGroupIds).length
) {
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
}
if (
Object.keys(mergedDeletedLockedMultiSelections).length ||
Object.keys(mergedInsertedLockedMultiSelections).length
) {
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
mergedInserted.lockedMultiSelections =
mergedInsertedLockedMultiSelections;
}
this.delta = Delta.merge(
this.delta,
delta.delta,
Delta.create(mergedDeleted, mergedInserted),
);
return this;
}
public applyTo(
appState: AppState,
nextElements: SceneElementsMap,
): [AppState, boolean] {
try {
const {
selectedElementIds: removedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {},
selectedElementIds: deletedSelectedElementIds = {},
selectedGroupIds: deletedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
} = this.delta.deleted;
const {
selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {},
selectedLinearElementId,
selectedLinearElementIsEditing,
selectedElementIds: insertedSelectedElementIds = {},
selectedGroupIds: insertedSelectedGroupIds = {},
lockedMultiSelections: insertedLockedMultiSelections = {},
selectedLinearElement: insertedSelectedLinearElement,
...directlyApplicablePartial
} = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds,
addedSelectedElementIds,
removedSelectedElementIds,
insertedSelectedElementIds,
deletedSelectedElementIds,
);
const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds,
addedSelectedGroupIds,
removedSelectedGroupIds,
insertedSelectedGroupIds,
deletedSelectedGroupIds,
);
let selectedLinearElement = appState.selectedLinearElement;
const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
);
if (selectedLinearElementId === null) {
// Unselect linear element (visible change)
selectedLinearElement = null;
} else if (
selectedLinearElementId &&
nextElements.has(selectedLinearElementId)
) {
selectedLinearElement = new LinearElementEditor(
nextElements.get(
selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
);
}
if (
// Value being 'null' is equivaluent to unknown in this case because it only gets set
// to null when 'selectedLinearElementId' is set to null
selectedLinearElementIsEditing != null
) {
invariant(
selectedLinearElement,
`selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
);
selectedLinearElement = {
...selectedLinearElement,
isEditing: selectedLinearElementIsEditing,
};
}
const selectedLinearElement =
insertedSelectedLinearElement &&
nextElements.has(insertedSelectedLinearElement.elementId)
? new LinearElementEditor(
nextElements.get(
insertedSelectedLinearElement.elementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
insertedSelectedLinearElement.isEditing,
)
: null;
const nextAppState = {
...appState,
...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds,
selectedLinearElement,
lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement:
typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement
: appState.selectedLinearElement,
};
const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -733,64 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
}
break;
case "selectedLinearElementId": {
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
case "selectedLinearElement":
const nextLinearElement = nextAppState[key];
if (!linearElement) {
if (!nextLinearElement) {
// previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true;
} else {
const element = nextElements.get(linearElement.elementId);
const element = nextElements.get(nextLinearElement.elementId);
if (element && !element.isDeleted) {
// previously there wasn't a linear element, now there is one which is visible
visibleDifferenceFlag.value = true;
} else {
// there was assigned a linear element now, but it's deleted
nextAppState[appStateKey] = null;
nextAppState[key] = null;
}
}
break;
}
case "selectedLinearElementIsEditing": {
// Changes in editing state are always visible
const prevIsEditing =
prevAppState.selectedLinearElement?.isEditing ?? false;
const nextIsEditing =
nextAppState.selectedLinearElement?.isEditing ?? false;
if (prevIsEditing !== nextIsEditing) {
visibleDifferenceFlag.value = true;
}
break;
}
case "lockedMultiSelections": {
case "lockedMultiSelections":
const prevLockedUnits = prevAppState[key] || {};
const nextLockedUnits = nextAppState[key] || {};
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
visibleDifferenceFlag.value = true;
}
break;
}
case "activeLockedId": {
case "activeLockedId":
const prevHitLockedId = prevAppState[key] || null;
const nextHitLockedId = nextAppState[key] || null;
// TODO: this seems wrong, we are already doing this comparison generically above,
// hence instead we should check whether elements are actually visible,
// so that once these changes are applied they actually result in a visible change to the user
if (prevHitLockedId !== nextHitLockedId) {
visibleDifferenceFlag.value = true;
}
break;
}
default: {
default:
assertNever(
key,
`Unknown ObservedElementsAppState's key "${key}"`,
true,
);
}
}
}
}
@@ -798,15 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
return visibleDifferenceFlag.value;
}
private static convertToAppStateKey(
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
): keyof Pick<AppState, "selectedLinearElement"> {
switch (key) {
case "selectedLinearElementId":
return "selectedLinearElement";
}
}
private static filterSelectedElements(
selectedElementIds: AppState["selectedElementIds"],
elements: SceneElementsMap,
@@ -871,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId,
selectedGroupIds,
selectedElementIds,
selectedLinearElementId,
selectedLinearElementIsEditing,
selectedLinearElement,
croppingElementId,
lockedMultiSelections,
activeLockedId,
@@ -926,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
"lockedMultiSelections",
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
);
Delta.diffObjects(
deleted,
inserted,
"activeLockedId",
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
);
} catch (e) {
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
console.error(`Couldn't postprocess appstate change deltas.`);
@@ -960,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
export type ApplyToOptions = {
excludedProperties: Set<keyof ElementPartial>;
excludedProperties?: Set<keyof ElementPartial>;
};
type ApplyToFlags = {
containsVisibleDifference: boolean;
containsZindexDifference: boolean;
applyDirection: "forward" | "backward" | undefined;
};
/**
@@ -1066,6 +1123,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
deleted.version !== inserted.version
);
private static satisfiesUniqueInvariants = (
elementsDelta: ElementsDelta,
id: string,
) => {
const { added, removed, updated } = elementsDelta;
// it's required that there is only one unique delta type per element
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
};
private static validate(
elementsDelta: ElementsDelta,
type: "added" | "removed" | "updated",
@@ -1074,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
for (const [id, delta] of Object.entries(elementsDelta[type])) {
if (
!this.satisfiesCommmonInvariants(delta) ||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
!satifiesSpecialInvariants(delta)
) {
console.error(
@@ -1110,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id);
if (!nextElement) {
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const deleted = { ...prevElement } as ElementPartial;
const inserted = {
isDeleted: true,
@@ -1124,7 +1191,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps,
);
removed[prevElement.id] = delta;
// ignore updates which would "delete" already deleted element
if (!prevElement.isDeleted) {
removed[prevElement.id] = delta;
}
}
}
@@ -1140,7 +1210,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = {
...nextElement,
isDeleted: false,
} as ElementPartial;
const delta = Delta.create(
@@ -1149,7 +1218,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps,
);
added[nextElement.id] = delta;
// ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
added[nextElement.id] = delta;
}
continue;
}
@@ -1178,8 +1250,13 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue;
}
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
const strippedDeleted = ElementsDelta.stripVersionProps(delta.deleted);
const strippedInserted = ElementsDelta.stripVersionProps(
delta.inserted,
);
// making sure there are at least some changes and only changed version & versionNonce does not count!
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted, true)) {
updated[nextElement.id] = delta;
}
}
@@ -1196,8 +1273,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
}
return inversedDeltas;
@@ -1295,8 +1372,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
latestDelta = delta;
}
const strippedDeleted = ElementsDelta.stripVersionProps(
latestDelta.deleted,
);
const strippedInserted = ElementsDelta.stripVersionProps(
latestDelta.inserted,
);
// it might happen that after applying latest changes the delta itself does not contain any changes
if (Delta.isInnerDifferent(latestDelta.deleted, latestDelta.inserted)) {
if (Delta.isInnerDifferent(strippedDeleted, strippedInserted)) {
modifiedDeltas[id] = latestDelta;
}
}
@@ -1316,9 +1400,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo(
elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
options?: ApplyToOptions,
): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
@@ -1326,22 +1408,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const flags: ApplyToFlags = {
containsVisibleDifference: false,
containsZindexDifference: false,
applyDirection: undefined,
};
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
try {
const applyDeltas = ElementsDelta.createApplier(
elements,
nextElements,
snapshot,
options,
flags,
options,
);
const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(elements, nextElements);
const affectedElements = this.resolveConflicts(
elements,
nextElements,
flags.applyDirection,
);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([
@@ -1365,22 +1453,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
try {
// the following reorder performs also mutations, but only on new instances of changed elements
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
// the following reorder performs mutations, but only on new instances of changed elements,
// unless something goes really bad and it fallbacks to fixing all invalid indices
nextElements = ElementsDelta.reorderElements(
nextElements,
changedElements,
flags,
);
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
ElementsDelta.redrawElements(nextElements, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@@ -1395,12 +1476,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
}
public squash(delta: ElementsDelta): this {
if (delta.isEmpty()) {
return this;
}
const { added, removed, updated } = delta;
const mergeBoundElements = (
prevDelta: Delta<ElementPartial>,
nextDelta: Delta<ElementPartial>,
) => {
const mergedDeletedBoundElements =
Delta.mergeArrays(
prevDelta.deleted.boundElements ?? [],
nextDelta.deleted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
const mergedInsertedBoundElements =
Delta.mergeArrays(
prevDelta.inserted.boundElements ?? [],
nextDelta.inserted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
if (
!mergedDeletedBoundElements.length &&
!mergedInsertedBoundElements.length
) {
return;
}
return Delta.create(
{
boundElements: mergedDeletedBoundElements,
},
{
boundElements: mergedInsertedBoundElements,
},
);
};
for (const [id, nextDelta] of Object.entries(added)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.added[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.removed[id];
delete this.updated[id];
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(removed)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.removed[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.added[id];
delete this.updated[id];
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(updated)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.updated[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
if (prevDelta === this.added[id]) {
this.added[id] = updatedDelta;
} else if (prevDelta === this.removed[id]) {
this.removed[id] = updatedDelta;
} else {
this.updated[id] = updatedDelta;
}
}
}
if (isTestEnv() || isDevEnv()) {
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
}
return this;
}
private static createApplier =
(
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags,
options?: ApplyToOptions,
) =>
(deltas: Record<string, Delta<ElementPartial>>) => {
const getElement = ElementsDelta.createGetter(
@@ -1413,15 +1595,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted);
if (element) {
const newElement = ElementsDelta.applyDelta(
const nextElement = ElementsDelta.applyDelta(
element,
delta,
options,
flags,
options,
);
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
nextElements.set(nextElement.id, nextElement);
acc.set(nextElement.id, nextElement);
if (!flags.applyDirection) {
const prevElement = prevElements.get(id);
if (prevElement) {
flags.applyDirection =
prevElement.version > nextElement.version
? "backward"
: "forward";
}
}
}
return acc;
@@ -1466,8 +1659,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private static applyDelta(
element: OrderedExcalidrawElement,
delta: Delta<ElementPartial>,
options: ApplyToOptions,
flags: ApplyToFlags,
options?: ApplyToOptions,
) {
const directlyApplicablePartial: Mutable<ElementPartial> = {};
@@ -1481,7 +1674,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue;
}
if (options.excludedProperties.has(key)) {
if (options?.excludedProperties?.has(key)) {
continue;
}
@@ -1521,7 +1714,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index;
}
return newElementWith(element, directlyApplicablePartial);
return newElementWith(element, directlyApplicablePartial, true);
}
/**
@@ -1561,6 +1754,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private resolveConflicts(
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
applyDirection: "forward" | "backward" = "forward",
) {
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
const updater = (
@@ -1572,21 +1766,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return;
}
const prevElement = prevElements.get(element.id);
const nextVersion =
applyDirection === "forward"
? nextElement.version + 1
: nextElement.version - 1;
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
let affectedElement: OrderedExcalidrawElement;
if (prevElements.get(element.id) === nextElement) {
if (prevElement === nextElement) {
// create the new element instance in case we didn't modify the element yet
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
affectedElement = newElementWith(
nextElement,
updates as ElementUpdate<OrderedExcalidrawElement>,
{
...elementUpdates,
version: nextVersion,
},
true,
);
} else {
affectedElement = mutateElement(
nextElement,
nextElements,
updates as ElementUpdate<OrderedExcalidrawElement>,
);
affectedElement = mutateElement(nextElement, nextElements, {
...elementUpdates,
// don't modify the version further, if it's already different
version:
prevElement?.version !== nextElement.version
? nextElement.version
: nextVersion,
});
}
nextAffectedElements.set(affectedElement.id, affectedElement);
@@ -1624,25 +1833,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
);
// calculate complete deltas for affected elements, and assign them back to all the deltas
// technically we could do better here if perf. would become an issue
const { added, removed, updated } = ElementsDelta.calculate(
prevAffectedElements,
nextAffectedElements,
// calculate complete deltas for affected elements, and squash them back to the current deltas
this.squash(
// technically we could do better here if perf. would become an issue
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
);
for (const [id, delta] of Object.entries(added)) {
this.added[id] = delta;
}
for (const [id, delta] of Object.entries(removed)) {
this.removed[id] = delta;
}
for (const [id, delta] of Object.entries(updated)) {
this.updated[id] = delta;
}
return nextAffectedElements;
}
@@ -1704,6 +1900,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
BindableElement.rebindAffected(nextElements, nextElement(), updater);
}
public static redrawElements(
nextElements: SceneElementsMap,
changedElements: Map<string, OrderedExcalidrawElement>,
) {
try {
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements, { skipValidation: true });
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// needs ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(`Couldn't redraw elements`, e);
if (isTestEnv() || isDevEnv()) {
throw e;
}
} finally {
return nextElements;
}
}
private static redrawTextBoundingBoxes(
scene: Scene,
changed: Map<string, OrderedExcalidrawElement>,
@@ -1758,6 +1979,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
) {
for (const element of changed.values()) {
if (!element.isDeleted && isBindableElement(element)) {
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
updateBoundElements(element, scene, {
changedElements: changed,
});
@@ -1853,4 +2075,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
return strippedPartial;
}
private static stripVersionProps(
partial: Partial<OrderedExcalidrawElement>,
): ElementPartial {
const { version, versionNonce, ...strippedPartial } = partial;
return strippedPartial;
}
}

View File

@@ -76,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events.
*/
export class Store {
// internally used by history
// for internal use by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement]
>();
@@ -239,7 +240,6 @@ export class Store {
if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment);
}
@@ -552,10 +552,26 @@ export class StoreDelta {
public static load({
id,
elements: { added, removed, updated },
appState: { delta: appStateDelta },
}: DTO<StoreDelta>) {
const elements = ElementsDelta.create(added, removed, updated);
const appState = AppStateDelta.create(appStateDelta);
return new this(id, elements, AppStateDelta.empty());
return new this(id, elements, appState);
}
/**
* Squash the passed deltas into the aggregated delta instance.
*/
public static squash(...deltas: StoreDelta[]) {
const aggregatedDelta = StoreDelta.empty();
for (const delta of deltas) {
aggregatedDelta.elements.squash(delta.elements);
aggregatedDelta.appState.squash(delta.appState);
}
return aggregatedDelta;
}
/**
@@ -572,9 +588,7 @@ export class StoreDelta {
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
@@ -613,6 +627,10 @@ export class StoreDelta {
);
}
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty();
}
@@ -978,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
selectedLinearElement: null,
croppingElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
@@ -998,14 +1015,12 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElementId:
(appState as AppState).selectedLinearElement?.elementId ??
(appState as ObservedAppState).selectedLinearElementId ??
null,
selectedLinearElementIsEditing:
(appState as AppState).selectedLinearElement?.isEditing ??
(appState as ObservedAppState).selectedLinearElementIsEditing ??
null,
selectedLinearElement: appState.selectedLinearElement
? {
elementId: appState.selectedLinearElement.elementId,
isEditing: !!appState.selectedLinearElement.isEditing,
}
: null,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

@@ -1,13 +1,328 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element";
import type { SceneElementsMap } from "@excalidraw/element/types";
import { AppStateDelta } from "../src/delta";
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
describe("ElementsDelta", () => {
describe("elements delta calculation", () => {
it("should not create removed delta when element gets removed but was already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map([[element.id, element]]);
const nextElements = new Map();
const delta = ElementsDelta.calculate(prevElements, nextElements);
expect(delta.isEmpty()).toBeTruthy();
});
it("should not create added delta when adding element as already deleted", () => {
const element = API.createElement({
type: "rectangle",
x: 100,
y: 100,
isDeleted: true,
});
const prevElements = new Map();
const nextElements = new Map([[element.id, element]]);
const delta = ElementsDelta.calculate(prevElements, nextElements);
expect(delta.isEmpty()).toBeTruthy();
});
it("should not create updated delta when there is only version and versionNonce change", () => {
const baseElement = API.createElement({
type: "rectangle",
x: 100,
y: 100,
strokeColor: "#000000",
backgroundColor: "#ffffff",
});
const modifiedElement = {
...baseElement,
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
};
// Create maps for the delta calculation
const prevElements = new Map([[baseElement.id, baseElement]]);
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
// Calculate the delta
const delta = ElementsDelta.calculate(
prevElements as SceneElementsMap,
nextElements as SceneElementsMap,
);
expect(delta.isEmpty()).toBeTruthy();
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{},
{},
{ id1: updatedDelta },
);
const elementsDelta2 = ElementsDelta.empty();
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.updated.id1).toBe(updatedDelta);
});
it("should squash mutually exclusive delta types", () => {
const addedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
);
const removedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
);
const updatedDelta = Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
);
const elementsDelta1 = ElementsDelta.create(
{ id1: addedDelta },
{ id2: removedDelta },
{},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{ id3: updatedDelta },
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toBe(addedDelta);
expect(elementsDelta.removed.id2).toBe(removedDelta);
expect(elementsDelta.updated.id3).toBe(updatedDelta);
});
it("should squash the same delta types", () => {
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 100, version: 1, versionNonce: 1 },
{ x: 200, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id2: Delta.create(
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id3: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 200, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added.id1).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
),
);
expect(elementsDelta.removed.id2).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
),
);
expect(elementsDelta.updated.id3).toEqual(
Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2 },
{ x: 200, y: 200, version: 3, versionNonce: 3 },
),
);
});
it("should squash different delta types ", () => {
// id1: added -> updated => added
// id2: removed -> added => added
// id3: updated -> removed => removed
const elementsDelta1 = ElementsDelta.create(
{
id1: Delta.create(
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
),
},
{
id2: Delta.create(
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
),
},
{
id3: Delta.create(
{ x: 300, version: 1, versionNonce: 1 },
{ x: 301, version: 2, versionNonce: 2 },
),
},
);
const elementsDelta2 = ElementsDelta.create(
{
id2: Delta.create(
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
},
{
id3: Delta.create(
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
},
{
id1: Delta.create(
{ y: 100, version: 2, versionNonce: 2 },
{ y: 101, version: 3, versionNonce: 3 },
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.isEmpty()).toBeFalsy();
expect(elementsDelta).toBe(elementsDelta1);
expect(elementsDelta.added).toEqual({
id1: Delta.create(
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
),
id2: Delta.create(
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
),
});
expect(elementsDelta.removed).toEqual({
id3: Delta.create(
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
),
});
expect(elementsDelta.updated).toEqual({});
});
it("should squash bound elements", () => {
const elementsDelta1 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 1,
versionNonce: 1,
boundElements: [{ id: "t1", type: "text" }],
},
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "t2", type: "text" }],
},
),
},
);
const elementsDelta2 = ElementsDelta.create(
{},
{},
{
id1: Delta.create(
{
version: 2,
versionNonce: 2,
boundElements: [{ id: "a1", type: "arrow" }],
},
{
version: 3,
versionNonce: 3,
boundElements: [{ id: "a2", type: "arrow" }],
},
),
},
);
const elementsDelta = elementsDelta1.squash(elementsDelta2);
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
{ id: "t1", type: "text" },
{ id: "a1", type: "arrow" },
]);
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
{ id: "t2", type: "text" },
{ id: "a2", type: "arrow" },
]);
});
});
});
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const commonAppState = {
viewBackgroundColor: "#ffffff",
@@ -24,23 +339,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElementId: null,
selectedLinearElement: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElementId,
selectedLinearElement,
};
const prevAppState2: ObservedAppState = {
selectedLinearElementId: null,
selectedLinearElement: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElementId,
selectedLinearElement,
name,
...commonAppState,
};
@@ -58,9 +373,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@@ -106,9 +419,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@@ -149,4 +460,97 @@ describe("AppStateDelta", () => {
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
});
});
describe("squash", () => {
it("should not squash when second delta is empty", () => {
const delta = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const appStateDelta1 = AppStateDelta.create(delta);
const appStateDelta2 = AppStateDelta.empty();
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toBe(delta);
});
it("should squash exclusive properties", () => {
const delta1 = Delta.create(
{ name: "untitled scene" },
{ name: "titled scene" },
);
const delta2 = Delta.create(
{ viewBackgroundColor: "#ffffff" },
{ viewBackgroundColor: "#000000" },
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create(
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
{ name: "titled scene", viewBackgroundColor: "#000000" },
),
);
});
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
const delta1 = Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true },
selectedGroupIds: {},
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
);
const delta2 = Delta.create<Partial<ObservedAppState>>(
{
selectedElementIds: { id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: {},
},
{
selectedElementIds: { id2: true },
selectedGroupIds: { g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
);
const appStateDelta1 = AppStateDelta.create(delta1);
const appStateDelta2 = AppStateDelta.create(delta2);
const appStateDelta = appStateDelta1.squash(appStateDelta2);
expect(appStateDelta.isEmpty()).toBeFalsy();
expect(appStateDelta).toBe(appStateDelta1);
expect(appStateDelta.delta).toEqual(
Delta.create<Partial<ObservedAppState>>(
{
name: "untitled scene",
selectedElementIds: { id1: true, id3: true },
selectedGroupIds: { g1: true },
lockedMultiSelections: { g1: true },
},
{
name: "titled scene",
selectedElementIds: { id2: true },
selectedGroupIds: { g1: true, g2: true, g3: true },
lockedMultiSelections: { g3: true },
},
),
);
});
});
});

5
packages/eslint/index.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
rules: {
"no-binding-direct-mod": require("./no-binding-direct-mod"),
},
};

View File

@@ -0,0 +1,84 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: "problem",
docs: {
description:
"disallow direct mutation of startBinding or endBinding via mutateElement",
category: "Best Practices",
recommended: false,
},
fixable: null,
schema: [],
messages: {
noDirectBindingMutation:
"Direct mutation of {{ property }} via mutateElement() is not allowed. Use proper binding update functions instead.",
},
},
create(context) {
return {
CallExpression(node) {
// Check if this is a call to mutateElement (direct call or method call)
let isMutateElementCall = false;
if (
node.callee.type === "Identifier" &&
node.callee.name === "mutateElement"
) {
// Direct call: mutateElement()
isMutateElementCall = true;
} else if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "mutateElement"
) {
// Method call: something.mutateElement() or this.scene.mutateElement()
isMutateElementCall = true;
}
if (isMutateElementCall) {
// mutateElement can have different argument patterns:
// 1. mutateElement(element, updates) - 2 args
// 2. mutateElement(element, elementsMap, updates) - 3 args
// 3. mutateElement(element, updates, options) - 3 args
let updatesArg = null;
if (node.arguments.length >= 2) {
// Try second argument first (most common pattern)
const secondArg = node.arguments[1];
if (secondArg.type === "ObjectExpression") {
updatesArg = secondArg;
} else if (node.arguments.length >= 3) {
// If second arg is not an object, try third argument
const thirdArg = node.arguments[2];
if (thirdArg.type === "ObjectExpression") {
updatesArg = thirdArg;
}
}
}
if (updatesArg) {
// Look for startBinding or endBinding properties
for (const property of updatesArg.properties) {
if (
property.type === "Property" &&
property.key.type === "Identifier" &&
(property.key.name === "startBinding" ||
property.key.name === "endBinding")
) {
context.report({
node: property,
messageId: "noDirectBindingMutation",
data: {
property: property.key.name,
},
});
}
}
}
}
},
};
},
};

View File

@@ -0,0 +1,11 @@
{
"name": "eslint-plugin-excalidraw",
"version": "0.1.0",
"main": "index.js",
"scripts": {
"lint": "eslint ."
},
"devDependencies": {
"eslint": "^7.32.0"
}
}

View File

@@ -7,6 +7,7 @@ import {
getFontString,
} from "@excalidraw/common";
import {
bindOrUnbindLinearElement,
getOriginalContainerHeightFromCache,
resetOriginalContainerCache,
updateOriginalContainerCache,
@@ -36,6 +37,7 @@ import { newElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element";
import type {
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
@@ -270,7 +272,7 @@ export const actionWrapTextInContainer = register({
),
groupIds: textElement.groupIds,
frameId: textElement.frameId,
});
}) as ExcalidrawBindableElement;
// update bindings
if (textElement.boundElements?.length) {
@@ -281,26 +283,14 @@ export const actionWrapTextInContainer = register({
linearElementIds.includes(ele.id),
) as ExcalidrawLinearElement[];
linearElements.forEach((ele) => {
let startBinding = ele.startBinding;
let endBinding = ele.endBinding;
if (startBinding?.elementId === textElement.id) {
startBinding = {
...startBinding,
elementId: container.id,
};
}
if (endBinding?.elementId === textElement.id) {
endBinding = { ...endBinding, elementId: container.id };
}
if (startBinding || endBinding) {
app.scene.mutateElement(ele, {
startBinding,
endBinding,
});
}
bindOrUnbindLinearElement(
ele,
ele.startBinding?.elementId === textElement.id
? container
: "keep",
ele.endBinding?.elementId === textElement.id ? container : "keep",
app.scene,
);
});
}

View File

@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
? { ...appState.activeTool, type: app.defaultSelectionTool }
: appState.activeTool,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool",
label: "toolBar.eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
type: app.defaultSelectionTool,
}),
lastActiveToolBeforeEraser: null,
});
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso";
},
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];

View File

@@ -1,6 +1,9 @@
import { KEYS, updateActiveTool } from "@excalidraw/common";
import { getNonDeletedElements } from "@excalidraw/element";
import {
bindOrUnbindLinearElement,
getNonDeletedElements,
} from "@excalidraw/element";
import { fixBindingsAfterDeletion } from "@excalidraw/element";
import { LinearElementEditor } from "@excalidraw/element";
import { newElementWith } from "@excalidraw/element";
@@ -92,14 +95,14 @@ const deleteSelectedElements = (
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
if (bound && isElbowArrow(bound)) {
app.scene.mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId ? null : bound.endBinding,
});
if (el.id === bound.startBinding?.elementId) {
bindOrUnbindLinearElement(
bound,
el.id === bound.startBinding?.elementId ? null : "keep",
el.id === bound.endBinding?.elementId ? null : "keep",
app.scene,
);
}
}
});
}
@@ -298,7 +301,9 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
activeTool: updateActiveTool(appState, { type: "selection" }),
activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool,
}),
multiElement: null,
activeEmbeddable: null,
selectedLinearElement: null,

View File

@@ -5,7 +5,11 @@ import {
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import {
isBindingElement,
@@ -78,7 +82,14 @@ export const actionFinalize = register({
let newElements = elements;
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id);
newElements = newElements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
}
return {
elements: newElements,
@@ -117,7 +128,12 @@ export const actionFinalize = register({
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.filter((el) => el.id !== element.id)
? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
: undefined,
appState: {
...appState,
@@ -172,7 +188,12 @@ export const actionFinalize = register({
if (element && isInvisiblySmallElement(element)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter((el) => el.id !== element!.id);
newElements = newElements.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
}
if (isLinearElement(element) || isFreeDrawElement(element)) {
@@ -240,13 +261,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: "selection",
type: app.defaultSelectionTool,
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: "selection",
type: app.defaultSelectionTool,
});
}

View File

@@ -46,7 +46,7 @@ import {
hasStrokeWidth,
} from "../scene";
import { SHAPES } from "./shapes";
import { getToolbarTools } from "./shapes";
import "./Actions.scss";
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected = activeTool.type === "lasso";
const lassoToolSelected =
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
@@ -303,63 +304,68 @@ export const ShapesSwitcher = ({
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
] === false
) {
return null;
}
{getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false
) {
return null;
}
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
app.togglePenMode(true);
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
})}
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: "selection" });
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
},
)}
<div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}>
@@ -418,14 +424,16 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
{app.defaultSelectionTool !== "lasso" && (
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "lasso" })}
icon={LassoIcon}
data-testid="toolbar-lasso"
selected={lassoToolSelected}
>
{t("toolBar.lasso")}
</DropdownMenu.Item>
)}
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>

View File

@@ -100,6 +100,7 @@ import {
randomInteger,
CLASSES,
Emitter,
isMobile,
MINIMUM_ARROW_SIZE,
} from "@excalidraw/common";
@@ -233,6 +234,8 @@ import {
hitElementBoundingBox,
isLineElement,
isSimpleArrow,
StoreDelta,
type ApplyToOptions,
} from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -259,6 +262,7 @@ import type {
MagicGenerationData,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -650,9 +654,14 @@ class App extends React.Component<AppProps, AppState> {
>();
onRemoveEventListenersEmitter = new Emitter<[]>();
defaultSelectionTool: "selection" | "lasso" = "selection";
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
this.defaultSelectionTool = this.isMobileOrTablet()
? ("lasso" as const)
: ("selection" as const);
const {
excalidrawAPI,
viewModeEnabled = false,
@@ -697,6 +706,7 @@ class App extends React.Component<AppProps, AppState> {
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
@@ -1602,7 +1612,8 @@ class App extends React.Component<AppProps, AppState> {
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type === "selection" &&
this.state.activeTool.type ===
this.defaultSelectionTool &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
@@ -2342,7 +2353,11 @@ class App extends React.Component<AppProps, AppState> {
},
};
}
const scene = restore(initialData, null, null, { repairBindings: true });
const scene = restore(initialData, null, null, {
repairBindings: true,
deleteInvisibleElements: true,
});
const activeTool = scene.appState.activeTool;
scene.appState = {
...scene.appState,
theme: this.props.theme || scene.appState.theme,
@@ -2352,8 +2367,13 @@ class App extends React.Component<AppProps, AppState> {
// with a library install link, which should auto-open the library)
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool:
scene.appState.activeTool.type === "image"
? { ...scene.appState.activeTool, type: "selection" }
activeTool.type === "image" ||
activeTool.type === "lasso" ||
activeTool.type === "selection"
? {
...activeTool,
type: this.defaultSelectionTool,
}
: scene.appState.activeTool,
isLoading: false,
toast: this.state.toast,
@@ -2392,6 +2412,16 @@ class App extends React.Component<AppProps, AppState> {
}
};
private isMobileOrTablet = (): boolean => {
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
const hasCoarsePointer =
"matchMedia" in window &&
window?.matchMedia("(pointer: coarse)")?.matches;
const isTouchMobile = hasTouch && hasCoarsePointer;
return isMobile || isTouchMobile;
};
private isMobileBreakpoint = (width: number, height: number) => {
return (
width < MQ_MAX_WIDTH_PORTRAIT ||
@@ -3110,7 +3140,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files: data.files || null,
position: "cursor",
position: this.isMobileOrTablet() ? "center" : "cursor",
retainSeed: isPlainPaste,
});
} else if (data.text) {
@@ -3128,7 +3158,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files,
position: "cursor",
position: this.isMobileOrTablet() ? "center" : "cursor",
});
return;
@@ -3188,7 +3218,7 @@ class App extends React.Component<AppProps, AppState> {
}
this.addTextFromPaste(data.text, isPlainPaste);
}
this.setActiveTool({ type: "selection" });
this.setActiveTool({ type: this.defaultSelectionTool }, true);
event?.preventDefault();
},
);
@@ -3200,7 +3230,9 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean;
fitToContent?: boolean;
}) => {
const elements = restoreElements(opts.elements, null, undefined);
const elements = restoreElements(opts.elements, null, {
deleteInvisibleElements: true,
});
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2;
@@ -3332,7 +3364,7 @@ class App extends React.Component<AppProps, AppState> {
}
},
);
this.setActiveTool({ type: "selection" });
this.setActiveTool({ type: this.defaultSelectionTool }, true);
if (opts.fitToContent) {
this.scrollToContent(duplicatedElements, {
@@ -3578,7 +3610,7 @@ class App extends React.Component<AppProps, AppState> {
...updateActiveTool(
this.state,
prevState.activeTool.locked
? { type: "selection" }
? { type: this.defaultSelectionTool }
: prevState.activeTool,
),
locked: !prevState.activeTool.locked,
@@ -3933,6 +3965,27 @@ class App extends React.Component<AppProps, AppState> {
},
);
public applyDeltas = (
deltas: StoreDelta[],
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] => {
// squash all deltas together, starting with a fresh new delta instance
const aggregatedDelta = StoreDelta.squash(...deltas);
// create new instance of elements map & appState, so we don't accidentaly mutate existing ones
const nextAppState = { ...this.state };
const nextElements = new Map(
this.scene.getElementsMapIncludingDeleted(),
) as SceneElementsMap;
return StoreDelta.applyTo(
aggregatedDelta,
nextElements,
nextAppState,
options,
);
};
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
element: TElement,
updates: ElementUpdate<TElement>,
@@ -4470,7 +4523,7 @@ class App extends React.Component<AppProps, AppState> {
!this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged
) {
const shape = findShapeByKey(event.key);
const shape = findShapeByKey(event.key, this);
if (shape) {
if (this.state.activeTool.type !== shape) {
trackEvent(
@@ -4563,7 +4616,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: "selection" });
this.setActiveTool({ type: this.defaultSelectionTool });
} else {
this.setActiveTool({ type: "laser" });
}
@@ -4927,17 +4980,8 @@ class App extends React.Component<AppProps, AppState> {
}),
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
const isDeleted = !nextOriginalText.trim();
updateElement(nextOriginalText, isDeleted);
if (isDeleted && !isExistingElement) {
// let's just remove the element from the scene, as it's an empty just created text element
this.scene.replaceAllElements(
this.scene
.getElementsIncludingDeleted()
.filter((x) => x.id !== element.id),
);
} else {
updateElement(nextOriginalText, isDeleted);
}
// select the created text element only if submitting via keyboard
// (when submitting via click it should act as signal to deselect)
if (!isDeleted && viaKeyboard) {
@@ -4961,15 +5005,16 @@ class App extends React.Component<AppProps, AppState> {
}));
});
}
if (isDeleted) {
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
element,
]);
}
// we need to record either way, whether the text element was added or removed
// since we need to sync this delta to other clients, otherwise it would end up with inconsistencies
this.store.scheduleCapture();
if (!isDeleted || isExistingElement) {
this.store.scheduleCapture();
}
flushSync(() => {
this.setState({
@@ -5416,7 +5461,7 @@ class App extends React.Component<AppProps, AppState> {
return;
}
// we should only be able to double click when mode is selection
if (this.state.activeTool.type !== "selection") {
if (this.state.activeTool.type !== this.defaultSelectionTool) {
return;
}
@@ -5708,8 +5753,9 @@ class App extends React.Component<AppProps, AppState> {
const elementsMap = this.scene.getNonDeletedElementsMap();
const frames = this.scene
.getNonDeletedFramesLikes()
.filter((frame): frame is ExcalidrawFrameLikeElement =>
isCursorInFrame(sceneCoords, frame, elementsMap),
.filter(
(frame): frame is ExcalidrawFrameLikeElement =>
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
);
return frames.length ? frames[frames.length - 1] : null;
@@ -6027,6 +6073,7 @@ class App extends React.Component<AppProps, AppState> {
if (
hasDeselectedButton ||
(this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser")
) {
@@ -6189,7 +6236,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
this.state.activeTool.type !== "lasso" ||
selectedElements.length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
@@ -6306,7 +6358,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
}
}
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
@@ -6315,7 +6372,12 @@ class App extends React.Component<AppProps, AppState> {
!isElbowArrow(element) ||
!(element.startBinding || element.endBinding)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
if (
this.state.activeTool.type !== "lasso" ||
Object.keys(this.state.selectedElementIds).length > 0
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
}
}
}
@@ -6577,11 +6639,119 @@ class App extends React.Component<AppProps, AppState> {
}
if (this.state.activeTool.type === "lasso") {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
const hitSelectedElement =
pointerDownState.hit.element &&
this.isASelectedElement(pointerDownState.hit.element);
const isMobileOrTablet = this.isMobileOrTablet();
if (
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
!pointerDownState.resize.handleType &&
!hitSelectedElement
) {
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
// block dragging after lasso selection on PCs until the next pointer down
// (on mobile or tablet, we want to allow user to drag immediately)
pointerDownState.drag.blockDragging = !isMobileOrTablet;
}
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
if (
isMobileOrTablet &&
pointerDownState.hit.element &&
!hitSelectedElement
) {
this.setState((prevState) => {
const nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[pointerDownState.hit.element!.id]: true,
};
const previouslySelectedElements: ExcalidrawElement[] = [];
Object.keys(prevState.selectedElementIds).forEach((id) => {
const element = this.scene.getElement(id);
element && previouslySelectedElements.push(element);
});
const hitElement = pointerDownState.hit.element!;
// if hitElement is frame-like, deselect all of its elements
// if they are selected
if (isFrameLikeElement(hitElement)) {
getFrameChildren(previouslySelectedElements, hitElement.id).forEach(
(element) => {
delete nextSelectedElementIds[element.id];
},
);
} else if (hitElement.frameId) {
// if hitElement is in a frame and its frame has been selected
// disable selection for the given element
if (nextSelectedElementIds[hitElement.frameId]) {
delete nextSelectedElementIds[hitElement.id];
}
} else {
// hitElement is neither a frame nor an element in a frame
// but since hitElement could be in a group with some frames
// this means selecting hitElement will have the frames selected as well
// because we want to keep the invariant:
// - frames and their elements are not selected at the same time
// we deselect elements in those frames that were previously selected
const groupIds = hitElement.groupIds;
const framesInGroups = new Set(
groupIds
.flatMap((gid) =>
getElementsInGroup(this.scene.getNonDeletedElements(), gid),
)
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id),
);
if (framesInGroups.size > 0) {
previouslySelectedElements.forEach((element) => {
if (element.frameId && framesInGroups.has(element.frameId)) {
// deselect element and groups containing the element
delete nextSelectedElementIds[element.id];
element.groupIds
.flatMap((gid) =>
getElementsInGroup(
this.scene.getNonDeletedElements(),
gid,
),
)
.forEach((element) => {
delete nextSelectedElementIds[element.id];
});
}
});
}
}
return {
...selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: nextSelectedElementIds,
},
this.scene.getNonDeletedElements(),
prevState,
this,
),
showHyperlinkPopup:
hitElement.link || isEmbeddableElement(hitElement)
? "info"
: false,
};
});
pointerDownState.hit.wasAddedToSelection = true;
}
} else if (this.state.activeTool.type === "text") {
this.handleTextOnPointerDown(event, pointerDownState);
} else if (
@@ -6961,6 +7131,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false,
offset: null,
origin: { ...origin },
blockDragging: false,
},
eventListeners: {
onMove: null,
@@ -7036,7 +7207,10 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>,
pointerDownState: PointerDownState,
): boolean => {
if (this.state.activeTool.type === "selection") {
if (
this.state.activeTool.type === "selection" ||
this.state.activeTool.type === "lasso"
) {
const elements = this.scene.getNonDeletedElements();
const elementsMap = this.scene.getNonDeletedElementsMap();
const selectedElements = this.scene.getSelectedElements(this.state);
@@ -7243,7 +7417,18 @@ class App extends React.Component<AppProps, AppState> {
// on CMD/CTRL, drill down to hit element regardless of groups etc.
if (event[KEYS.CTRL_OR_CMD]) {
if (event.altKey) {
// ctrl + alt means we're lasso selecting
// ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
// Close any open dialogs that might interfere with lasso selection
if (this.state.openDialog?.name === "elementLinkSelector") {
this.setOpenDialog(null);
}
this.lassoTrail.startPath(
pointerDownState.origin.x,
pointerDownState.origin.y,
event.shiftKey,
);
this.setActiveTool({ type: "lasso", fromSelection: true });
return false;
}
if (!this.state.selectedElementIds[hitElement.id]) {
@@ -7464,7 +7649,9 @@ class App extends React.Component<AppProps, AppState> {
resetCursor(this.interactiveCanvas);
if (!this.state.activeTool.locked) {
this.setState({
activeTool: updateActiveTool(this.state, { type: "selection" }),
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
});
}
};
@@ -8248,15 +8435,18 @@ class App extends React.Component<AppProps, AppState> {
event.shiftKey &&
this.state.selectedLinearElement.elementId ===
pointerDownState.hit.element?.id;
if (
(hasHitASelectedElement ||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
!isSelectingPointsInLineEditor &&
this.state.activeTool.type !== "lasso"
!pointerDownState.drag.blockDragging
) {
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.every((element) => element.locked)) {
if (
selectedElements.length > 0 &&
selectedElements.every((element) => element.locked)
) {
return;
}
@@ -8277,6 +8467,29 @@ class App extends React.Component<AppProps, AppState> {
// if elements should be deselected on pointerup
pointerDownState.drag.hasOccurred = true;
// prevent immediate dragging during lasso selection to avoid element displacement
// only allow dragging if we're not in the middle of lasso selection
// (on mobile, allow dragging if we hit an element)
if (
this.state.activeTool.type === "lasso" &&
this.lassoTrail.hasCurrentTrail &&
!(this.isMobileOrTablet() && pointerDownState.hit.element) &&
!this.state.activeTool.fromSelection
) {
return;
}
// Clear lasso trail when starting to drag selected elements with lasso tool
// Only clear if we're actually dragging (not during lasso selection)
if (
this.state.activeTool.type === "lasso" &&
selectedElements.length > 0 &&
pointerDownState.drag.hasOccurred &&
!this.state.activeTool.fromSelection
) {
this.lassoTrail.endPath();
}
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
// it would have weird results (stuff jumping all over the screen)
// Checking for editingTextElement to avoid jump while editing on mobile #6503
@@ -8871,6 +9084,7 @@ class App extends React.Component<AppProps, AppState> {
): (event: PointerEvent) => void {
return withBatchedUpdates((childEvent: PointerEvent) => {
this.removePointer(childEvent);
pointerDownState.drag.blockDragging = false;
if (pointerDownState.eventListeners.onMove) {
pointerDownState.eventListeners.onMove.flush();
}
@@ -9159,7 +9373,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState((prevState) => ({
newElement: null,
activeTool: updateActiveTool(this.state, {
type: "selection",
type: this.defaultSelectionTool,
}),
selectedElementIds: makeNextSelectedElementIds(
{
@@ -9775,7 +9989,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState({
newElement: null,
suggestedBindings: [],
activeTool: updateActiveTool(this.state, { type: "selection" }),
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
});
} else {
this.setState({
@@ -10069,7 +10285,9 @@ class App extends React.Component<AppProps, AppState> {
this.setState(
{
newElement: null,
activeTool: updateActiveTool(this.state, { type: "selection" }),
activeTool: updateActiveTool(this.state, {
type: this.defaultSelectionTool,
}),
},
() => {
this.actionManager.executeAction(actionFinalize);
@@ -10121,7 +10339,7 @@ class App extends React.Component<AppProps, AppState> {
if (erroredFiles.size) {
this.store.scheduleAction(CaptureUpdateAction.NEVER);
this.scene.replaceAllElements(
elements.map((element) => {
this.scene.getElementsIncludingDeleted().map((element) => {
if (
isInitializedImageElement(element) &&
erroredFiles.has(element.fileId)
@@ -10442,7 +10660,7 @@ class App extends React.Component<AppProps, AppState> {
event.nativeEvent.pointerType === "pen" &&
// always allow if user uses a pen secondary button
event.button !== POINTER_BUTTON.SECONDARY)) &&
this.state.activeTool.type !== "selection"
this.state.activeTool.type !== this.defaultSelectionTool
) {
return;
}

View File

@@ -13,6 +13,8 @@ import {
EraserIcon,
} from "./icons";
import type { AppClassProperties } from "../types";
export const SHAPES = [
{
icon: SelectionIcon,
@@ -86,8 +88,23 @@ export const SHAPES = [
},
] as const;
export const findShapeByKey = (key: string) => {
const shape = SHAPES.find((shape, index) => {
export const getToolbarTools = (app: AppClassProperties) => {
return app.defaultSelectionTool === "lasso"
? ([
{
value: "lasso",
icon: SelectionIcon,
key: KEYS.V,
numericKey: KEYS["1"],
fillable: true,
},
...SHAPES.slice(1),
] as const)
: SHAPES;
};
export const findShapeByKey = (key: string, app: AppClassProperties) => {
const shape = getToolbarTools(app).find((shape, index) => {
return (
(shape.numericKey != null && key === shape.numericKey.toString()) ||
(shape.key &&

View File

@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
},
localAppState,
localElements,
{ repairBindings: true, refreshDimensions: false },
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
),
};
} else if (isValidLibrary(data)) {

View File

@@ -4,13 +4,7 @@ import {
supported as nativeFileSystemSupported,
} from "browser-fs-access";
import {
EVENT,
MIME_TYPES,
debounce,
isIOS,
isAndroid,
} from "@excalidraw/common";
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
import { AbortError } from "../errors";
@@ -19,8 +13,6 @@ import type { FileSystemHandle } from "browser-fs-access";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
const INPUT_CHANGE_INTERVAL_MS = 500;
// increase timeout for mobile devices to give more time for file selection
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
export const fileOpen = <M extends boolean | undefined = false>(opts: {
extensions?: FILE_EXTENSION[];
@@ -49,22 +41,13 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
mimeTypes,
multiple: opts.multiple ?? false,
legacySetup: (resolve, reject, input) => {
const isMobile = isIOS || isAndroid;
const intervalMs = isMobile
? MOBILE_INPUT_CHANGE_INTERVAL_MS
: INPUT_CHANGE_INTERVAL_MS;
const scheduleRejection = debounce(reject, intervalMs);
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
const focusHandler = () => {
checkForFile();
// on mobile, be less aggressive with rejection
if (!isMobile) {
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
}
document.addEventListener(EVENT.KEYUP, scheduleRejection);
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
scheduleRejection();
};
const checkForFile = () => {
// this hack might not work when expecting multiple files
if (input.files?.length) {
@@ -72,15 +55,12 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
resolve(ret as RetType);
}
};
requestAnimationFrame(() => {
window.addEventListener(EVENT.FOCUS, focusHandler);
});
const interval = window.setInterval(() => {
checkForFile();
}, intervalMs);
}, INPUT_CHANGE_INTERVAL_MS);
return (rejectPromise) => {
clearInterval(interval);
scheduleRejection.cancel();
@@ -89,9 +69,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
if (rejectPromise) {
// so that something is shown in console if we need to debug this
console.warn(
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
);
console.warn("Opening the file was canceled (legacy-fs).");
rejectPromise(new AbortError());
}
};

View File

@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"RemoteExcalidrawElement">;
const shouldDiscardRemoteElement = (
export const shouldDiscardRemoteElement = (
localAppState: AppState,
local: OrderedExcalidrawElement | undefined,
remote: RemoteExcalidrawElement,
@@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited
(local.id === localAppState.editingTextElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
local.id === localAppState.newElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with

View File

@@ -241,8 +241,9 @@ const restoreElementWithProperties = <
return ret;
};
const restoreElement = (
export const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
opts?: { deleteInvisibleElements?: boolean },
): typeof element | null => {
element = { ...element };
@@ -290,7 +291,8 @@ const restoreElement = (
// if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.)
if (!text && !element.isDeleted) {
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
element = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element);
}
@@ -523,7 +525,13 @@ export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
opts?:
| {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
}
| undefined,
): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
@@ -532,24 +540,38 @@ export const restoreElements = (
(elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(
migratedElement,
localElement.version,
);
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
if (element.type === "selection") {
return elements;
}
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
deleteInvisibleElements: opts?.deleteInvisibleElements,
});
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
const shouldMarkAsDeleted =
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
if (
shouldMarkAsDeleted ||
(localElement && localElement.version > migratedElement.version)
) {
migratedElement = bumpVersion(migratedElement, localElement?.version);
}
if (shouldMarkAsDeleted) {
migratedElement = { ...migratedElement, isDeleted: true };
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
return elements;
}, [] as ExcalidrawElement[]),
);
@@ -790,7 +812,11 @@ export const restore = (
*/
localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
elementsConfig?: {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
},
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements, elementsConfig),

View File

@@ -175,7 +175,7 @@ export class History {
let nextAppState = appState;
let containsVisibleChange = false;
// iterate through the history entries in case ;they result in no visible changes
// iterate through the history entries in case they result in no visible changes
while (historyDelta) {
try {
[nextElements, nextAppState, containsVisibleChange] =

View File

@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
export {
restore,
restoreAppState,
restoreElement,
restoreElements,
restoreLibraryItems,
} from "./data/restore";

View File

@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
selectedElements: NonDeletedExcalidrawElement[];
}) => {
if (event) {
// Allow snapping for lasso tool when dragging selected elements
// but not during lasso selection phase
const isLassoDragging =
app.state.activeTool.type === "lasso" &&
app.state.selectedElementsAreBeingDragged;
return (
app.state.activeTool.type !== "lasso" &&
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] &&

View File

@@ -3682,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1116226695,
"seed": 400692809,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 23633383,
"versionNonce": 81784553,
"width": 20,
"x": 20,
"y": 30,
@@ -3714,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 1278240551,
"seed": 449462985,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 401146281,
"versionNonce": 1150084233,
"width": 20,
"x": -10,
"y": 0,

File diff suppressed because it is too large Load Diff

View File

@@ -2216,16 +2216,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] undo
},
},
},
"updated": {
"id0": {
"deleted": {
"version": 5,
},
"inserted": {
"version": 3,
},
},
},
"updated": {},
},
"id": "id6",
},
@@ -6394,15 +6385,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": {
"id9": true,
},
"selectedLinearElementId": "id9",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id9",
"isEditing": false,
},
},
"inserted": {
"selectedElementIds": {
"id6": true,
},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -6470,13 +6462,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": {
"id12": true,
},
"selectedLinearElementId": "id12",
"selectedLinearElement": {
"elementId": "id12",
"isEditing": false,
},
},
"inserted": {
"selectedElementIds": {
"id9": true,
},
"selectedLinearElementId": "id9",
"selectedLinearElement": {
"elementId": "id9",
"isEditing": false,
},
},
},
},
@@ -6542,15 +6540,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": {
"id15": true,
},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
"inserted": {
"selectedElementIds": {
"id12": true,
},
"selectedLinearElementId": "id12",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id12",
"isEditing": false,
},
},
},
},
@@ -6677,12 +6676,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"selectedLinearElementId": "id15",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id15",
"isEditing": false,
},
},
"inserted": {
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -6700,15 +6700,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"selectedElementIds": {
"id22": true,
},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
"inserted": {
"selectedElementIds": {
"id15": true,
},
"selectedLinearElementId": "id15",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id15",
"isEditing": false,
},
},
},
},
@@ -6833,12 +6834,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"selectedLinearElementId": "id22",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id22",
"isEditing": false,
},
},
"inserted": {
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -6873,12 +6875,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
"inserted": {
"selectedLinearElementId": "id22",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id22",
"isEditing": false,
},
},
},
},
@@ -8719,13 +8722,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
"selectedElementIds": {
"id0": true,
},
"selectedLinearElementId": "id0",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
},
"inserted": {
"selectedElementIds": {},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -8946,13 +8950,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
"selectedElementIds": {
"id0": true,
},
"selectedLinearElementId": "id0",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
},
"inserted": {
"selectedElementIds": {},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -9365,13 +9370,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
"selectedElementIds": {
"id0": true,
},
"selectedLinearElementId": "id0",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
},
"inserted": {
"selectedElementIds": {},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -9770,13 +9776,14 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
"selectedElementIds": {
"id0": true,
},
"selectedLinearElementId": "id0",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id0",
"isEditing": false,
},
},
"inserted": {
"selectedElementIds": {},
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
},
},
@@ -10885,32 +10892,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] undo s
},
},
},
"updated": {
"id0": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id3": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
"id6": {
"deleted": {
"version": 6,
},
"inserted": {
"version": 4,
},
},
},
"updated": {},
},
"id": "id21",
},
@@ -14494,12 +14476,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
"appState": AppStateDelta {
"delta": Delta {
"deleted": {
"selectedLinearElementId": null,
"selectedLinearElementIsEditing": null,
"selectedLinearElement": null,
},
"inserted": {
"selectedLinearElementId": "id6",
"selectedLinearElementIsEditing": false,
"selectedLinearElement": {
"elementId": "id6",
"isEditing": false,
},
},
},
},

View File

@@ -60,7 +60,11 @@ describe("restoreElements", () => {
const rectElement = API.createElement({ type: "rectangle" });
mockSizeHelper.mockImplementation(() => true);
expect(restore.restoreElements([rectElement], null).length).toBe(0);
expect(
restore.restoreElements([rectElement], null, {
deleteInvisibleElements: true,
}),
).toEqual([expect.objectContaining({ isDeleted: true })]);
});
it("should restore text element correctly passing value for each attribute", () => {
@@ -85,6 +89,23 @@ describe("restoreElements", () => {
});
});
it("should not delete empty text element when opts.deleteInvisibleElements is not defined", () => {
const textElement = API.createElement({
type: "text",
text: "",
isDeleted: false,
});
const restoredElements = restore.restoreElements([textElement], null);
expect(restoredElements).toEqual([
expect.objectContaining({
id: textElement.id,
isDeleted: false,
}),
]);
});
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
const textElement: any = API.createElement({
type: "text",
@@ -97,10 +118,9 @@ describe("restoreElements", () => {
textElement.font = "10 unknown";
expect(textElement.isDeleted).toBe(false);
const restoredText = restore.restoreElements(
[textElement],
null,
)[0] as ExcalidrawTextElement;
const restoredText = restore.restoreElements([textElement], null, {
deleteInvisibleElements: true,
})[0] as ExcalidrawTextElement;
expect(restoredText.isDeleted).toBe(true);
expect(restoredText).toMatchSnapshot({
seed: expect.any(Number),
@@ -177,13 +197,16 @@ describe("restoreElements", () => {
y: 0,
});
const restoredElements = restore.restoreElements([arrowElement], null);
const restoredElements = restore.restoreElements([arrowElement], null, {
deleteInvisibleElements: true,
});
const restoredArrow = restoredElements[0] as
| ExcalidrawArrowElement
| undefined;
expect(restoredArrow).toBeUndefined();
expect(restoredArrow).not.toBeUndefined();
expect(restoredArrow?.isDeleted).toBe(true);
});
it("should keep 'imperceptibly' small freedraw/line elements", () => {
@@ -848,6 +871,7 @@ describe("repairing bindings", () => {
let restoredElements = restore.restoreElements(
[container, invisibleBoundElement, boundElement],
null,
{ deleteInvisibleElements: true },
);
expect(restoredElements).toEqual([
@@ -855,6 +879,11 @@ describe("repairing bindings", () => {
id: container.id,
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
}),
expect.objectContaining({
id: invisibleBoundElement.id,
containerId: container.id,
isDeleted: true,
}),
expect.objectContaining({
id: boundElement.id,
containerId: container.id,
@@ -864,7 +893,7 @@ describe("repairing bindings", () => {
restoredElements = restore.restoreElements(
[container, invisibleBoundElement, boundElement],
null,
{ repairBindings: true },
{ repairBindings: true, deleteInvisibleElements: true },
);
expect(restoredElements).toEqual([
@@ -872,6 +901,11 @@ describe("repairing bindings", () => {
id: container.id,
boundElements: [],
}),
expect.objectContaining({
id: invisibleBoundElement.id,
containerId: container.id,
isDeleted: true,
}),
expect.objectContaining({
id: boundElement.id,
containerId: container.id,

View File

@@ -315,7 +315,12 @@ describe("Test dragCreate", () => {
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "arrow",
isDeleted: true,
}),
]);
});
it("line", async () => {
@@ -344,7 +349,12 @@ describe("Test dragCreate", () => {
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
expect(h.state.selectionElement).toBeNull();
expect(h.elements.length).toEqual(0);
expect(h.elements).toEqual([
expect.objectContaining({
type: "line",
isDeleted: true,
}),
]);
});
});
});

View File

@@ -3043,15 +3043,14 @@ describe("history", () => {
});
Keyboard.undo();
expect(API.getUndoStack().length).toBe(3);
expect(API.getRedoStack().length).toBe(1);
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(API.getUndoStack().length).toBe(0);
expect(API.getRedoStack().length).toBe(4);
expect(h.state.selectedLinearElement).toBeNull();
Keyboard.redo();
expect(API.getUndoStack().length).toBe(4);
expect(API.getRedoStack().length).toBe(0);
expect(h.state.selectedLinearElement).not.toBeNull();
expect(h.state.selectedLinearElement).toBeNull();
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
});
@@ -4056,7 +4055,7 @@ describe("history", () => {
expect.objectContaining({
id: container.id,
boundElements: [{ id: remoteText.id, type: "text" }],
isDeleted: false, // isDeleted got remotely updated to false
isDeleted: true,
}),
expect.objectContaining({
id: text.id,
@@ -4066,7 +4065,7 @@ describe("history", () => {
expect.objectContaining({
id: remoteText.id,
// unbound
containerId: container.id,
containerId: null,
isDeleted: false,
}),
]);

View File

@@ -248,8 +248,10 @@ export type ObservedElementsAppState = {
editingGroupId: AppState["editingGroupId"];
selectedElementIds: AppState["selectedElementIds"];
selectedGroupIds: AppState["selectedGroupIds"];
selectedLinearElementId: LinearElementEditor["elementId"] | null;
selectedLinearElementIsEditing: boolean | null;
selectedLinearElement: {
elementId: LinearElementEditor["elementId"];
isEditing: boolean;
} | null;
croppingElementId: AppState["croppingElementId"];
lockedMultiSelections: AppState["lockedMultiSelections"];
activeLockedId: AppState["activeLockedId"];
@@ -729,6 +731,8 @@ export type AppClassProperties = {
onPointerUpEmitter: App["onPointerUpEmitter"];
updateEditorAtom: App["updateEditorAtom"];
defaultSelectionTool: "selection" | "lasso";
};
export type PointerDownState = Readonly<{
@@ -778,6 +782,10 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication.
origin: { x: number; y: number };
// Whether to block drag after lasso selection
// this is meant to be used to block dragging after lasso selection on PCs
// until the next pointer down
blockDragging: boolean;
};
// We need to have these in the state so that we can unsubscribe them
eventListeners: {
@@ -799,6 +807,7 @@ export type UnsubscribeCallback = () => void;
export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"];
applyDeltas: InstanceType<typeof App>["applyDeltas"];
mutateElement: InstanceType<typeof App>["mutateElement"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
resetScene: InstanceType<typeof App>["resetScene"];

View File

@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
rectangle.x + rectangle.width / 2,
rectangle.y + rectangle.height / 2,
);
expect(h.elements.length).toBe(2);
expect(h.elements.length).toBe(3);
text = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(text.type).toBe("text");
@@ -1198,7 +1198,11 @@ describe("textWysiwyg", () => {
updateTextEditor(editor, " ");
Keyboard.exitTextEditor(editor);
expect(rectangle.boundElements).toStrictEqual([]);
expect(h.elements[1]).toBeUndefined();
expect(h.elements[1]).toEqual(
expect.objectContaining({
isDeleted: true,
}),
);
});
it("should restore original container height and clear cache once text is unbind", async () => {

View File

@@ -49,6 +49,7 @@ export const exportToCanvas = ({
{ elements, appState },
null,
null,
{ deleteInvisibleElements: true },
);
const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas(
@@ -179,6 +180,7 @@ export const exportToSvg = async ({
{ elements, appState },
null,
null,
{ deleteInvisibleElements: true },
);
const exportAppState = {

View File

@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
});
rl.question(
"Do you want to commit these changes to git? (Y/n): ",
"Would you like to commit these changes to git? (Y/n): ",
(answer) => {
rl.close();
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
});
rl.question(
"Do you want to publish these changes to npm? (Y/n): ",
"Would you like to publish these changes to npm? (Y/n): ",
(answer) => {
rl.close();

112
yarn.lock
View File

@@ -40,6 +40,15 @@
js-tokens "^4.0.0"
picocolors "^1.0.0"
"@babel/code-frame@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be"
integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==
dependencies:
"@babel/helper-validator-identifier" "^7.27.1"
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.5", "@babel/compat-data@^7.26.8":
version "7.26.8"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367"
@@ -86,6 +95,17 @@
"@jridgewell/trace-mapping" "^0.3.25"
jsesc "^3.0.2"
"@babel/generator@^7.28.3":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e"
integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==
dependencies:
"@babel/parser" "^7.28.3"
"@babel/types" "^7.28.2"
"@jridgewell/gen-mapping" "^0.3.12"
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4"
@@ -137,6 +157,11 @@
lodash.debounce "^4.0.8"
resolve "^1.14.2"
"@babel/helper-globals@^7.28.0":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
"@babel/helper-member-expression-to-functions@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz#9dfffe46f727005a5ea29051ac835fb735e4c1a3"
@@ -174,6 +199,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35"
integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==
"@babel/helper-plugin-utils@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c"
integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
"@babel/helper-remap-async-to-generator@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz#e53956ab3d5b9fb88be04b3e2f31b523afd34b92"
@@ -205,11 +235,21 @@
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c"
integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7"
integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==
"@babel/helper-validator-identifier@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
"@babel/helper-validator-option@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72"
@@ -249,6 +289,13 @@
dependencies:
"@babel/types" "^7.26.9"
"@babel/parser@^7.27.2", "@babel/parser@^7.28.3":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.3.tgz#d2d25b814621bca5fe9d172bc93792547e7a2a71"
integrity sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==
dependencies:
"@babel/types" "^7.28.2"
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz#cc2e53ebf0a0340777fff5ed521943e253b4d8fe"
@@ -513,6 +560,14 @@
dependencies:
"@babel/helper-plugin-utils" "^7.25.9"
"@babel/plugin-transform-destructuring@^7.28.0":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz#0f156588f69c596089b7d5b06f5af83d9aa7f97a"
integrity sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
"@babel/traverse" "^7.28.0"
"@babel/plugin-transform-dotall-regex@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz#bad7945dd07734ca52fe3ad4e872b40ed09bb09a"
@@ -543,6 +598,14 @@
dependencies:
"@babel/helper-plugin-utils" "^7.25.9"
"@babel/plugin-transform-explicit-resource-management@7.28.0":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz#45be6211b778dbf4b9d54c4e8a2b42fa72e09a1a"
integrity sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
"@babel/plugin-transform-destructuring" "^7.28.0"
"@babel/plugin-transform-exponentiation-operator@^7.26.3":
version "7.26.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz#e29f01b6de302c7c2c794277a48f04a9ca7f03bc"
@@ -1019,6 +1082,15 @@
"@babel/parser" "^7.26.9"
"@babel/types" "^7.26.9"
"@babel/template@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==
dependencies:
"@babel/code-frame" "^7.27.1"
"@babel/parser" "^7.27.2"
"@babel/types" "^7.27.1"
"@babel/traverse@^7.25.9", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.8", "@babel/traverse@^7.26.9":
version "7.26.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a"
@@ -1032,6 +1104,19 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/traverse@^7.28.0":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.3.tgz#6911a10795d2cce43ec6a28cffc440cca2593434"
integrity sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==
dependencies:
"@babel/code-frame" "^7.27.1"
"@babel/generator" "^7.28.3"
"@babel/helper-globals" "^7.28.0"
"@babel/parser" "^7.28.3"
"@babel/template" "^7.27.2"
"@babel/types" "^7.28.2"
debug "^4.3.1"
"@babel/types@^7.21.3", "@babel/types@^7.25.4", "@babel/types@^7.25.9", "@babel/types@^7.26.9", "@babel/types@^7.4.4":
version "7.26.9"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.9.tgz#08b43dec79ee8e682c2ac631c010bdcac54a21ce"
@@ -1040,6 +1125,14 @@
"@babel/helper-string-parser" "^7.25.9"
"@babel/helper-validator-identifier" "^7.25.9"
"@babel/types@^7.27.1", "@babel/types@^7.28.2":
version "7.28.2"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b"
integrity sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@bcoe/v8-coverage@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa"
@@ -1950,6 +2043,14 @@
dependencies:
"@sinclair/typebox" "^0.27.8"
"@jridgewell/gen-mapping@^0.3.12":
version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/gen-mapping@^0.3.5":
version "0.3.8"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142"
@@ -1990,6 +2091,14 @@
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@jridgewell/trace-mapping@^0.3.28":
version "0.3.30"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz#4a76c4daeee5df09f5d3940e087442fb36ce2b99"
integrity sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@next/env@14.1.4":
version "14.1.4"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.1.4.tgz#432e80651733fbd67230bf262aee28be65252674"
@@ -5265,6 +5374,9 @@ eslint-module-utils@^2.12.0:
dependencies:
debug "^3.2.7"
"eslint-plugin-eslint@file:packages/eslint":
version "0.1.0"
eslint-plugin-flowtype@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz#e1557e37118f24734aa3122e7536a038d34a4912"