Compare commits

...

49 Commits

Author SHA1 Message Date
dwelle 06dae6edf2 add stats menu to preferences 2025-09-03 13:10:30 +02:00
dwelle ecbaeb1701 add Preferences default menu item 2025-08-31 23:30:26 +02:00
dwelle 40c5c743b1 Merge branch 'master' into barnabasmolnar/mainmenu-radix
# Conflicts:
#	package.json
#	packages/excalidraw/.size-limit.json
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSubItem.tsx
#	packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx
#	packages/excalidraw/components/main-menu/MainMenu.tsx
#	packages/excalidraw/tests/excalidraw.test.tsx
#	packages/excalidraw/tests/library.test.tsx
#	src/components/Actions.tsx
#	src/components/dropdownMenu/DropdownMenu.scss
#	src/components/dropdownMenu/DropdownMenuItem.tsx
#	yarn.lock
2025-08-31 20:18:40 +02:00
Christopher Tangonan ae89608985 fix: bound text rotation across alignments (#9914)
Co-authored-by: A-Mundanilkunathil <aaronchackom2002@gmail.com>
2025-08-29 12:31:23 +02:00
Ryan Di 3085f4af81 fix: tighten distance for double tap text creation (#9889) 2025-08-22 18:12:51 +02:00
David Luzar 531f3e5524 fix: restore from invalid fixedSegments & type-safer point updates (#9899)
* fix: restore from invalid fixedSegments & type-safer point updates

* fix: Type updates and throw for invalid point states

---------

Co-authored-by: Mark Tolmacs <mark@lazycat.hu>
2025-08-22 15:45:58 +00:00
David Luzar 90ec2739ae fix: calling toLowerCase on potentially undefined navigator.* values (#9901) 2025-08-22 17:37:16 +02:00
David Luzar f29e9df72d chore: bump mermaid-to-excalidraw to 1.1.3 (#9898) 2025-08-21 20:58:04 +02:00
Marcel Mraz b5ad7ae4e3 fix: even deltas with version & version nonce are valid (#9897) 2025-08-21 16:09:19 +02:00
David Luzar c78e4aab7f chore: tweak title & remove timeout (#9883) 2025-08-20 14:09:20 +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
barnabasmolnar 5082142b36 Potentially improve main menu positioning. 2023-08-07 14:55:50 +02:00
barnabasmolnar 74cb027fd7 Some slight styling tweaks per design spec. 2023-07-28 13:22:43 +02:00
Aakansha Doshi bc09ac757f increase the limit for bundle 2023-07-27 13:59:19 +05:30
Aakansha Doshi 66e347f7d2 Merge remote-tracking branch 'origin/master' into barnabasmolnar/mainmenu-radix 2023-07-27 12:17:28 +05:30
barnabasmolnar d5974e66b2 button => Button, cleanup 2023-07-26 13:01:41 +02:00
barnabasmolnar 2a1b22a504 update test snapshot 2023-07-25 15:52:30 +02:00
barnabasmolnar b3d241ba7f button => Button in dropdown 2023-07-25 15:48:37 +02:00
barnabasmolnar 8ff1ac8097 Tweak dropdown alignment. 2023-07-25 13:53:00 +02:00
Aakansha Doshi d967123383 Merge remote-tracking branch 'origin/master' into barnabasmolnar/mainmenu-radix 2023-07-25 09:07:22 +05:30
barnabasmolnar 05cd1a79cc Fix test snapshot. 2023-07-24 16:45:55 +02:00
barnabasmolnar bd08bdf4c7 Fix some issues caused by too aggressive refactor 2023-07-24 16:35:56 +02:00
barnabasmolnar 011b268dde Merge master + post merge fixes. 2023-07-24 14:16:14 +02:00
barnabasmolnar b6a7f05761 Attempt to fix tests. 2023-07-24 14:04:37 +02:00
Aakansha Doshi 8787c7d8cf increase size limit for excalidraw.production.min.js 2023-07-24 17:06:39 +05:30
barnabasmolnar 6d21d7cab1 Some post merge fixes. 2023-07-21 16:40:42 +02:00
barnabasmolnar c9df3e143b Merge branch 'master' into barnabasmolnar/mainmenu-radix 2023-07-21 15:24:54 +02:00
barnabasmolnar 5b11660cc0 Removed MainMenu.Sub docs. 2023-07-21 14:56:12 +02:00
barnabasmolnar bf0b2965e6 Naming, removing unused code. 2023-07-21 14:30:18 +02:00
barnabasmolnar 8f8b6e7144 Add backdrop under mainmenu on mobile. 2023-04-26 14:28:22 +02:00
barnabasmolnar b63d17045e Revert "Make main menu full width on mobile again."
This reverts commit 70d48d5472.
2023-04-26 12:52:35 +02:00
barnabasmolnar 70d48d5472 Make main menu full width on mobile again. 2023-04-21 15:58:04 +02:00
barnabasmolnar 097000a2b7 Update docs with submenu. 2023-04-18 23:07:21 +02:00
barnabasmolnar 461661afc6 Styling. 2023-04-18 17:11:36 +02:00
barnabasmolnar c88f3c84eb Bunch of radix related fixes. 2023-04-18 16:39:23 +02:00
barnabasmolnar 7d791b86f8 Attempt to fix submenu not disappearing properly. 2023-04-18 14:57:25 +02:00
barnabasmolnar e615056302 Quick fix for mobile. 2023-04-18 01:24:31 +02:00
barnabasmolnar 14ad745d00 Initial attempt @ submenu implementation. 2023-04-18 01:04:02 +02:00
barnabasmolnar 9c3ff73a73 Styling fixes, naming. 2023-04-17 17:31:50 +02:00
barnabasmolnar 79cf71cccb Small refactor. Fixes library dropdown. 2023-04-17 17:12:42 +02:00
barnabasmolnar e094b8b539 Initial attempt @ using radixdropdown for mainmenu 2023-04-17 16:59:05 +02:00
67 changed files with 7028 additions and 2729 deletions
@@ -615,6 +615,52 @@ export default function ExampleApp({
const renderMenu = () => {
return (
<MainMenu>
<MainMenu.Sub>
<MainMenu.Sub.Trigger
title="Custom trigger"
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5"
/>
</svg>
}
>
Submenu trigger
</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
/>
</svg>
}
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export />
<MainMenu.Separator />
@@ -622,10 +668,57 @@ export default function ExampleApp({
isCollaborating={isCollaborating}
onSelect={() => window.alert("You clicked on collab button")}
/>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials />
</MainMenu.Group>
<MainMenu.Separator />
{/* <MainMenu.Separator /> */}
<MainMenu.Sub>
<MainMenu.Sub.Trigger className="custom-classname">
Another submenu trigger
</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content className="custom-classname-for-content">
<MainMenu.Sub.Item
title="Sub item"
onSelect={() => window.alert("You clicked on sub item")}
>
Sub item
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger me</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub>
<MainMenu.Sub.Trigger>Trigger me inside</MainMenu.Sub.Trigger>
<MainMenu.Sub.Content>
<MainMenu.Sub.Item
onSelect={() => {
alert("wow, nested submenus!");
}}
>
Item wow
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.Sub.Item
onSelect={() => {
alert("wow, nested submenus! very cool");
}}
>
Another one
</MainMenu.Sub.Item>
</MainMenu.Sub.Content>
</MainMenu.Sub>
<MainMenu.ItemCustom>
<button
style={{ height: "2rem" }}
-7
View File
@@ -20,7 +20,6 @@ import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
@@ -499,11 +498,6 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@@ -594,7 +588,6 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
@@ -39,6 +39,7 @@ export const AppMainMenu: React.FC<{
<MainMenu.DefaultItems.SearchMenu />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.DefaultItems.Preferences />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={ExcalLogo}
+3 -1
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) {
+6 -1
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,
});
}
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
<title>Excalidraw Whiteboard</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 +14,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"
+10 -1
View File
@@ -18,13 +18,20 @@ 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,
) ||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
@@ -515,3 +522,5 @@ export enum UserIdleState {
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
+16 -4
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();
+367 -157
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;
};
/**
@@ -1054,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
inserted,
}: Delta<ElementPartial>) =>
!!(
deleted.version &&
inserted.version &&
// versions are required integers
Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) &&
// versions should be positive, zero included
deleted.version >= 0 &&
inserted.version >= 0 &&
// versions should never be the same
deleted.version !== inserted.version
(
Number.isInteger(deleted.version) &&
Number.isInteger(inserted.version) &&
// versions should be positive, zero included
deleted.version! >= 0 &&
inserted.version! >= 0 &&
// versions should never be the same
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,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps,
);
removed[prevElement.id] = delta;
if (!prevElement.isDeleted) {
removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
}
}
}
@@ -1140,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = {
...nextElement,
isDeleted: false,
} as ElementPartial;
const delta = Delta.create(
@@ -1149,7 +1219,12 @@ 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;
} else {
updated[nextElement.id] = delta;
}
continue;
}
@@ -1178,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue;
}
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
updated[nextElement.id] = delta;
}
}
@@ -1196,8 +1268,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;
@@ -1316,9 +1388,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 +1396,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 +1441,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 +1464,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 +1583,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 +1647,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 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue;
}
if (options.excludedProperties.has(key)) {
if (options?.excludedProperties?.has(key)) {
continue;
}
@@ -1521,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index;
}
return newElementWith(element, directlyApplicablePartial);
return newElementWith(element, directlyApplicablePartial, true);
}
/**
@@ -1561,6 +1742,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 +1754,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 +1821,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 +1888,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 +1967,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,
});
+30 -17
View File
@@ -359,6 +359,12 @@ const handleSegmentRelease = (
null,
);
if (!restoredPoints || restoredPoints.length < 2) {
throw new Error(
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
);
}
const nextPoints: GlobalPoint[] = [];
// First part of the arrow are the old points
@@ -706,7 +712,7 @@ const handleEndpointDrag = (
endGlobalPoint: GlobalPoint,
hoveredStartElement: ExcalidrawBindableElement | null,
hoveredEndElement: ExcalidrawBindableElement | null,
) => {
): ElementUpdate<ExcalidrawElbowArrowElement> => {
let startIsSpecial = arrow.startIsSpecial ?? null;
let endIsSpecial = arrow.endIsSpecial ?? null;
const globalUpdatedPoints = updatedPoints.map((p, i) =>
@@ -741,8 +747,15 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point
{
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
if (!secondPoint || !thirdPoint) {
throw new Error(
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
);
}
const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection
{
const secondToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
const thirdToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
const secondToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
);
const thirdToLastPoint = globalUpdatedPoints.at(
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
);
if (!secondToLastPoint || !thirdToLastPoint) {
throw new Error(
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
);
}
const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint,
@@ -2071,16 +2093,7 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
): ElementUpdate<ExcalidrawElbowArrowElement> => {
const offsetX = global[0][0];
const offsetY = global[0][1];
let points = global.map((p) =>
+19 -3
View File
@@ -35,6 +35,7 @@ import {
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement";
import {
getMinTextElementWidth,
@@ -225,7 +226,16 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
scene.mutateElement(textElement, { angle });
const { x, y } = computeBoundTextPosition(
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
}
}
};
@@ -416,9 +426,15 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, {
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
x,
y,
angle: normalizeRadians((centerAngle + origAngle) as Radians),
});
}
+31 -16
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, {
+22 -2
View File
@@ -10,12 +10,12 @@ import {
invariant,
} from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
x =
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
}
const angle = (container.angle ?? 0) as Radians;
if (angle !== 0) {
const contentCenter = pointFrom(
containerCoords.x + maxContainerWidth / 2,
containerCoords.y + maxContainerHeight / 2,
);
const textCenter = pointFrom(
x + boundTextElement.width / 2,
y + boundTextElement.height / 2,
);
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
return {
x: rx - boundTextElement.width / 2,
y: ry - boundTextElement.height / 2,
};
}
return { x, y };
};
+433 -12
View File
@@ -1,13 +1,345 @@
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 throw 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();
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should not throw 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]]);
expect(() =>
ElementsDelta.calculate(prevElements, nextElements),
).not.toThrow();
});
it("should create updated delta even 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).toEqual(
ElementsDelta.create(
{},
{},
{
[baseElement.id]: Delta.create(
{
version: baseElement.version,
versionNonce: baseElement.versionNonce,
},
{
version: baseElement.version + 1,
versionNonce: baseElement.versionNonce + 1,
},
),
},
),
);
});
});
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 +356,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 +390,7 @@ describe("AppStateDelta", () => {
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@@ -106,9 +436,7 @@ describe("AppStateDelta", () => {
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElementId: null,
selectedLinearElementIsEditing: null,
editingLinearElementId: null,
selectedLinearElement: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@@ -149,4 +477,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 },
},
),
);
});
});
});
+171 -1
View File
@@ -1,13 +1,14 @@
import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY } from "@excalidraw/common";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import {
computeContainerDimensionForBoundText,
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
});
});
describe("Test computeBoundTextPosition", () => {
const createMockElementsMap = () => new Map();
// Helper function to create rectangle test case with 90-degree rotation
const createRotatedRectangleTestCase = (
textAlign: string,
verticalAlign: string,
) => {
const container = API.createElement({
type: "rectangle",
x: 100,
y: 100,
width: 200,
height: 100,
angle: (Math.PI / 2) as any, // 90 degrees
});
const boundTextElement = API.createElement({
type: "text",
width: 80,
height: 40,
text: "hello darkness my old friend",
textAlign: textAlign as any,
verticalAlign: verticalAlign as any,
containerId: container.id,
}) as ExcalidrawTextElementWithContainer;
const elementsMap = createMockElementsMap();
return { container, boundTextElement, elementsMap };
};
describe("90-degree rotation with all alignment combinations", () => {
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(75, 1);
});
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.MIDDLE,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(
TEXT_ALIGN.CENTER,
VERTICAL_ALIGN.BOTTOM,
);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(130, 1);
});
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(185, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(160, 1);
expect(result.y).toBeCloseTo(185, 1);
});
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
const { container, boundTextElement, elementsMap } =
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
const result = computeBoundTextPosition(
container,
boundTextElement,
elementsMap,
);
expect(result.x).toBeCloseTo(135, 1);
expect(result.y).toBeCloseTo(185, 1);
});
});
});
+6 -3
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"];
@@ -298,7 +298,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,
+27 -6
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,
});
}
+3 -1
View File
@@ -54,7 +54,8 @@ export type ShortcutName =
| "saveScene"
| "imageExport"
| "commandPalette"
| "searchMenu";
| "searchMenu"
| "toolLock";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
toggleShortcuts: [getShortcutKey("?")],
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
wrapSelectionInFrame: [],
toolLock: [getShortcutKey("Q")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
+74 -65
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}>
@@ -391,6 +397,7 @@ export const ShapesSwitcher = ({
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
align="end"
>
<DropdownMenu.Item
onSelect={() => app.setActiveTool({ type: "frame" })}
@@ -418,14 +425,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>
@@ -442,10 +451,10 @@ export const ShapesSwitcher = ({
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
icon={MagicIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</>
)}
+296 -54
View File
@@ -100,7 +100,9 @@ import {
randomInteger,
CLASSES,
Emitter,
isMobile,
MINIMUM_ARROW_SIZE,
DOUBLE_TAP_POSITION_THRESHOLD,
} from "@excalidraw/common";
import {
@@ -233,6 +235,8 @@ import {
hitElementBoundingBox,
isLineElement,
isSimpleArrow,
StoreDelta,
type ApplyToOptions,
} from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -259,6 +263,7 @@ import type {
MagicGenerationData,
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
SceneElementsMap,
} from "@excalidraw/element/types";
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
@@ -527,6 +532,7 @@ export const useExcalidrawActionManager = () =>
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
let firstTapPosition: { x: number; y: number } | null = null;
let isHoldingSpace: boolean = false;
let isPanning: boolean = false;
let isDraggingScrollBar: boolean = false;
@@ -650,9 +656,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 +708,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 +1614,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 +2355,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 +2369,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 +2414,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 ||
@@ -2959,6 +2991,7 @@ class App extends React.Component<AppProps, AppState> {
private static resetTapTwice() {
didTapTwice = false;
firstTapPosition = null;
}
private onTouchStart = (event: TouchEvent) => {
@@ -2969,6 +3002,13 @@ class App extends React.Component<AppProps, AppState> {
if (!didTapTwice) {
didTapTwice = true;
if (event.touches.length === 1) {
firstTapPosition = {
x: event.touches[0].clientX,
y: event.touches[0].clientY,
};
}
clearTimeout(tappedTwiceTimer);
tappedTwiceTimer = window.setTimeout(
App.resetTapTwice,
@@ -2976,15 +3016,29 @@ class App extends React.Component<AppProps, AppState> {
);
return;
}
// insert text only if we tapped twice with a single finger
// insert text only if we tapped twice with a single finger at approximately the same position
// event.touches.length === 1 will also prevent inserting text when user's zooming
if (didTapTwice && event.touches.length === 1) {
if (didTapTwice && event.touches.length === 1 && firstTapPosition) {
const touch = event.touches[0];
// @ts-ignore
this.handleCanvasDoubleClick({
clientX: touch.clientX,
clientY: touch.clientY,
});
const distance = pointDistance(
pointFrom(touch.clientX, touch.clientY),
pointFrom(firstTapPosition.x, firstTapPosition.y),
);
// only create text if the second tap is within the threshold of the first tap
// this prevents accidental text creation during dragging/selection
if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) {
// end lasso trail and deselect elements just in case
this.lassoTrail.endPath();
this.deselectElements();
// @ts-ignore
this.handleCanvasDoubleClick({
clientX: touch.clientX,
clientY: touch.clientY,
});
}
didTapTwice = false;
clearTimeout(tappedTwiceTimer);
}
@@ -3110,7 +3164,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 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
this.addElementsFromPasteOrLibrary({
elements,
files,
position: "cursor",
position: this.isMobileOrTablet() ? "center" : "cursor",
});
return;
@@ -3188,7 +3242,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 +3254,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 +3388,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 +3634,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 +3989,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 +4547,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 +4640,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 +5004,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 +5029,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 +5485,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 +5777,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 +6097,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 +6260,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 +6382,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 +6396,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 +6663,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 +7155,7 @@ class App extends React.Component<AppProps, AppState> {
hasOccurred: false,
offset: null,
origin: { ...origin },
blockDragging: false,
},
eventListeners: {
onMove: null,
@@ -7036,7 +7231,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 +7441,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 +7673,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 +8459,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 +8491,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 +9108,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 +9397,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 +10013,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 +10309,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 +10363,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 +10684,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;
}
+2 -2
View File
@@ -11,7 +11,7 @@ interface ButtonProps
HTMLButtonElement
> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => any;
/** whether button is in active state */
selected?: boolean;
children: React.ReactNode;
@@ -34,7 +34,7 @@ export const Button = ({
return (
<button
onClick={composeEventHandlers(rest.onClick, (event) => {
onSelect();
onSelect(event);
})}
type={type}
className={clsx("excalidraw-button", className, { selected })}
@@ -30,6 +30,18 @@
align-items: center;
}
#canvas-bg-color-picker-container {
.color-picker__top-picks {
gap: 0.5rem;
}
.color-picker-container {
@include isMobile {
max-width: none;
}
}
}
.color-picker__button {
--radius: 4px;
--size: 1.375rem;
@@ -25,10 +25,6 @@ import { PropertiesPopover } from "../PropertiesPopover";
import { QuickSearch } from "../QuickSearch";
import { ScrollableList } from "../ScrollableList";
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
import DropdownMenuItem, {
DropDownMenuItemBadgeType,
DropDownMenuItemBadge,
} from "../dropdownMenu/DropdownMenuItem";
import {
FontFamilyCodeIcon,
FontFamilyHeadingIcon,
@@ -36,8 +32,15 @@ import {
FreedrawIcon,
} from "../icons";
import { Ellipsify } from "../Ellipsify";
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
import {
FontPickerListItem,
FontPickerListItemBadgeType,
} from "./FontPickerListItem";
import type { JSX } from "react";
export interface FontDescriptor {
@@ -46,7 +49,7 @@ export interface FontDescriptor {
text: string;
deprecated?: true;
badge?: {
type: ValueOf<typeof DropDownMenuItemBadgeType>;
type: ValueOf<typeof FontPickerListItemBadgeType>;
placeholder: string;
};
}
@@ -112,7 +115,7 @@ export const FontPickerList = React.memo(
Object.assign(fontDescriptor, {
deprecated: metadata.deprecated,
badge: {
type: DropDownMenuItemBadgeType.RED,
type: FontPickerListItemBadgeType.RED,
placeholder: t("fontList.badge.old"),
},
});
@@ -227,7 +230,7 @@ export const FontPickerList = React.memo(
);
const renderFont = (font: FontDescriptor, index: number) => (
<DropdownMenuItem
<FontPickerListItem
key={font.value}
icon={font.icon}
value={font.value}
@@ -239,8 +242,8 @@ export const FontPickerList = React.memo(
selected={font.value === selectedFontFamily}
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
onSelect(Number(e.currentTarget.value));
onSelect={() => {
onSelect(font.value);
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
@@ -248,13 +251,13 @@ export const FontPickerList = React.memo(
}
}}
>
{font.text}
<Ellipsify>{font.text}</Ellipsify>
{font.badge && (
<DropDownMenuItemBadge type={font.badge.type}>
<FontPickerListItem.Badge type={font.badge.type}>
{font.badge.placeholder}
</DropDownMenuItemBadge>
</FontPickerListItem.Badge>
)}
</DropdownMenuItem>
</FontPickerListItem>
);
const groups = [];
@@ -0,0 +1,151 @@
import React, { useEffect, useRef } from "react";
import { THEME } from "@excalidraw/common";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App";
import { useDevice } from "../App";
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
shortcut,
children,
}: {
icon?: React.ReactNode;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
{children}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
)}
</>
);
};
export const FontPickerListItem = ({
icon,
value,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
value?: string | number | undefined;
order?: number;
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<div className="radix-menu-item">
<Button
{...rest}
ref={ref}
onSelect={onSelect}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</div>
);
};
FontPickerListItem.displayName = "FontPickerListItem";
export const FontPickerListItemBadgeType = {
GREEN: "green",
RED: "red",
BLUE: "blue",
} as const;
export const FontPickerListItemBadge = ({
type = FontPickerListItemBadgeType.BLUE,
children,
}: {
type?: ValueOf<typeof FontPickerListItemBadgeType>;
children: React.ReactNode;
}) => {
const { theme } = useExcalidrawAppState();
const style = {
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 9,
fontFamily: "Cascadia, monospace",
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
};
switch (type) {
case FontPickerListItemBadgeType.GREEN:
Object.assign(style, {
backgroundColor: "var(--background-color-badge)",
color: "var(--color-badge)",
});
break;
case FontPickerListItemBadgeType.RED:
Object.assign(style, {
backgroundColor: "pink",
color: "darkred",
});
break;
case FontPickerListItemBadgeType.BLUE:
default:
Object.assign(style, {
background: "var(--color-promo)",
color: "var(--color-surface-lowest)",
});
}
return (
<div className="DropDownMenuItemBadge" style={style}>
{children}
</div>
);
};
FontPickerListItemBadge.displayName = "DropdownMenuItemBadge";
FontPickerListItem.Badge = FontPickerListItemBadge;
@@ -238,7 +238,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
isOr={true}
/>
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
<Shortcut
label={t("toolBar.lock")}
shortcuts={[getShortcutFromShortcutName("toolLock")]}
/>
<Shortcut
label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]}
@@ -194,6 +194,7 @@ export const LibraryDropdownMenuButton: React.FC<{
<DropdownMenu open={isLibraryMenuOpen}>
<DropdownMenu.Trigger
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
aria-label="Library menu"
>
{DotsIcon}
</DropdownMenu.Trigger>
@@ -201,6 +202,7 @@ export const LibraryDropdownMenuButton: React.FC<{
onClickOutside={() => setIsLibraryMenuOpen(false)}
onSelect={() => setIsLibraryMenuOpen(false)}
className="library-menu"
align="end"
>
{!itemsSelected && (
<DropdownMenu.Item
@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
}}
icon={icon ?? brainIcon}
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
>
{children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);
@@ -1,20 +1,45 @@
@import "../../css/variables.module.scss";
@import "../../css/variables.module";
.excalidraw {
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
z-index: 2 !important;
}
.dropdown-menu {
position: absolute;
top: 100%;
margin-top: 0.5rem;
max-width: 16rem;
margin-top: 0.25rem;
&__submenu-trigger {
&[aria-expanded="true"] {
.dropdown-menu-item {
background-color: var(--button-hover-bg);
}
}
}
&__submenu-trigger-icon {
margin-left: auto;
opacity: 0.5;
}
.radix-menu-item {
&:focus-visible {
outline: none;
}
}
.dropdown-submenu {
margin-left: -0.75rem;
min-width: 16rem;
max-width: 20rem;
}
&--mobile {
left: 0;
width: 100%;
row-gap: 0.75rem;
.dropdown-menu-container {
grid-template-columns: minmax(0, 1fr);
padding: 8px 8px;
box-sizing: border-box;
// background-color: var(--island-bg-color);
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
border-radius: var(--border-radius-lg);
position: relative;
@@ -30,13 +55,14 @@
.dropdown-menu-container {
background-color: var(--island-bg-color);
max-height: calc(100vh - 150px);
max-height: var(--radix-popper-available-height);
overflow-y: auto;
--gap: 2;
}
.dropdown-menu-item-base {
display: flex;
padding: 0 0.625rem;
column-gap: 0.625rem;
font-size: 0.875rem;
color: var(--color-on-surface);
@@ -44,6 +70,7 @@
box-sizing: border-box;
font-weight: 400;
font-family: inherit;
justify-content: flex-start;
}
&.manual-hover {
@@ -1,5 +1,7 @@
import React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import DropdownMenuContent from "./DropdownMenuContent";
import DropdownMenuGroup from "./DropdownMenuGroup";
import DropdownMenuItem from "./DropdownMenuItem";
@@ -23,11 +25,12 @@ const DropdownMenu = ({
}) => {
const MenuTriggerComp = getMenuTriggerComponent(children);
const MenuContentComp = getMenuContentComponent(children);
return (
<>
<DropdownMenuPrimitive.Root open={open} modal={false}>
{MenuTriggerComp}
{open && MenuContentComp}
</>
{MenuContentComp}
</DropdownMenuPrimitive.Root>
);
};
@@ -3,6 +3,8 @@ import React, { useEffect, useRef } from "react";
import { EVENT, KEYS } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useStable } from "../../hooks/useStable";
import { useDevice } from "../App";
@@ -17,6 +19,9 @@ const MenuContent = ({
className = "",
onSelect,
style,
sideOffset = 4,
align = "start",
collisionPadding,
}: {
children?: React.ReactNode;
onClickOutside?: () => void;
@@ -26,6 +31,11 @@ const MenuContent = ({
*/
onSelect?: (event: Event) => void;
style?: React.CSSProperties;
sideOffset?: number;
align?: "start" | "center" | "end";
collisionPadding?:
| number
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
}) => {
const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null);
@@ -62,11 +72,15 @@ const MenuContent = ({
return (
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
<div
<DropdownMenuPrimitive.Content
ref={menuRef}
className={classNames}
style={style}
data-testid="dropdown-menu"
side="bottom"
sideOffset={sideOffset}
align={align}
collisionPadding={collisionPadding}
>
{/* the zIndex ensures this menu has higher stacking order,
see https://github.com/excalidraw/excalidraw/pull/1445 */}
@@ -81,7 +95,7 @@ const MenuContent = ({
{children}
</Island>
)}
</div>
</DropdownMenuPrimitive.Content>
</DropdownMenuContentPropsContext.Provider>
);
};
@@ -1,12 +1,17 @@
import React, { useEffect, useRef } from "react";
import React, { useRef } from "react";
import { THEME } from "@excalidraw/common";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type { ValueOf } from "@excalidraw/common/utility-types";
import { Button } from "../Button";
import { useExcalidrawAppState } from "../App";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
@@ -17,55 +22,45 @@ import type { JSX } from "react";
const DropdownMenuItem = ({
icon,
value,
badge,
order,
children,
shortcut,
className,
hovered,
selected,
textStyle,
onSelect,
onClick,
...rest
}: {
icon?: JSX.Element;
badge?: React.ReactNode;
value?: string | number | undefined;
order?: number;
onSelect?: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
hovered?: boolean;
selected?: boolean;
textStyle?: React.CSSProperties;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (hovered) {
if (order === 0) {
// scroll into the first item differently, so it's visible what is above (i.e. group title)
ref.current?.scrollIntoView({ block: "end" });
} else {
ref.current?.scrollIntoView({ block: "nearest" });
}
}
}, [hovered, order]);
return (
<button
{...rest}
ref={ref}
value={value}
onClick={handleClick}
className={getDropdownMenuItemClassName(className, selected, hovered)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</button>
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
ref={ref}
onSelect={handleClick}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
{children}
</MenuItemContent>
</Button>
</DropdownMenuPrimitive.Item>
);
};
DropdownMenuItem.displayName = "DropdownMenuItem";
@@ -2,25 +2,24 @@ import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({
textStyle,
icon,
badge,
shortcut,
children,
}: {
icon?: JSX.Element;
icon?: React.ReactNode;
shortcut?: string;
textStyle?: React.CSSProperties;
children: React.ReactNode;
badge?: React.ReactNode;
}) => {
const device = useDevice();
return (
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
<div className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
{badge}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
@@ -0,0 +1,27 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import {
getSubMenuContentComponent,
getSubMenuTriggerComponent,
} from "./dropdownMenuUtils";
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
import DropdownMenuSubContent from "./DropdownMenuSubContent";
import DropdownMenuSubItem from "./DropdownMenuSubItem";
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
const MenuTriggerComp = getSubMenuTriggerComponent(children);
const MenuContentComp = getSubMenuContentComponent(children);
return (
<DropdownMenuPrimitive.Sub>
{MenuTriggerComp}
{MenuContentComp}
</DropdownMenuPrimitive.Sub>
);
};
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
DropdownMenuSub.Content = DropdownMenuSubContent;
DropdownMenuSub.Item = DropdownMenuSubItem;
export default DropdownMenuSub;
DropdownMenuSub.displayName = "DropdownMenuSub";
@@ -0,0 +1,44 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import clsx from "clsx";
import { useDevice } from "../App";
import Stack from "../Stack";
import { Island } from "../Island";
const DropdownMenuSubContent = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const device = useDevice();
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile,
}).trim();
return (
<DropdownMenuPrimitive.SubContent
className={classNames}
sideOffset={8}
alignOffset={-4}
>
{device.editor.isMobile ? (
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
) : (
<Island
className="dropdown-menu-container"
padding={1}
style={{ zIndex: 1 }}
>
{children}
</Island>
)}
</DropdownMenuPrimitive.SubContent>
);
};
export default DropdownMenuSubContent;
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";
@@ -0,0 +1,45 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Button } from "../Button";
import MenuItemContent from "./DropdownMenuItemContent";
import {
getDropdownMenuItemClassName,
useHandleDropdownMenuItemClick,
} from "./common";
const DropdownMenuSubItem = ({
icon,
onSelect,
children,
shortcut,
className,
...rest
}: {
icon?: React.ReactNode;
onSelect: (event: Event) => void;
children: React.ReactNode;
shortcut?: string;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
return (
<DropdownMenuPrimitive.Item className="radix-menu-item">
<Button
{...rest}
onSelect={handleClick}
type="button"
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon} shortcut={shortcut}>
{children}
</MenuItemContent>
</Button>
</DropdownMenuPrimitive.Item>
);
};
export default DropdownMenuSubItem;
DropdownMenuSubItem.displayName = "DropdownMenuSubItem";
@@ -0,0 +1,39 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import React from "react";
import { ChevronRight } from "../icons";
import MenuItemContent from "./DropdownMenuItemContent";
import { getDropdownMenuItemClassName } from "./common";
import type { JSX } from "react";
const DropdownMenuSubTrigger = ({
children,
icon,
className,
...rest
}: {
children: React.ReactNode;
icon?: JSX.Element;
className?: string;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<DropdownMenuPrimitive.SubTrigger className="radix-menu-item dropdown-menu__submenu-trigger">
<div
{...rest}
className={getDropdownMenuItemClassName(className)}
title={rest.title ?? rest["aria-label"]}
>
<MenuItemContent icon={icon}>{children}</MenuItemContent>
<div className="dropdown-menu__submenu-trigger-icon">
{ChevronRight}
</div>
</div>
</DropdownMenuPrimitive.SubTrigger>
);
};
export default DropdownMenuSubTrigger;
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";
@@ -1,5 +1,7 @@
import clsx from "clsx";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { useDevice } from "../App";
const MenuTrigger = ({
@@ -23,7 +25,8 @@ const MenuTrigger = ({
},
).trim();
return (
<button
<DropdownMenuPrimitive.Trigger
data-dropdown-menu-trigger
data-prevent-outside-click
className={classNames}
onClick={onToggle}
@@ -33,7 +36,7 @@ const MenuTrigger = ({
{...rest}
>
{children}
</button>
</DropdownMenuPrimitive.Trigger>
);
};
@@ -1,6 +1,6 @@
import React from "react";
export const getMenuTriggerComponent = (children: React.ReactNode) => {
const getMenuComponent = (component: string) => (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
@@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuTrigger",
child.type.displayName === component,
);
if (!comp) {
return null;
@@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
return comp;
};
export const getMenuContentComponent = (children: React.ReactNode) => {
const comp = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) &&
typeof child.type !== "string" &&
//@ts-ignore
child?.type.displayName &&
//@ts-ignore
child.type.displayName === "DropdownMenuContent",
);
if (!comp) {
return null;
}
//@ts-ignore
return comp;
};
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
export const getSubMenuTriggerComponent = getMenuComponent(
"DropdownMenuSubTrigger",
);
export const getSubMenuContentComponent = getMenuComponent(
"DropdownMenuSubContent",
);
+27
View File
@@ -72,6 +72,15 @@ const modifiedTablerIconProps: Opts = {
// -----------------------------------------------------------------------------
//tabler-icons: chevron-right
export const ChevronRight = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="9 6 15 12 9 18" />
</g>,
tablerIconProps,
);
// tabler-icons: present
export const PlusPromoIcon = createIcon(
<g strokeWidth="1.5">
@@ -2269,3 +2278,21 @@ export const elementLinkIcon = createIcon(
</g>,
tablerIconProps,
);
export const settingsIcon = createIcon(
<g strokeWidth={1.25}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 6l8 0" />
<path d="M16 6l4 0" />
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 12l2 0" />
<path d="M10 12l10 0" />
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 18l11 0" />
<path d="M19 18l1 0" />
</g>,
tablerIconProps,
);
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
@@ -9,8 +9,11 @@ import {
actionLoadScene,
actionSaveToActiveFile,
actionShortcuts,
actionToggleGridMode,
actionToggleObjectsSnapMode,
actionToggleSearchMenu,
actionToggleTheme,
actionToggleZenMode,
} from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { trackEvent } from "../../analytics";
@@ -23,13 +26,23 @@ import {
useExcalidrawActionManager,
useExcalidrawElements,
useAppProps,
useApp,
} from "../App";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
import {
GithubIcon,
DiscordIcon,
XBrandIcon,
settingsIcon,
checkIcon,
emptyIcon,
} from "../icons";
import {
boltIcon,
DeviceDesktopIcon,
@@ -313,7 +326,10 @@ export const ChangeCanvasBackground = () => {
>
{t("labels.canvasBackground")}
</div>
<div style={{ padding: "0 0.625rem" }}>
<div
style={{ padding: "0 0.625rem" }}
id="canvas-bg-color-picker-container"
>
{actionManager.renderAction("changeViewBackgroundColor")}
</div>
</div>
@@ -393,3 +409,73 @@ export const LiveCollaborationTrigger = ({
};
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
export const Preferences = ({ children }: { children?: React.ReactNode }) => {
const { t } = useI18n();
const actionManager = useExcalidrawActionManager();
const appState = useUIAppState();
const app = useApp();
return (
<DropdownMenuSub>
<DropdownMenuSub.Trigger icon={settingsIcon}>
{t("labels.preferences")}
</DropdownMenuSub.Trigger>
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
<DropdownMenuSub.Item
icon={appState.activeTool.locked ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("toolLock")}
onSelect={(event) => {
app.toggleLock();
event.preventDefault();
}}
>
{t("labels.preferences_toolLock")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.objectsSnapModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleObjectsSnapMode);
event.preventDefault();
}}
>
{t("buttons.objectsSnapMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.gridModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("gridMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleGridMode);
event.preventDefault();
}}
>
{t("labels.toggleGrid")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.zenModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("zenMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleZenMode);
event.preventDefault();
}}
>
{t("buttons.zenMode")}
</DropdownMenuSub.Item>
<DropdownMenuSub.Item
icon={appState.viewModeEnabled ? checkIcon : emptyIcon}
shortcut={getShortcutFromShortcutName("viewMode")}
onSelect={(event) => {
actionManager.executeAction(actionToggleViewMode);
event.preventDefault();
}}
>
{t("labels.viewMode")}
</DropdownMenuSub.Item>
{children}
</DropdownMenuSub.Content>
</DropdownMenuSub>
);
};
Preferences.displayName = "Preferences";
@@ -2,8 +2,12 @@ import React from "react";
import { composeEventHandlers } from "@excalidraw/common";
import * as Portal from "@radix-ui/react-portal";
import { useTunnels } from "../../context/tunnels";
import { useUIAppState } from "../../context/ui-appState";
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
import { t } from "../../i18n";
import { useDevice, useExcalidrawSetAppState } from "../App";
import { UserList } from "../UserList";
@@ -36,6 +40,17 @@ const MainMenu = Object.assign(
return (
<MainMenuTunnel.In>
{appState.openMenu === "canvas" && device.editor.isMobile && (
<Portal.Root
style={{
backgroundColor: "rgba(18, 18, 18, 0.2)",
position: "fixed",
inset: "0px",
// zIndex: "var(--zIndex-layerUI)",
}}
onClick={() => setAppState({ openMenu: null })}
/>
)}
<DropdownMenu open={appState.openMenu === "canvas"}>
<DropdownMenu.Trigger
onToggle={() => {
@@ -44,15 +59,27 @@ const MainMenu = Object.assign(
});
}}
data-testid="main-menu-trigger"
aria-label="Main menu"
className="main-menu-trigger"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
sideOffset={device.editor.isMobile ? 20 : undefined}
className="main-menu-content"
onClickOutside={onClickOutside}
onSelect={composeEventHandlers(onSelect, () => {
setAppState({ openMenu: null });
})}
collisionPadding={
// accounting for
// - editor footer on desktop
// - toolbar on mobile
// we probably don't want the menu to overlay these elements
!device.editor.isMobile
? { bottom: 90, top: 10 }
: { top: 90, bottom: 10 }
}
>
{children}
{device.editor.isMobile && appState.collaborators.size > 0 && (
@@ -78,6 +105,7 @@ const MainMenu = Object.assign(
ItemCustom: DropdownMenu.ItemCustom,
Group: DropdownMenu.Group,
Separator: DropdownMenu.Separator,
Sub: DropdownMenuSub,
DefaultItems,
},
);
+19 -2
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 &&
+1
View File
@@ -144,6 +144,7 @@
--color-logo-icon: var(--color-primary);
--color-logo-text: #190064;
--border-radius-sm: 0.25rem;
--border-radius-md: 0.375rem;
--border-radius-lg: 0.5rem;
+5 -1
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)) {
+2 -2
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
+51 -22
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);
}
@@ -385,7 +387,10 @@ const restoreElement = (
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments: element.fixedSegments,
fixedSegments:
element.fixedSegments?.length && base.points.length >= 4
? element.fixedSegments
: null,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
@@ -523,7 +528,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 +543,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 +815,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),
+1 -1
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] =
+1
View File
@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
export {
restore,
restoreAppState,
restoreElement,
restoreElements,
restoreLibraryItems,
} from "./data/restore";
+3 -1
View File
@@ -171,7 +171,9 @@
"linkToElement": "Link to object",
"wrapSelectionInFrame": "Wrap selection in frame",
"tab": "Tab",
"shapeSwitch": "Switch shape"
"shapeSwitch": "Switch shape",
"preferences": "Preferences",
"preferences_toolLock": "Tool lock"
},
"elementLink": {
"title": "Link to object",
+5 -3
View File
@@ -81,11 +81,13 @@
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/common": "0.18.0",
"@excalidraw/element": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/math": "0.18.0",
"@excalidraw/mermaid-to-excalidraw": "1.1.3",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-tabs": "1.1.3",
"browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1",
@@ -97,8 +99,8 @@
"image-blob-reduce": "3.0.1",
"jotai": "2.11.0",
"jotai-scope": "0.7.2",
"lodash.throttle": "4.1.1",
"lodash.debounce": "4.0.8",
"lodash.throttle": "4.1.1",
"nanoid": "3.3.3",
"open-color": "1.9.1",
"pako": "2.0.3",
+7 -1
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] &&
@@ -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
@@ -6394,15 +6394,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 +6471,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 +6549,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 +6685,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 +6709,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 +6843,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 +6884,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 +8731,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 +8959,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 +9379,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 +9785,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,
},
},
},
@@ -14494,12 +14510,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,
},
},
},
},
+42 -8
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,
+12 -2
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,
}),
]);
});
});
});
+27 -22
View File
@@ -1,13 +1,18 @@
import { queryByText, queryByTestId } from "@testing-library/react";
import React from "react";
import { useMemo } from "react";
import { THEME } from "@excalidraw/common";
import { t } from "../i18n";
import { Excalidraw, Footer, MainMenu } from "../index";
import { Excalidraw, Footer } from "..";
import MainMenu from "../components/main-menu/MainMenu";
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
import {
render,
togglePopover,
fireEvent,
GlobalTestState,
} from "./test-utils";
const { h } = window;
@@ -15,7 +20,7 @@ describe("<Excalidraw/>", () => {
afterEach(() => {
const menu = document.querySelector(".dropdown-menu");
if (menu) {
toggleMenu(document.querySelector(".excalidraw")!);
togglePopover("Main menu");
}
});
@@ -136,7 +141,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={undefined} />,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
});
@@ -145,7 +150,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
});
@@ -154,7 +159,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "json-export-button")).toBeNull();
});
@@ -163,7 +168,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "image-export-button")).toBeNull();
});
@@ -182,7 +187,7 @@ describe("<Excalidraw/>", () => {
/>,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "save-as-button")).toBeNull();
});
@@ -193,7 +198,7 @@ describe("<Excalidraw/>", () => {
/>,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "save-button")).toBeNull();
});
@@ -204,7 +209,7 @@ describe("<Excalidraw/>", () => {
/>,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
});
@@ -220,7 +225,7 @@ describe("<Excalidraw/>", () => {
</Excalidraw>,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
});
@@ -230,7 +235,7 @@ describe("<Excalidraw/>", () => {
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
});
@@ -251,8 +256,8 @@ describe("<Excalidraw/>", () => {
</Excalidraw>,
);
//open menu
toggleMenu(container);
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
togglePopover("Main menu");
expect(queryByTestId(container, "load-button")).toBeNull();
});
});
@@ -263,7 +268,7 @@ describe("<Excalidraw/>", () => {
const { container } = await render(<Excalidraw />);
expect(h.state.theme).toBe(THEME.LIGHT);
//open menu
toggleMenu(container);
togglePopover("Main menu");
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy();
});
@@ -273,7 +278,7 @@ describe("<Excalidraw/>", () => {
expect(h.state.theme).toBe(THEME.DARK);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
});
@@ -286,7 +291,7 @@ describe("<Excalidraw/>", () => {
);
expect(h.state.theme).toBe(THEME.DARK);
//open menu
toggleMenu(container);
togglePopover("Main menu");
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBeTruthy();
});
@@ -300,7 +305,7 @@ describe("<Excalidraw/>", () => {
);
expect(h.state.theme).toBe(THEME.DARK);
//open menu
toggleMenu(container);
togglePopover("Main menu");
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
expect(darkModeToggle).toBe(null);
});
@@ -310,7 +315,7 @@ describe("<Excalidraw/>", () => {
it("should allow editing name", async () => {
const { container } = await render(<Excalidraw />);
//open menu
toggleMenu(container);
togglePopover("Main menu");
fireEvent.click(queryByTestId(container, "image-export-button")!);
const textInput: HTMLInputElement | null = document.querySelector(
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
@@ -323,7 +328,7 @@ describe("<Excalidraw/>", () => {
const name = "test";
const { container } = await render(<Excalidraw name={name} />);
//open menu
toggleMenu(container);
togglePopover("Main menu");
await fireEvent.click(queryByTestId(container, "image-export-button")!);
const textInput = document.querySelector(
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
@@ -375,7 +380,7 @@ describe("<Excalidraw/>", () => {
</Excalidraw>,
);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
});
@@ -394,7 +399,7 @@ describe("<Excalidraw/>", () => {
const { container } = await render(<CustomExcalidraw />);
//open menu
toggleMenu(container);
togglePopover("Main menu");
expect(h.state.theme).toBe(THEME.LIGHT);
+9 -11
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: false,
}),
expect.objectContaining({
id: text.id,
@@ -4065,7 +4064,6 @@ describe("history", () => {
}),
expect.objectContaining({
id: remoteText.id,
// unbound
containerId: container.id,
isDeleted: false,
}),
@@ -4356,8 +4354,8 @@ describe("history", () => {
expect.objectContaining({
...textProps,
// text element got redrawn!
x: 205,
y: 205,
x: 241.295259647664,
y: 247.59240920619527,
angle: 90,
id: text.id,
containerId: container.id,
@@ -4400,8 +4398,8 @@ describe("history", () => {
}),
expect.objectContaining({
...textProps,
x: 205,
y: 205,
x: 241.295259647664,
y: 247.59240920619527,
angle: 90,
id: text.id,
containerId: container.id,
+12 -9
View File
@@ -1,5 +1,5 @@
import { act, queryByTestId } from "@testing-library/react";
import React from "react";
import { queryByTestId } from "@testing-library/react";
import { act } from "@testing-library/react";
import { vi } from "vitest";
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
@@ -13,9 +13,11 @@ import { serializeLibraryAsJSON } from "../data/json";
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
import { Excalidraw } from "../index";
import { fireEvent, render, togglePopover, waitFor } from "./test-utils";
import { API } from "./helpers/api";
import { UI } from "./helpers/ui";
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
import { getCloneByOrigId } from "./test-utils";
import type { LibraryItem, LibraryItems } from "../types";
@@ -215,12 +217,13 @@ describe("library menu", () => {
const libraryButton = container.querySelector(".sidebar-trigger");
fireEvent.click(libraryButton!);
fireEvent.click(
queryByTestId(
container.querySelector(".layer-ui__library")!,
"dropdown-menu-button",
)!,
);
togglePopover("Library menu");
// fireEvent.click(
// queryByTestId(
// container.querySelector(".layer-ui__library")!,
// "dropdown-menu-button",
// )!,
// );
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
@@ -2,26 +2,46 @@
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
<div
class="dropdown-menu"
aria-labelledby="radix-:r65:"
aria-orientation="vertical"
class="dropdown-menu main-menu-content"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu"
dir="ltr"
id="radix-:r66:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;"
style="--padding: 1; z-index: 2;"
>
<button
class="dropdown-menu-item dropdown-menu-item-base"
type="button"
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="dropdown-menu-item__icon"
/>
<div
class="dropdown-menu-item__text"
<button
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
type="button"
>
Click me
</div>
</button>
<div
class="dropdown-menu-item__icon"
/>
<div
class="dropdown-menu-item__text"
>
Click me
</div>
</button>
</div>
<a
class="dropdown-menu-item dropdown-menu-item-base"
href="https://plus.excalidraw.com/blog"
@@ -46,301 +66,361 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
custom menu item
</button>
</div>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="dropdown-menu-item__icon"
<button
aria-label="Help"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
<div
class="dropdown-menu-item__icon"
>
<g
stroke-width="1.5"
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
</div>
</div>
</div>
`;
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
<div
class="dropdown-menu"
aria-labelledby="radix-:rq:"
aria-orientation="vertical"
class="dropdown-menu main-menu-content"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="bottom"
data-state="open"
data-testid="dropdown-menu"
dir="ltr"
id="radix-:rr:"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
tabindex="-1"
>
<div
class="Island dropdown-menu-container"
style="--padding: 2; z-index: 2;"
style="--padding: 1; z-index: 2;"
>
<button
aria-label="Open"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="load-button"
title="Open"
type="button"
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="dropdown-menu-item__icon"
<button
aria-label="Open"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="load-button"
title="Open"
type="button"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
<div
class="dropdown-menu-item__icon"
>
<path
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Open
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+O
</div>
</button>
<button
aria-label="Save to..."
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="json-export-button"
title="Save to..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Save to...
</div>
</button>
<button
aria-label="Export image..."
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="image-export-button"
title="Export image..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.25"
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
stroke-width="1.25"
/>
<path
d="M15 8h.01"
/>
<path
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
/>
<path
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
/>
<path
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
/>
<path
d="M19 16v6"
/>
<path
d="M22 19l-3 3l-3 -3"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Export image...
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+Shift+E
</div>
</button>
<button
aria-label="Help"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
<g
stroke-width="1.5"
Open
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+O
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Save to..."
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="json-export-button"
title="Save to..."
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
stroke-width="1.25"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
<button
aria-label="Reset the canvas"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
<path
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
Save to...
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Export image..."
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="image-export-button"
title="Export image..."
type="button"
>
Reset the canvas
</div>
</button>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.25"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M15 8h.01"
/>
<path
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
/>
<path
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
/>
<path
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
/>
<path
d="M19 16v6"
/>
<path
d="M22 19l-3 3l-3 -3"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Export image...
</div>
<div
class="dropdown-menu-item__shortcut"
>
Ctrl+Shift+E
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Help"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="help-menu-item"
title="Help"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<circle
cx="12"
cy="12"
r="9"
/>
<line
x1="12"
x2="12"
y1="17"
y2="17.01"
/>
<path
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
/>
</g>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
>
?
</div>
</button>
</div>
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<button
aria-label="Reset the canvas"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
class="dropdown-menu-item__icon"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
stroke-width="1.25"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Reset the canvas
</div>
</button>
</div>
<div
style="height: 1px; margin: .5rem 0px;"
/>
@@ -473,45 +553,53 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
style="height: 1px; margin: .5rem 0px;"
/>
<button
aria-label="Dark mode"
class="dropdown-menu-item dropdown-menu-item-base"
data-testid="toggle-dark-mode"
title="Dark mode"
type="button"
<div
class="radix-menu-item"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
class="dropdown-menu-item__icon"
<button
aria-label="Dark mode"
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
data-testid="toggle-dark-mode"
title="Dark mode"
type="button"
>
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
<div
class="dropdown-menu-item__icon"
>
<path
clip-rule="evenodd"
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
<svg
aria-hidden="true"
class=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Dark mode
</div>
<div
class="dropdown-menu-item__shortcut"
>
Shift+Alt+D
</div>
</button>
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 20 20"
>
<path
clip-rule="evenodd"
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
stroke="currentColor"
/>
</svg>
</div>
<div
class="dropdown-menu-item__text"
>
Dark mode
</div>
<div
class="dropdown-menu-item__shortcut"
>
Shift+Alt+D
</div>
</button>
</div>
<div
style="margin-top: 0.5rem;"
>
@@ -522,6 +610,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
Canvas background
</div>
<div
id="canvas-bg-color-picker-container"
style="padding: 0px 0.625rem;"
>
<div>
@@ -593,7 +682,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
style="width: 1px; height: 100%; margin: 0px auto;"
/>
<button
aria-controls="radix-:r0:"
aria-controls="radix-:r12:"
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
+11 -2
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"];
@@ -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 () => {
+2 -1
View File
@@ -215,11 +215,12 @@ export const textWysiwyg = ({
);
app.scene.mutateElement(container, { height: targetContainerHeight });
} else {
const { y } = computeBoundTextPosition(
const { x, y } = computeBoundTextPosition(
container,
updatedTextElement as ExcalidrawTextElementWithContainer,
elementsMap,
);
coordX = x;
coordY = y;
}
}
+2
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 = {
+2 -2
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();
+3590 -1639
View File
File diff suppressed because it is too large Load Diff