Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e625d5aba3 |
@@ -615,52 +615,6 @@ export default function ExampleApp({
|
|||||||
const renderMenu = () => {
|
const renderMenu = () => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<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.SaveAsImage />
|
||||||
<MainMenu.DefaultItems.Export />
|
<MainMenu.DefaultItems.Export />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
@@ -668,57 +622,10 @@ export default function ExampleApp({
|
|||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
onSelect={() => window.alert("You clicked on collab button")}
|
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.Group title="Excalidraw links">
|
||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
</MainMenu.Group>
|
</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>
|
<MainMenu.ItemCustom>
|
||||||
<button
|
<button
|
||||||
style={{ height: "2rem" }}
|
style={{ height: "2rem" }}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
THEME,
|
||||||
|
TITLE_TIMEOUT,
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -498,6 +499,11 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const titleTimeout = setTimeout(
|
||||||
|
() => (document.title = APP_NAME),
|
||||||
|
TITLE_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
const syncData = debounce(() => {
|
const syncData = debounce(() => {
|
||||||
if (isTestEnv()) {
|
if (isTestEnv()) {
|
||||||
return;
|
return;
|
||||||
@@ -588,6 +594,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
visibilityChange,
|
visibilityChange,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
clearTimeout(titleTimeout);
|
||||||
};
|
};
|
||||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export const AppMainMenu: React.FC<{
|
|||||||
<MainMenu.DefaultItems.SearchMenu />
|
<MainMenu.DefaultItems.SearchMenu />
|
||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.DefaultItems.Preferences />
|
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.ItemLink
|
<MainMenu.ItemLink
|
||||||
icon={ExcalLogo}
|
icon={ExcalLogo}
|
||||||
|
|||||||
@@ -259,9 +259,7 @@ export const loadFromFirebase = async (
|
|||||||
}
|
}
|
||||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||||
const elements = getSyncableElements(
|
const elements = getSyncableElements(
|
||||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||||
deleteInvisibleElements: true,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -258,16 +258,11 @@ export const loadScene = async (
|
|||||||
await importFromBackend(id, privateKey),
|
await importFromBackend(id, privateKey),
|
||||||
localDataState?.appState,
|
localDataState?.appState,
|
||||||
localDataState?.elements,
|
localDataState?.elements,
|
||||||
{
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Excalidraw Whiteboard</title>
|
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
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 -->
|
<!-- Primary Meta Tags -->
|
||||||
<meta
|
<meta
|
||||||
name="title"
|
name="title"
|
||||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -18,20 +18,13 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
|||||||
export const isSafari =
|
export const isSafari =
|
||||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||||
export const isIOS =
|
export const isIOS =
|
||||||
/iPad|iPhone/i.test(navigator.platform) ||
|
/iPad|iPhone/.test(navigator.platform) ||
|
||||||
// iPadOS 13+
|
// iPadOS 13+
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
// keeping function so it can be mocked in test
|
// keeping function so it can be mocked in test
|
||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "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 =
|
export const supportsResizeObserver =
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
@@ -522,5 +515,3 @@ export enum UserIdleState {
|
|||||||
* the start and end points)
|
* the start and end points)
|
||||||
*/
|
*/
|
||||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||||
|
|
||||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
|
||||||
|
|||||||
@@ -164,14 +164,9 @@ export class Scene {
|
|||||||
return this.frames;
|
return this.frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(elements: ElementsMapOrArray | null = null) {
|
||||||
elements: ElementsMapOrArray | null = null,
|
|
||||||
options?: {
|
|
||||||
skipValidation?: true;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
if (elements) {
|
if (elements) {
|
||||||
this.replaceAllElements(elements, options);
|
this.replaceAllElements(elements);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,19 +263,12 @@ export class Scene {
|
|||||||
return didChange;
|
return didChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceAllElements(
|
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||||
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
|
// 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 _nextElements = toArray(nextElements);
|
||||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||||
|
|
||||||
if (!options?.skipValidation) {
|
validateIndicesThrottled(_nextElements);
|
||||||
validateIndicesThrottled(_nextElements);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.elements = syncInvalidIndices(_nextElements);
|
this.elements = syncInvalidIndices(_nextElements);
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
|
|||||||
+157
-367
@@ -2,6 +2,7 @@ import {
|
|||||||
arrayToMap,
|
arrayToMap,
|
||||||
arrayToObject,
|
arrayToObject,
|
||||||
assertNever,
|
assertNever,
|
||||||
|
invariant,
|
||||||
isDevEnv,
|
isDevEnv,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
@@ -55,10 +56,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
|||||||
|
|
||||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||||
|
|
||||||
import { StoreSnapshot } from "./store";
|
|
||||||
|
|
||||||
import { Scene } from "./Scene";
|
import { Scene } from "./Scene";
|
||||||
|
|
||||||
|
import { StoreSnapshot } from "./store";
|
||||||
|
|
||||||
import type { BindableProp, BindingProp } from "./binding";
|
import type { BindableProp, BindingProp } from "./binding";
|
||||||
|
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
import type { ElementUpdate } from "./mutateElement";
|
||||||
@@ -150,27 +151,13 @@ 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.
|
* Merges deleted and inserted object partials.
|
||||||
*/
|
*/
|
||||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||||
prev: T,
|
prev: T,
|
||||||
added: T,
|
added: T,
|
||||||
removed: T = {} as T,
|
removed: T,
|
||||||
) {
|
) {
|
||||||
const cloned = { ...prev };
|
const cloned = { ...prev };
|
||||||
|
|
||||||
@@ -510,11 +497,6 @@ export interface DeltaContainer<T> {
|
|||||||
*/
|
*/
|
||||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
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.
|
* Checks whether all `Delta`s are empty.
|
||||||
*/
|
*/
|
||||||
@@ -522,11 +504,7 @@ export interface DeltaContainer<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||||
|
|
||||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
|
||||||
return new AppStateDelta(delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static calculate<T extends ObservedAppState>(
|
public static calculate<T extends ObservedAppState>(
|
||||||
prevAppState: T,
|
prevAppState: T,
|
||||||
@@ -557,137 +535,76 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return new AppStateDelta(inversedDelta);
|
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(
|
public applyTo(
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
): [AppState, boolean] {
|
): [AppState, boolean] {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
selectedElementIds: deletedSelectedElementIds = {},
|
selectedElementIds: removedSelectedElementIds = {},
|
||||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
selectedGroupIds: removedSelectedGroupIds = {},
|
||||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
|
||||||
} = this.delta.deleted;
|
} = this.delta.deleted;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedElementIds: insertedSelectedElementIds = {},
|
selectedElementIds: addedSelectedElementIds = {},
|
||||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
selectedGroupIds: addedSelectedGroupIds = {},
|
||||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
selectedLinearElementId,
|
||||||
selectedLinearElement: insertedSelectedLinearElement,
|
selectedLinearElementIsEditing,
|
||||||
...directlyApplicablePartial
|
...directlyApplicablePartial
|
||||||
} = this.delta.inserted;
|
} = this.delta.inserted;
|
||||||
|
|
||||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||||
appState.selectedElementIds,
|
appState.selectedElementIds,
|
||||||
insertedSelectedElementIds,
|
addedSelectedElementIds,
|
||||||
deletedSelectedElementIds,
|
removedSelectedElementIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||||
appState.selectedGroupIds,
|
appState.selectedGroupIds,
|
||||||
insertedSelectedGroupIds,
|
addedSelectedGroupIds,
|
||||||
deletedSelectedGroupIds,
|
removedSelectedGroupIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
let selectedLinearElement = appState.selectedLinearElement;
|
||||||
appState.lockedMultiSelections,
|
|
||||||
insertedLockedMultiSelections,
|
|
||||||
deletedLockedMultiSelections,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedLinearElement =
|
if (selectedLinearElementId === null) {
|
||||||
insertedSelectedLinearElement &&
|
// Unselect linear element (visible change)
|
||||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
selectedLinearElement = null;
|
||||||
? new LinearElementEditor(
|
} else if (
|
||||||
nextElements.get(
|
selectedLinearElementId &&
|
||||||
insertedSelectedLinearElement.elementId,
|
nextElements.has(selectedLinearElementId)
|
||||||
) as NonDeleted<ExcalidrawLinearElement>,
|
) {
|
||||||
nextElements,
|
selectedLinearElement = new LinearElementEditor(
|
||||||
insertedSelectedLinearElement.isEditing,
|
nextElements.get(
|
||||||
)
|
selectedLinearElementId,
|
||||||
: null;
|
) 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 nextAppState = {
|
const nextAppState = {
|
||||||
...appState,
|
...appState,
|
||||||
...directlyApplicablePartial,
|
...directlyApplicablePartial,
|
||||||
selectedElementIds: mergedSelectedElementIds,
|
selectedElementIds: mergedSelectedElementIds,
|
||||||
selectedGroupIds: mergedSelectedGroupIds,
|
selectedGroupIds: mergedSelectedGroupIds,
|
||||||
lockedMultiSelections: mergedLockedMultiSelections,
|
selectedLinearElement,
|
||||||
selectedLinearElement:
|
|
||||||
typeof insertedSelectedLinearElement !== "undefined"
|
|
||||||
? selectedLinearElement
|
|
||||||
: appState.selectedLinearElement,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||||
@@ -816,53 +733,64 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "selectedLinearElement":
|
case "selectedLinearElementId": {
|
||||||
const nextLinearElement = nextAppState[key];
|
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||||
|
const linearElement = nextAppState[appStateKey];
|
||||||
|
|
||||||
if (!nextLinearElement) {
|
if (!linearElement) {
|
||||||
// previously there was a linear element (assuming visible), now there is none
|
// previously there was a linear element (assuming visible), now there is none
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
const element = nextElements.get(nextLinearElement.elementId);
|
const element = nextElements.get(linearElement.elementId);
|
||||||
|
|
||||||
if (element && !element.isDeleted) {
|
if (element && !element.isDeleted) {
|
||||||
// previously there wasn't a linear element, now there is one which is visible
|
// previously there wasn't a linear element, now there is one which is visible
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
} else {
|
} else {
|
||||||
// there was assigned a linear element now, but it's deleted
|
// there was assigned a linear element now, but it's deleted
|
||||||
nextAppState[key] = null;
|
nextAppState[appStateKey] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "lockedMultiSelections":
|
}
|
||||||
|
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": {
|
||||||
const prevLockedUnits = prevAppState[key] || {};
|
const prevLockedUnits = prevAppState[key] || {};
|
||||||
const nextLockedUnits = nextAppState[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)) {
|
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "activeLockedId":
|
}
|
||||||
|
case "activeLockedId": {
|
||||||
const prevHitLockedId = prevAppState[key] || null;
|
const prevHitLockedId = prevAppState[key] || null;
|
||||||
const nextHitLockedId = nextAppState[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) {
|
if (prevHitLockedId !== nextHitLockedId) {
|
||||||
visibleDifferenceFlag.value = true;
|
visibleDifferenceFlag.value = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
}
|
||||||
|
default: {
|
||||||
assertNever(
|
assertNever(
|
||||||
key,
|
key,
|
||||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,6 +798,15 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
return visibleDifferenceFlag.value;
|
return visibleDifferenceFlag.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static convertToAppStateKey(
|
||||||
|
key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
|
||||||
|
): keyof Pick<AppState, "selectedLinearElement"> {
|
||||||
|
switch (key) {
|
||||||
|
case "selectedLinearElementId":
|
||||||
|
return "selectedLinearElement";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static filterSelectedElements(
|
private static filterSelectedElements(
|
||||||
selectedElementIds: AppState["selectedElementIds"],
|
selectedElementIds: AppState["selectedElementIds"],
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
@@ -934,7 +871,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
editingGroupId,
|
editingGroupId,
|
||||||
selectedGroupIds,
|
selectedGroupIds,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
selectedLinearElement,
|
selectedLinearElementId,
|
||||||
|
selectedLinearElementIsEditing,
|
||||||
croppingElementId,
|
croppingElementId,
|
||||||
lockedMultiSelections,
|
lockedMultiSelections,
|
||||||
activeLockedId,
|
activeLockedId,
|
||||||
@@ -988,6 +926,12 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
|||||||
"lockedMultiSelections",
|
"lockedMultiSelections",
|
||||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||||
);
|
);
|
||||||
|
Delta.diffObjects(
|
||||||
|
deleted,
|
||||||
|
inserted,
|
||||||
|
"activeLockedId",
|
||||||
|
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
// 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.`);
|
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||||
@@ -1016,13 +960,12 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
|||||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||||
|
|
||||||
export type ApplyToOptions = {
|
export type ApplyToOptions = {
|
||||||
excludedProperties?: Set<keyof ElementPartial>;
|
excludedProperties: Set<keyof ElementPartial>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ApplyToFlags = {
|
type ApplyToFlags = {
|
||||||
containsVisibleDifference: boolean;
|
containsVisibleDifference: boolean;
|
||||||
containsZindexDifference: boolean;
|
containsZindexDifference: boolean;
|
||||||
applyDirection: "forward" | "backward" | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1111,27 +1054,18 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
inserted,
|
inserted,
|
||||||
}: Delta<ElementPartial>) =>
|
}: Delta<ElementPartial>) =>
|
||||||
!!(
|
!!(
|
||||||
|
deleted.version &&
|
||||||
|
inserted.version &&
|
||||||
// versions are required integers
|
// versions are required integers
|
||||||
(
|
Number.isInteger(deleted.version) &&
|
||||||
Number.isInteger(deleted.version) &&
|
Number.isInteger(inserted.version) &&
|
||||||
Number.isInteger(inserted.version) &&
|
// versions should be positive, zero included
|
||||||
// versions should be positive, zero included
|
deleted.version >= 0 &&
|
||||||
deleted.version! >= 0 &&
|
inserted.version >= 0 &&
|
||||||
inserted.version! >= 0 &&
|
// versions should never be the same
|
||||||
// versions should never be the same
|
deleted.version !== inserted.version
|
||||||
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(
|
private static validate(
|
||||||
elementsDelta: ElementsDelta,
|
elementsDelta: ElementsDelta,
|
||||||
type: "added" | "removed" | "updated",
|
type: "added" | "removed" | "updated",
|
||||||
@@ -1140,7 +1074,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||||
if (
|
if (
|
||||||
!this.satisfiesCommmonInvariants(delta) ||
|
!this.satisfiesCommmonInvariants(delta) ||
|
||||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
|
||||||
!satifiesSpecialInvariants(delta)
|
!satifiesSpecialInvariants(delta)
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1177,7 +1110,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const nextElement = nextElements.get(prevElement.id);
|
const nextElement = nextElements.get(prevElement.id);
|
||||||
|
|
||||||
if (!nextElement) {
|
if (!nextElement) {
|
||||||
const deleted = { ...prevElement } as ElementPartial;
|
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
isDeleted: true,
|
isDeleted: true,
|
||||||
@@ -1191,11 +1124,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!prevElement.isDeleted) {
|
removed[prevElement.id] = delta;
|
||||||
removed[prevElement.id] = delta;
|
|
||||||
} else {
|
|
||||||
updated[prevElement.id] = delta;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
|
|
||||||
const inserted = {
|
const inserted = {
|
||||||
...nextElement,
|
...nextElement,
|
||||||
|
isDeleted: false,
|
||||||
} as ElementPartial;
|
} as ElementPartial;
|
||||||
|
|
||||||
const delta = Delta.create(
|
const delta = Delta.create(
|
||||||
@@ -1219,12 +1149,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
ElementsDelta.stripIrrelevantProps,
|
ElementsDelta.stripIrrelevantProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ignore updates which would "delete" already deleted element
|
added[nextElement.id] = delta;
|
||||||
if (!nextElement.isDeleted) {
|
|
||||||
added[nextElement.id] = delta;
|
|
||||||
} else {
|
|
||||||
updated[nextElement.id] = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1253,7 +1178,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
updated[nextElement.id] = delta;
|
// making sure there are at least some changes
|
||||||
|
if (!Delta.isEmpty(delta)) {
|
||||||
|
updated[nextElement.id] = delta;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,8 +1196,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||||
|
|
||||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
for (const [id, delta] of Object.entries(deltas)) {
|
||||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inversedDeltas;
|
return inversedDeltas;
|
||||||
@@ -1388,7 +1316,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
public applyTo(
|
public applyTo(
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||||
options?: ApplyToOptions,
|
options: ApplyToOptions = {
|
||||||
|
excludedProperties: new Set(),
|
||||||
|
},
|
||||||
): [SceneElementsMap, boolean] {
|
): [SceneElementsMap, boolean] {
|
||||||
let nextElements = new Map(elements) as SceneElementsMap;
|
let nextElements = new Map(elements) as SceneElementsMap;
|
||||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||||
@@ -1396,28 +1326,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const flags: ApplyToFlags = {
|
const flags: ApplyToFlags = {
|
||||||
containsVisibleDifference: false,
|
containsVisibleDifference: false,
|
||||||
containsZindexDifference: false,
|
containsZindexDifference: false,
|
||||||
applyDirection: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||||
try {
|
try {
|
||||||
const applyDeltas = ElementsDelta.createApplier(
|
const applyDeltas = ElementsDelta.createApplier(
|
||||||
elements,
|
|
||||||
nextElements,
|
nextElements,
|
||||||
snapshot,
|
snapshot,
|
||||||
flags,
|
|
||||||
options,
|
options,
|
||||||
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addedElements = applyDeltas(this.added);
|
const addedElements = applyDeltas(this.added);
|
||||||
const removedElements = applyDeltas(this.removed);
|
const removedElements = applyDeltas(this.removed);
|
||||||
const updatedElements = applyDeltas(this.updated);
|
const updatedElements = applyDeltas(this.updated);
|
||||||
|
|
||||||
const affectedElements = this.resolveConflicts(
|
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||||
elements,
|
|
||||||
nextElements,
|
|
||||||
flags.applyDirection,
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||||
changedElements = new Map([
|
changedElements = new Map([
|
||||||
@@ -1441,15 +1365,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
// 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
|
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||||
nextElements = ElementsDelta.reorderElements(
|
nextElements = ElementsDelta.reorderElements(
|
||||||
nextElements,
|
nextElements,
|
||||||
changedElements,
|
changedElements,
|
||||||
flags,
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
// 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);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`Couldn't mutate elements after applying elements change`,
|
`Couldn't mutate elements after applying elements change`,
|
||||||
@@ -1464,113 +1395,12 @@ 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 =
|
private static createApplier =
|
||||||
(
|
(
|
||||||
prevElements: SceneElementsMap,
|
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
snapshot: StoreSnapshot["elements"],
|
snapshot: StoreSnapshot["elements"],
|
||||||
|
options: ApplyToOptions,
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
options?: ApplyToOptions,
|
|
||||||
) =>
|
) =>
|
||||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||||
const getElement = ElementsDelta.createGetter(
|
const getElement = ElementsDelta.createGetter(
|
||||||
@@ -1583,26 +1413,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
const element = getElement(id, delta.inserted);
|
const element = getElement(id, delta.inserted);
|
||||||
|
|
||||||
if (element) {
|
if (element) {
|
||||||
const nextElement = ElementsDelta.applyDelta(
|
const newElement = ElementsDelta.applyDelta(
|
||||||
element,
|
element,
|
||||||
delta,
|
delta,
|
||||||
flags,
|
|
||||||
options,
|
options,
|
||||||
|
flags,
|
||||||
);
|
);
|
||||||
|
|
||||||
nextElements.set(nextElement.id, nextElement);
|
nextElements.set(newElement.id, newElement);
|
||||||
acc.set(nextElement.id, nextElement);
|
acc.set(newElement.id, newElement);
|
||||||
|
|
||||||
if (!flags.applyDirection) {
|
|
||||||
const prevElement = prevElements.get(id);
|
|
||||||
|
|
||||||
if (prevElement) {
|
|
||||||
flags.applyDirection =
|
|
||||||
prevElement.version > nextElement.version
|
|
||||||
? "backward"
|
|
||||||
: "forward";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@@ -1647,8 +1466,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private static applyDelta(
|
private static applyDelta(
|
||||||
element: OrderedExcalidrawElement,
|
element: OrderedExcalidrawElement,
|
||||||
delta: Delta<ElementPartial>,
|
delta: Delta<ElementPartial>,
|
||||||
|
options: ApplyToOptions,
|
||||||
flags: ApplyToFlags,
|
flags: ApplyToFlags,
|
||||||
options?: ApplyToOptions,
|
|
||||||
) {
|
) {
|
||||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||||
|
|
||||||
@@ -1662,7 +1481,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.excludedProperties?.has(key)) {
|
if (options.excludedProperties.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,7 +1521,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
delta.deleted.index !== delta.inserted.index;
|
delta.deleted.index !== delta.inserted.index;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElementWith(element, directlyApplicablePartial, true);
|
return newElementWith(element, directlyApplicablePartial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1742,7 +1561,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
private resolveConflicts(
|
private resolveConflicts(
|
||||||
prevElements: SceneElementsMap,
|
prevElements: SceneElementsMap,
|
||||||
nextElements: SceneElementsMap,
|
nextElements: SceneElementsMap,
|
||||||
applyDirection: "forward" | "backward" = "forward",
|
|
||||||
) {
|
) {
|
||||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||||
const updater = (
|
const updater = (
|
||||||
@@ -1754,36 +1572,21 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
return;
|
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;
|
let affectedElement: OrderedExcalidrawElement;
|
||||||
|
|
||||||
if (prevElement === nextElement) {
|
if (prevElements.get(element.id) === nextElement) {
|
||||||
// create the new element instance in case we didn't modify the element yet
|
// 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
|
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||||
affectedElement = newElementWith(
|
affectedElement = newElementWith(
|
||||||
nextElement,
|
nextElement,
|
||||||
{
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
...elementUpdates,
|
|
||||||
version: nextVersion,
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
affectedElement = mutateElement(nextElement, nextElements, {
|
affectedElement = mutateElement(
|
||||||
...elementUpdates,
|
nextElement,
|
||||||
// don't modify the version further, if it's already different
|
nextElements,
|
||||||
version:
|
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||||
prevElement?.version !== nextElement.version
|
);
|
||||||
? nextElement.version
|
|
||||||
: nextVersion,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||||
@@ -1821,12 +1624,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||||
this.squash(
|
// technically we could do better here if perf. would become an issue
|
||||||
// technically we could do better here if perf. would become an issue
|
const { added, removed, updated } = ElementsDelta.calculate(
|
||||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
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;
|
return nextAffectedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1888,31 +1704,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
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(
|
private static redrawTextBoundingBoxes(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
changed: Map<string, OrderedExcalidrawElement>,
|
changed: Map<string, OrderedExcalidrawElement>,
|
||||||
@@ -1967,7 +1758,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
|||||||
) {
|
) {
|
||||||
for (const element of changed.values()) {
|
for (const element of changed.values()) {
|
||||||
if (!element.isDeleted && isBindableElement(element)) {
|
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, {
|
updateBoundElements(element, scene, {
|
||||||
changedElements: changed,
|
changedElements: changed,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -359,12 +359,6 @@ const handleSegmentRelease = (
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!restoredPoints || restoredPoints.length < 2) {
|
|
||||||
throw new Error(
|
|
||||||
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPoints: GlobalPoint[] = [];
|
const nextPoints: GlobalPoint[] = [];
|
||||||
|
|
||||||
// First part of the arrow are the old points
|
// First part of the arrow are the old points
|
||||||
@@ -712,7 +706,7 @@ const handleEndpointDrag = (
|
|||||||
endGlobalPoint: GlobalPoint,
|
endGlobalPoint: GlobalPoint,
|
||||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
) => {
|
||||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||||
@@ -747,15 +741,8 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second point connection and add the start point
|
// Calculate the moving second point connection and add the start point
|
||||||
{
|
{
|
||||||
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||||
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
const thirdPoint = globalUpdatedPoints[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 startIsHorizontal = headingIsHorizontal(startHeading);
|
||||||
const secondIsHorizontal = headingIsHorizontal(
|
const secondIsHorizontal = headingIsHorizontal(
|
||||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||||
@@ -814,19 +801,10 @@ const handleEndpointDrag = (
|
|||||||
|
|
||||||
// Calculate the moving second to last point connection
|
// Calculate the moving second to last point connection
|
||||||
{
|
{
|
||||||
const secondToLastPoint = globalUpdatedPoints.at(
|
const secondToLastPoint =
|
||||||
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||||
);
|
const thirdToLastPoint =
|
||||||
const thirdToLastPoint = globalUpdatedPoints.at(
|
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
||||||
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 endIsHorizontal = headingIsHorizontal(endHeading);
|
||||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||||
thirdToLastPoint,
|
thirdToLastPoint,
|
||||||
@@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
|
|||||||
nextFixedSegments: readonly FixedSegment[] | null,
|
nextFixedSegments: readonly FixedSegment[] | null,
|
||||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
): {
|
||||||
|
points: LocalPoint[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fixedSegments: readonly FixedSegment[] | null;
|
||||||
|
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
|
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||||
|
} => {
|
||||||
const offsetX = global[0][0];
|
const offsetX = global[0][0];
|
||||||
const offsetY = global[0][1];
|
const offsetY = global[0][1];
|
||||||
let points = global.map((p) =>
|
let points = global.map((p) =>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
getContainerElement,
|
getContainerElement,
|
||||||
handleBindTextResize,
|
handleBindTextResize,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
computeBoundTextPosition,
|
|
||||||
} from "./textElement";
|
} from "./textElement";
|
||||||
import {
|
import {
|
||||||
getMinTextElementWidth,
|
getMinTextElementWidth,
|
||||||
@@ -226,16 +225,7 @@ const rotateSingleElement = (
|
|||||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||||
|
|
||||||
if (textElement && !isArrowElement(element)) {
|
if (textElement && !isArrowElement(element)) {
|
||||||
const { x, y } = computeBoundTextPosition(
|
scene.mutateElement(textElement, { angle });
|
||||||
element,
|
|
||||||
textElement,
|
|
||||||
scene.getNonDeletedElementsMap(),
|
|
||||||
);
|
|
||||||
scene.mutateElement(textElement, {
|
|
||||||
angle,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -426,15 +416,9 @@ const rotateMultipleElements = (
|
|||||||
|
|
||||||
const boundText = getBoundTextElement(element, elementsMap);
|
const boundText = getBoundTextElement(element, elementsMap);
|
||||||
if (boundText && !isArrowElement(element)) {
|
if (boundText && !isArrowElement(element)) {
|
||||||
const { x, y } = computeBoundTextPosition(
|
|
||||||
element,
|
|
||||||
boundText,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
|
|
||||||
scene.mutateElement(boundText, {
|
scene.mutateElement(boundText, {
|
||||||
x,
|
x: boundText.x + (rotatedCX - cx),
|
||||||
y,
|
y: boundText.y + (rotatedCY - cy),
|
||||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,8 @@ type MicroActionsQueue = (() => void)[];
|
|||||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||||
*/
|
*/
|
||||||
export class Store {
|
export class Store {
|
||||||
// for internal use by history
|
// internally used by history
|
||||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||||
// for public use as part of onIncrement API
|
|
||||||
public readonly onStoreIncrementEmitter = new Emitter<
|
public readonly onStoreIncrementEmitter = new Emitter<
|
||||||
[DurableIncrement | EphemeralIncrement]
|
[DurableIncrement | EphemeralIncrement]
|
||||||
>();
|
>();
|
||||||
@@ -240,6 +239,7 @@ export class Store {
|
|||||||
if (!storeDelta.isEmpty()) {
|
if (!storeDelta.isEmpty()) {
|
||||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||||
|
|
||||||
|
// Notify listeners with the increment
|
||||||
this.onDurableIncrementEmitter.trigger(increment);
|
this.onDurableIncrementEmitter.trigger(increment);
|
||||||
this.onStoreIncrementEmitter.trigger(increment);
|
this.onStoreIncrementEmitter.trigger(increment);
|
||||||
}
|
}
|
||||||
@@ -552,26 +552,10 @@ export class StoreDelta {
|
|||||||
public static load({
|
public static load({
|
||||||
id,
|
id,
|
||||||
elements: { added, removed, updated },
|
elements: { added, removed, updated },
|
||||||
appState: { delta: appStateDelta },
|
|
||||||
}: DTO<StoreDelta>) {
|
}: DTO<StoreDelta>) {
|
||||||
const elements = ElementsDelta.create(added, removed, updated);
|
const elements = ElementsDelta.create(added, removed, updated);
|
||||||
const appState = AppStateDelta.create(appStateDelta);
|
|
||||||
|
|
||||||
return new this(id, elements, appState);
|
return new this(id, elements, AppStateDelta.empty());
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -588,7 +572,9 @@ export class StoreDelta {
|
|||||||
delta: StoreDelta,
|
delta: StoreDelta,
|
||||||
elements: SceneElementsMap,
|
elements: SceneElementsMap,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
options?: ApplyToOptions,
|
options: ApplyToOptions = {
|
||||||
|
excludedProperties: new Set(),
|
||||||
|
},
|
||||||
): [SceneElementsMap, AppState, boolean] {
|
): [SceneElementsMap, AppState, boolean] {
|
||||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||||
elements,
|
elements,
|
||||||
@@ -627,10 +613,6 @@ export class StoreDelta {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static empty() {
|
|
||||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
public isEmpty() {
|
public isEmpty() {
|
||||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||||
}
|
}
|
||||||
@@ -996,7 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
|||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
|
selectedLinearElementIsEditing: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
@@ -1015,12 +998,14 @@ export const getObservedAppState = (
|
|||||||
croppingElementId: appState.croppingElementId,
|
croppingElementId: appState.croppingElementId,
|
||||||
activeLockedId: appState.activeLockedId,
|
activeLockedId: appState.activeLockedId,
|
||||||
lockedMultiSelections: appState.lockedMultiSelections,
|
lockedMultiSelections: appState.lockedMultiSelections,
|
||||||
selectedLinearElement: appState.selectedLinearElement
|
selectedLinearElementId:
|
||||||
? {
|
(appState as AppState).selectedLinearElement?.elementId ??
|
||||||
elementId: appState.selectedLinearElement.elementId,
|
(appState as ObservedAppState).selectedLinearElementId ??
|
||||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
null,
|
||||||
}
|
selectedLinearElementIsEditing:
|
||||||
: null,
|
(appState as AppState).selectedLinearElement?.isEditing ??
|
||||||
|
(appState as ObservedAppState).selectedLinearElementIsEditing ??
|
||||||
|
null,
|
||||||
};
|
};
|
||||||
|
|
||||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import {
|
|||||||
invariant,
|
invariant,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
resetOriginalContainerCache,
|
resetOriginalContainerCache,
|
||||||
updateOriginalContainerCache,
|
updateOriginalContainerCache,
|
||||||
@@ -254,26 +254,6 @@ export const computeBoundTextPosition = (
|
|||||||
x =
|
x =
|
||||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
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 };
|
return { x, y };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,345 +1,13 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
|
|
||||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { LinearElementEditor } from "@excalidraw/element";
|
import type { LinearElementEditor } from "@excalidraw/element";
|
||||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
|
||||||
|
|
||||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
import { AppStateDelta } 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("AppStateDelta", () => {
|
||||||
describe("ensure stable delta properties order", () => {
|
describe("ensure stable delta properties order", () => {
|
||||||
it("should maintain stable order for root properties", () => {
|
it("should maintain stable order for root properties", () => {
|
||||||
const name = "untitled scene";
|
const name = "untitled scene";
|
||||||
const selectedLinearElement = {
|
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||||
elementId: "id1" as LinearElementEditor["elementId"],
|
|
||||||
isEditing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const commonAppState = {
|
const commonAppState = {
|
||||||
viewBackgroundColor: "#ffffff",
|
viewBackgroundColor: "#ffffff",
|
||||||
@@ -356,23 +24,23 @@ describe("AppStateDelta", () => {
|
|||||||
const prevAppState1: ObservedAppState = {
|
const prevAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name: "",
|
name: "",
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState1: ObservedAppState = {
|
const nextAppState1: ObservedAppState = {
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
name,
|
name,
|
||||||
selectedLinearElement,
|
selectedLinearElementId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevAppState2: ObservedAppState = {
|
const prevAppState2: ObservedAppState = {
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
name: "",
|
name: "",
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextAppState2: ObservedAppState = {
|
const nextAppState2: ObservedAppState = {
|
||||||
selectedLinearElement,
|
selectedLinearElementId,
|
||||||
name,
|
name,
|
||||||
...commonAppState,
|
...commonAppState,
|
||||||
};
|
};
|
||||||
@@ -390,7 +58,9 @@ describe("AppStateDelta", () => {
|
|||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
|
selectedLinearElementIsEditing: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@@ -436,7 +106,9 @@ describe("AppStateDelta", () => {
|
|||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
editingGroupId: null,
|
editingGroupId: null,
|
||||||
croppingElementId: null,
|
croppingElementId: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElementId: null,
|
||||||
|
selectedLinearElementIsEditing: null,
|
||||||
|
editingLinearElementId: null,
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
};
|
};
|
||||||
@@ -477,97 +149,4 @@ describe("AppStateDelta", () => {
|
|||||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
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 },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { getLineHeight } from "@excalidraw/common";
|
import { getLineHeight } from "@excalidraw/common";
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
import { FONT_FAMILY } from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeContainerDimensionForBoundText,
|
computeContainerDimensionForBoundText,
|
||||||
getContainerCoords,
|
getContainerCoords,
|
||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
getBoundTextMaxHeight,
|
getBoundTextMaxHeight,
|
||||||
computeBoundTextPosition,
|
|
||||||
} from "../src/textElement";
|
} from "../src/textElement";
|
||||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||||
|
|
||||||
@@ -208,172 +207,3 @@ describe("Test getDefaultLineHeight", () => {
|
|||||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
|
|||||||
pasteDialog: appState.pasteDialog,
|
pasteDialog: appState.pasteDialog,
|
||||||
activeTool:
|
activeTool:
|
||||||
appState.activeTool.type === "image"
|
appState.activeTool.type === "image"
|
||||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
? { ...appState.activeTool, type: "selection" }
|
||||||
: appState.activeTool,
|
: appState.activeTool,
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
|
|||||||
name: "toggleEraserTool",
|
name: "toggleEraserTool",
|
||||||
label: "toolBar.eraser",
|
label: "toolBar.eraser",
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
if (isEraserActive(appState)) {
|
if (isEraserActive(appState)) {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: app.defaultSelectionTool,
|
type: "selection",
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
@@ -530,9 +530,6 @@ export const actionToggleLassoTool = register({
|
|||||||
label: "toolBar.lasso",
|
label: "toolBar.lasso",
|
||||||
icon: LassoIcon,
|
icon: LassoIcon,
|
||||||
trackEvent: { category: "toolbar" },
|
trackEvent: { category: "toolbar" },
|
||||||
predicate: (elements, appState, props, app) => {
|
|
||||||
return app.defaultSelectionTool !== "lasso";
|
|
||||||
},
|
|
||||||
perform: (elements, appState, _, app) => {
|
perform: (elements, appState, _, app) => {
|
||||||
let activeTool: AppState["activeTool"];
|
let activeTool: AppState["activeTool"];
|
||||||
|
|
||||||
|
|||||||
@@ -298,9 +298,7 @@ export const actionDeleteSelected = register({
|
|||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
appState: {
|
appState: {
|
||||||
...nextAppState,
|
...nextAppState,
|
||||||
activeTool: updateActiveTool(appState, {
|
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||||
type: app.defaultSelectionTool,
|
|
||||||
}),
|
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
activeEmbeddable: null,
|
activeEmbeddable: null,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import {
|
|||||||
bindOrUnbindLinearElement,
|
bindOrUnbindLinearElement,
|
||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "@excalidraw/element/binding";
|
} from "@excalidraw/element/binding";
|
||||||
import {
|
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||||
isValidPolygon,
|
|
||||||
LinearElementEditor,
|
|
||||||
newElementWith,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isBindingElement,
|
isBindingElement,
|
||||||
@@ -82,14 +78,7 @@ export const actionFinalize = register({
|
|||||||
let newElements = elements;
|
let newElements = elements;
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
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
|
// 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.map((el) => {
|
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||||
if (el.id === element.id) {
|
|
||||||
return newElementWith(el, {
|
|
||||||
isDeleted: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
elements: newElements,
|
elements: newElements,
|
||||||
@@ -128,12 +117,7 @@ export const actionFinalize = register({
|
|||||||
return {
|
return {
|
||||||
elements:
|
elements:
|
||||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||||
? elements.map((el) => {
|
? elements.filter((el) => el.id !== element.id)
|
||||||
if (el.id === element.id) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
})
|
|
||||||
: undefined,
|
: undefined,
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
@@ -188,12 +172,7 @@ export const actionFinalize = register({
|
|||||||
|
|
||||||
if (element && isInvisiblySmallElement(element)) {
|
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
|
// 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.map((el) => {
|
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||||
if (el.id === element?.id) {
|
|
||||||
return newElementWith(el, { isDeleted: true });
|
|
||||||
}
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
@@ -261,13 +240,13 @@ export const actionFinalize = register({
|
|||||||
if (appState.activeTool.type === "eraser") {
|
if (appState.activeTool.type === "eraser") {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
...(appState.activeTool.lastActiveTool || {
|
...(appState.activeTool.lastActiveTool || {
|
||||||
type: app.defaultSelectionTool,
|
type: "selection",
|
||||||
}),
|
}),
|
||||||
lastActiveToolBeforeEraser: null,
|
lastActiveToolBeforeEraser: null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
activeTool = updateActiveTool(appState, {
|
activeTool = updateActiveTool(appState, {
|
||||||
type: app.defaultSelectionTool,
|
type: "selection",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ export type ShortcutName =
|
|||||||
| "saveScene"
|
| "saveScene"
|
||||||
| "imageExport"
|
| "imageExport"
|
||||||
| "commandPalette"
|
| "commandPalette"
|
||||||
| "searchMenu"
|
| "searchMenu";
|
||||||
| "toolLock";
|
|
||||||
|
|
||||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||||
@@ -117,7 +116,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
|||||||
toggleShortcuts: [getShortcutKey("?")],
|
toggleShortcuts: [getShortcutKey("?")],
|
||||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||||
wrapSelectionInFrame: [],
|
wrapSelectionInFrame: [],
|
||||||
toolLock: [getShortcutKey("Q")],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
hasStrokeWidth,
|
hasStrokeWidth,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
import { getToolbarTools } from "./shapes";
|
import { SHAPES } from "./shapes";
|
||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
|
|
||||||
@@ -295,8 +295,7 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
const frameToolSelected = activeTool.type === "frame";
|
const frameToolSelected = activeTool.type === "frame";
|
||||||
const laserToolSelected = activeTool.type === "laser";
|
const laserToolSelected = activeTool.type === "laser";
|
||||||
const lassoToolSelected =
|
const lassoToolSelected = activeTool.type === "lasso";
|
||||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
|
||||||
|
|
||||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||||
|
|
||||||
@@ -304,68 +303,63 @@ export const ShapesSwitcher = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{getToolbarTools(app).map(
|
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
({ value, icon, key, numericKey, fillable }, index) => {
|
if (
|
||||||
if (
|
UIOptions.tools?.[
|
||||||
UIOptions.tools?.[
|
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||||
value as Extract<
|
] === false
|
||||||
typeof value,
|
) {
|
||||||
keyof AppProps["UIOptions"]["tools"]
|
return null;
|
||||||
>
|
}
|
||||||
] === false
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = t(`toolBar.${value}`);
|
const label = t(`toolBar.${value}`);
|
||||||
const letter =
|
const letter =
|
||||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||||
const shortcut = letter
|
const shortcut = letter
|
||||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||||
: `${numericKey}`;
|
: `${numericKey}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
className={clsx("Shape", { fillable })}
|
className={clsx("Shape", { fillable })}
|
||||||
key={value}
|
key={value}
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={icon}
|
||||||
checked={activeTool.type === value}
|
checked={activeTool.type === value}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||||
keyBindingLabel={numericKey || letter}
|
keyBindingLabel={numericKey || letter}
|
||||||
aria-label={capitalizeString(label)}
|
aria-label={capitalizeString(label)}
|
||||||
aria-keyshortcuts={shortcut}
|
aria-keyshortcuts={shortcut}
|
||||||
data-testid={`toolbar-${value}`}
|
data-testid={`toolbar-${value}`}
|
||||||
onPointerDown={({ pointerType }) => {
|
onPointerDown={({ pointerType }) => {
|
||||||
if (!appState.penDetected && pointerType === "pen") {
|
if (!appState.penDetected && pointerType === "pen") {
|
||||||
app.togglePenMode(true);
|
app.togglePenMode(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "selection") {
|
if (value === "selection") {
|
||||||
if (appState.activeTool.type === "selection") {
|
if (appState.activeTool.type === "selection") {
|
||||||
app.setActiveTool({ type: "lasso" });
|
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 {
|
} else {
|
||||||
app.setActiveTool({ type: value });
|
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" />
|
<div className="App-toolbar__divider" />
|
||||||
|
|
||||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||||
@@ -397,7 +391,6 @@ export const ShapesSwitcher = ({
|
|||||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||||
className="App-toolbar__extra-tools-dropdown"
|
className="App-toolbar__extra-tools-dropdown"
|
||||||
align="end"
|
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.setActiveTool({ type: "frame" })}
|
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||||
@@ -425,16 +418,14 @@ export const ShapesSwitcher = ({
|
|||||||
>
|
>
|
||||||
{t("toolBar.laser")}
|
{t("toolBar.laser")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{app.defaultSelectionTool !== "lasso" && (
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.Item
|
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
icon={LassoIcon}
|
||||||
icon={LassoIcon}
|
data-testid="toolbar-lasso"
|
||||||
data-testid="toolbar-lasso"
|
selected={lassoToolSelected}
|
||||||
selected={lassoToolSelected}
|
>
|
||||||
>
|
{t("toolBar.lasso")}
|
||||||
{t("toolBar.lasso")}
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Item>
|
|
||||||
)}
|
|
||||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||||
Generate
|
Generate
|
||||||
</div>
|
</div>
|
||||||
@@ -451,10 +442,10 @@ export const ShapesSwitcher = ({
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => app.onMagicframeToolSelect()}
|
onSelect={() => app.onMagicframeToolSelect()}
|
||||||
icon={MagicIcon}
|
icon={MagicIcon}
|
||||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
|
||||||
data-testid="toolbar-magicframe"
|
data-testid="toolbar-magicframe"
|
||||||
>
|
>
|
||||||
{t("toolBar.magicframe")}
|
{t("toolBar.magicframe")}
|
||||||
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ import {
|
|||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
Emitter,
|
Emitter,
|
||||||
isMobile,
|
|
||||||
MINIMUM_ARROW_SIZE,
|
MINIMUM_ARROW_SIZE,
|
||||||
DOUBLE_TAP_POSITION_THRESHOLD,
|
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -235,8 +233,6 @@ import {
|
|||||||
hitElementBoundingBox,
|
hitElementBoundingBox,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
isSimpleArrow,
|
isSimpleArrow,
|
||||||
StoreDelta,
|
|
||||||
type ApplyToOptions,
|
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
@@ -263,7 +259,6 @@ import type {
|
|||||||
MagicGenerationData,
|
MagicGenerationData,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
SceneElementsMap,
|
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
|
||||||
@@ -532,7 +527,6 @@ export const useExcalidrawActionManager = () =>
|
|||||||
|
|
||||||
let didTapTwice: boolean = false;
|
let didTapTwice: boolean = false;
|
||||||
let tappedTwiceTimer = 0;
|
let tappedTwiceTimer = 0;
|
||||||
let firstTapPosition: { x: number; y: number } | null = null;
|
|
||||||
let isHoldingSpace: boolean = false;
|
let isHoldingSpace: boolean = false;
|
||||||
let isPanning: boolean = false;
|
let isPanning: boolean = false;
|
||||||
let isDraggingScrollBar: boolean = false;
|
let isDraggingScrollBar: boolean = false;
|
||||||
@@ -656,14 +650,9 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
>();
|
>();
|
||||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||||
|
|
||||||
defaultSelectionTool: "selection" | "lasso" = "selection";
|
|
||||||
|
|
||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
this.defaultSelectionTool = this.isMobileOrTablet()
|
|
||||||
? ("lasso" as const)
|
|
||||||
: ("selection" as const);
|
|
||||||
const {
|
const {
|
||||||
excalidrawAPI,
|
excalidrawAPI,
|
||||||
viewModeEnabled = false,
|
viewModeEnabled = false,
|
||||||
@@ -708,7 +697,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (excalidrawAPI) {
|
if (excalidrawAPI) {
|
||||||
const api: ExcalidrawImperativeAPI = {
|
const api: ExcalidrawImperativeAPI = {
|
||||||
updateScene: this.updateScene,
|
updateScene: this.updateScene,
|
||||||
applyDeltas: this.applyDeltas,
|
|
||||||
mutateElement: this.mutateElement,
|
mutateElement: this.mutateElement,
|
||||||
updateLibrary: this.library.updateLibrary,
|
updateLibrary: this.library.updateLibrary,
|
||||||
addFiles: this.addFiles,
|
addFiles: this.addFiles,
|
||||||
@@ -1614,8 +1602,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
renderWelcomeScreen={
|
renderWelcomeScreen={
|
||||||
!this.state.isLoading &&
|
!this.state.isLoading &&
|
||||||
this.state.showWelcomeScreen &&
|
this.state.showWelcomeScreen &&
|
||||||
this.state.activeTool.type ===
|
this.state.activeTool.type === "selection" &&
|
||||||
this.defaultSelectionTool &&
|
|
||||||
!this.state.zenModeEnabled &&
|
!this.state.zenModeEnabled &&
|
||||||
!this.scene.getElementsIncludingDeleted().length
|
!this.scene.getElementsIncludingDeleted().length
|
||||||
}
|
}
|
||||||
@@ -2355,11 +2342,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const scene = restore(initialData, null, null, {
|
const scene = restore(initialData, null, null, { repairBindings: true });
|
||||||
repairBindings: true,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
|
||||||
const activeTool = scene.appState.activeTool;
|
|
||||||
scene.appState = {
|
scene.appState = {
|
||||||
...scene.appState,
|
...scene.appState,
|
||||||
theme: this.props.theme || scene.appState.theme,
|
theme: this.props.theme || scene.appState.theme,
|
||||||
@@ -2369,13 +2352,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// with a library install link, which should auto-open the library)
|
// with a library install link, which should auto-open the library)
|
||||||
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
|
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
|
||||||
activeTool:
|
activeTool:
|
||||||
activeTool.type === "image" ||
|
scene.appState.activeTool.type === "image"
|
||||||
activeTool.type === "lasso" ||
|
? { ...scene.appState.activeTool, type: "selection" }
|
||||||
activeTool.type === "selection"
|
|
||||||
? {
|
|
||||||
...activeTool,
|
|
||||||
type: this.defaultSelectionTool,
|
|
||||||
}
|
|
||||||
: scene.appState.activeTool,
|
: scene.appState.activeTool,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
toast: this.state.toast,
|
toast: this.state.toast,
|
||||||
@@ -2414,16 +2392,6 @@ 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) => {
|
private isMobileBreakpoint = (width: number, height: number) => {
|
||||||
return (
|
return (
|
||||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||||
@@ -2991,7 +2959,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
private static resetTapTwice() {
|
private static resetTapTwice() {
|
||||||
didTapTwice = false;
|
didTapTwice = false;
|
||||||
firstTapPosition = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTouchStart = (event: TouchEvent) => {
|
private onTouchStart = (event: TouchEvent) => {
|
||||||
@@ -3002,13 +2969,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (!didTapTwice) {
|
if (!didTapTwice) {
|
||||||
didTapTwice = true;
|
didTapTwice = true;
|
||||||
|
|
||||||
if (event.touches.length === 1) {
|
|
||||||
firstTapPosition = {
|
|
||||||
x: event.touches[0].clientX,
|
|
||||||
y: event.touches[0].clientY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
clearTimeout(tappedTwiceTimer);
|
clearTimeout(tappedTwiceTimer);
|
||||||
tappedTwiceTimer = window.setTimeout(
|
tappedTwiceTimer = window.setTimeout(
|
||||||
App.resetTapTwice,
|
App.resetTapTwice,
|
||||||
@@ -3016,29 +2976,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
);
|
);
|
||||||
return;
|
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
|
// event.touches.length === 1 will also prevent inserting text when user's zooming
|
||||||
if (didTapTwice && event.touches.length === 1 && firstTapPosition) {
|
if (didTapTwice && event.touches.length === 1) {
|
||||||
const touch = event.touches[0];
|
const touch = event.touches[0];
|
||||||
const distance = pointDistance(
|
// @ts-ignore
|
||||||
pointFrom(touch.clientX, touch.clientY),
|
this.handleCanvasDoubleClick({
|
||||||
pointFrom(firstTapPosition.x, firstTapPosition.y),
|
clientX: touch.clientX,
|
||||||
);
|
clientY: touch.clientY,
|
||||||
|
});
|
||||||
// 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;
|
didTapTwice = false;
|
||||||
clearTimeout(tappedTwiceTimer);
|
clearTimeout(tappedTwiceTimer);
|
||||||
}
|
}
|
||||||
@@ -3164,7 +3110,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements,
|
elements,
|
||||||
files: data.files || null,
|
files: data.files || null,
|
||||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
position: "cursor",
|
||||||
retainSeed: isPlainPaste,
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
} else if (data.text) {
|
} else if (data.text) {
|
||||||
@@ -3182,7 +3128,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements,
|
elements,
|
||||||
files,
|
files,
|
||||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
position: "cursor",
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -3242,7 +3188,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
this.addTextFromPaste(data.text, isPlainPaste);
|
this.addTextFromPaste(data.text, isPlainPaste);
|
||||||
}
|
}
|
||||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
this.setActiveTool({ type: "selection" });
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -3254,9 +3200,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
retainSeed?: boolean;
|
retainSeed?: boolean;
|
||||||
fitToContent?: boolean;
|
fitToContent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const elements = restoreElements(opts.elements, null, {
|
const elements = restoreElements(opts.elements, null, undefined);
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
|
|
||||||
const elementsCenterX = distance(minX, maxX) / 2;
|
const elementsCenterX = distance(minX, maxX) / 2;
|
||||||
@@ -3388,7 +3332,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
this.setActiveTool({ type: "selection" });
|
||||||
|
|
||||||
if (opts.fitToContent) {
|
if (opts.fitToContent) {
|
||||||
this.scrollToContent(duplicatedElements, {
|
this.scrollToContent(duplicatedElements, {
|
||||||
@@ -3634,7 +3578,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
...updateActiveTool(
|
...updateActiveTool(
|
||||||
this.state,
|
this.state,
|
||||||
prevState.activeTool.locked
|
prevState.activeTool.locked
|
||||||
? { type: this.defaultSelectionTool }
|
? { type: "selection" }
|
||||||
: prevState.activeTool,
|
: prevState.activeTool,
|
||||||
),
|
),
|
||||||
locked: !prevState.activeTool.locked,
|
locked: !prevState.activeTool.locked,
|
||||||
@@ -3989,27 +3933,6 @@ 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>>(
|
public mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||||
element: TElement,
|
element: TElement,
|
||||||
updates: ElementUpdate<TElement>,
|
updates: ElementUpdate<TElement>,
|
||||||
@@ -4547,7 +4470,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!this.state.selectionElement &&
|
!this.state.selectionElement &&
|
||||||
!this.state.selectedElementsAreBeingDragged
|
!this.state.selectedElementsAreBeingDragged
|
||||||
) {
|
) {
|
||||||
const shape = findShapeByKey(event.key, this);
|
const shape = findShapeByKey(event.key);
|
||||||
if (shape) {
|
if (shape) {
|
||||||
if (this.state.activeTool.type !== shape) {
|
if (this.state.activeTool.type !== shape) {
|
||||||
trackEvent(
|
trackEvent(
|
||||||
@@ -4640,7 +4563,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||||
if (this.state.activeTool.type === "laser") {
|
if (this.state.activeTool.type === "laser") {
|
||||||
this.setActiveTool({ type: this.defaultSelectionTool });
|
this.setActiveTool({ type: "selection" });
|
||||||
} else {
|
} else {
|
||||||
this.setActiveTool({ type: "laser" });
|
this.setActiveTool({ type: "laser" });
|
||||||
}
|
}
|
||||||
@@ -5004,8 +4927,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}),
|
}),
|
||||||
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||||
const isDeleted = !nextOriginalText.trim();
|
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
|
// select the created text element only if submitting via keyboard
|
||||||
// (when submitting via click it should act as signal to deselect)
|
// (when submitting via click it should act as signal to deselect)
|
||||||
if (!isDeleted && viaKeyboard) {
|
if (!isDeleted && viaKeyboard) {
|
||||||
@@ -5029,16 +4961,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
|
fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
|
||||||
element,
|
element,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isDeleted || isExistingElement) {
|
// we need to record either way, whether the text element was added or removed
|
||||||
this.store.scheduleCapture();
|
// since we need to sync this delta to other clients, otherwise it would end up with inconsistencies
|
||||||
}
|
this.store.scheduleCapture();
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -5485,7 +5416,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// we should only be able to double click when mode is selection
|
// we should only be able to double click when mode is selection
|
||||||
if (this.state.activeTool.type !== this.defaultSelectionTool) {
|
if (this.state.activeTool.type !== "selection") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5777,9 +5708,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
const frames = this.scene
|
const frames = this.scene
|
||||||
.getNonDeletedFramesLikes()
|
.getNonDeletedFramesLikes()
|
||||||
.filter(
|
.filter((frame): frame is ExcalidrawFrameLikeElement =>
|
||||||
(frame): frame is ExcalidrawFrameLikeElement =>
|
isCursorInFrame(sceneCoords, frame, elementsMap),
|
||||||
!frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return frames.length ? frames[frames.length - 1] : null;
|
return frames.length ? frames[frames.length - 1] : null;
|
||||||
@@ -6097,7 +6027,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (
|
if (
|
||||||
hasDeselectedButton ||
|
hasDeselectedButton ||
|
||||||
(this.state.activeTool.type !== "selection" &&
|
(this.state.activeTool.type !== "selection" &&
|
||||||
this.state.activeTool.type !== "lasso" &&
|
|
||||||
this.state.activeTool.type !== "text" &&
|
this.state.activeTool.type !== "text" &&
|
||||||
this.state.activeTool.type !== "eraser")
|
this.state.activeTool.type !== "eraser")
|
||||||
) {
|
) {
|
||||||
@@ -6260,12 +6189,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!isElbowArrow(hitElement) ||
|
!isElbowArrow(hitElement) ||
|
||||||
!(hitElement.startBinding || hitElement.endBinding)
|
!(hitElement.startBinding || hitElement.endBinding)
|
||||||
) {
|
) {
|
||||||
if (
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
this.state.activeTool.type !== "lasso" ||
|
|
||||||
selectedElements.length > 0
|
|
||||||
) {
|
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
|
||||||
}
|
|
||||||
if (this.state.activeEmbeddable?.state === "hover") {
|
if (this.state.activeEmbeddable?.state === "hover") {
|
||||||
this.setState({ activeEmbeddable: null });
|
this.setState({ activeEmbeddable: null });
|
||||||
}
|
}
|
||||||
@@ -6382,12 +6306,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!isElbowArrow(element) ||
|
!isElbowArrow(element) ||
|
||||||
!(element.startBinding || element.endBinding)
|
!(element.startBinding || element.endBinding)
|
||||||
) {
|
) {
|
||||||
if (
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
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)) {
|
} else if (this.hitElement(scenePointerX, scenePointerY, element)) {
|
||||||
@@ -6396,12 +6315,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
!isElbowArrow(element) ||
|
!isElbowArrow(element) ||
|
||||||
!(element.startBinding || element.endBinding)
|
!(element.startBinding || element.endBinding)
|
||||||
) {
|
) {
|
||||||
if (
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
||||||
this.state.activeTool.type !== "lasso" ||
|
|
||||||
Object.keys(this.state.selectedElementIds).length > 0
|
|
||||||
) {
|
|
||||||
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6663,119 +6577,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.activeTool.type === "lasso") {
|
if (this.state.activeTool.type === "lasso") {
|
||||||
const hitSelectedElement =
|
this.lassoTrail.startPath(
|
||||||
pointerDownState.hit.element &&
|
pointerDownState.origin.x,
|
||||||
this.isASelectedElement(pointerDownState.hit.element);
|
pointerDownState.origin.y,
|
||||||
|
event.shiftKey,
|
||||||
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") {
|
} else if (this.state.activeTool.type === "text") {
|
||||||
this.handleTextOnPointerDown(event, pointerDownState);
|
this.handleTextOnPointerDown(event, pointerDownState);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -7155,7 +6961,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
hasOccurred: false,
|
hasOccurred: false,
|
||||||
offset: null,
|
offset: null,
|
||||||
origin: { ...origin },
|
origin: { ...origin },
|
||||||
blockDragging: false,
|
|
||||||
},
|
},
|
||||||
eventListeners: {
|
eventListeners: {
|
||||||
onMove: null,
|
onMove: null,
|
||||||
@@ -7231,10 +7036,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (
|
if (this.state.activeTool.type === "selection") {
|
||||||
this.state.activeTool.type === "selection" ||
|
|
||||||
this.state.activeTool.type === "lasso"
|
|
||||||
) {
|
|
||||||
const elements = this.scene.getNonDeletedElements();
|
const elements = this.scene.getNonDeletedElements();
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
@@ -7441,18 +7243,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// on CMD/CTRL, drill down to hit element regardless of groups etc.
|
// on CMD/CTRL, drill down to hit element regardless of groups etc.
|
||||||
if (event[KEYS.CTRL_OR_CMD]) {
|
if (event[KEYS.CTRL_OR_CMD]) {
|
||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
// ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool
|
// ctrl + alt means we're lasso selecting
|
||||||
|
|
||||||
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
if (!this.state.selectedElementIds[hitElement.id]) {
|
if (!this.state.selectedElementIds[hitElement.id]) {
|
||||||
@@ -7673,9 +7464,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
resetCursor(this.interactiveCanvas);
|
resetCursor(this.interactiveCanvas);
|
||||||
if (!this.state.activeTool.locked) {
|
if (!this.state.activeTool.locked) {
|
||||||
this.setState({
|
this.setState({
|
||||||
activeTool: updateActiveTool(this.state, {
|
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||||
type: this.defaultSelectionTool,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -8459,18 +8248,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
this.state.selectedLinearElement.elementId ===
|
this.state.selectedLinearElement.elementId ===
|
||||||
pointerDownState.hit.element?.id;
|
pointerDownState.hit.element?.id;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(hasHitASelectedElement ||
|
(hasHitASelectedElement ||
|
||||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
|
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
|
||||||
!isSelectingPointsInLineEditor &&
|
!isSelectingPointsInLineEditor &&
|
||||||
!pointerDownState.drag.blockDragging
|
this.state.activeTool.type !== "lasso"
|
||||||
) {
|
) {
|
||||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||||
if (
|
|
||||||
selectedElements.length > 0 &&
|
if (selectedElements.every((element) => element.locked)) {
|
||||||
selectedElements.every((element) => element.locked)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8491,29 +8277,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// if elements should be deselected on pointerup
|
// if elements should be deselected on pointerup
|
||||||
pointerDownState.drag.hasOccurred = true;
|
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
|
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||||
// it would have weird results (stuff jumping all over the screen)
|
// it would have weird results (stuff jumping all over the screen)
|
||||||
// Checking for editingTextElement to avoid jump while editing on mobile #6503
|
// Checking for editingTextElement to avoid jump while editing on mobile #6503
|
||||||
@@ -9108,7 +8871,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
): (event: PointerEvent) => void {
|
): (event: PointerEvent) => void {
|
||||||
return withBatchedUpdates((childEvent: PointerEvent) => {
|
return withBatchedUpdates((childEvent: PointerEvent) => {
|
||||||
this.removePointer(childEvent);
|
this.removePointer(childEvent);
|
||||||
pointerDownState.drag.blockDragging = false;
|
|
||||||
if (pointerDownState.eventListeners.onMove) {
|
if (pointerDownState.eventListeners.onMove) {
|
||||||
pointerDownState.eventListeners.onMove.flush();
|
pointerDownState.eventListeners.onMove.flush();
|
||||||
}
|
}
|
||||||
@@ -9397,7 +9159,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState((prevState) => ({
|
this.setState((prevState) => ({
|
||||||
newElement: null,
|
newElement: null,
|
||||||
activeTool: updateActiveTool(this.state, {
|
activeTool: updateActiveTool(this.state, {
|
||||||
type: this.defaultSelectionTool,
|
type: "selection",
|
||||||
}),
|
}),
|
||||||
selectedElementIds: makeNextSelectedElementIds(
|
selectedElementIds: makeNextSelectedElementIds(
|
||||||
{
|
{
|
||||||
@@ -10013,9 +9775,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
newElement: null,
|
newElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
activeTool: updateActiveTool(this.state, {
|
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||||
type: this.defaultSelectionTool,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -10309,9 +10069,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState(
|
this.setState(
|
||||||
{
|
{
|
||||||
newElement: null,
|
newElement: null,
|
||||||
activeTool: updateActiveTool(this.state, {
|
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
||||||
type: this.defaultSelectionTool,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
this.actionManager.executeAction(actionFinalize);
|
this.actionManager.executeAction(actionFinalize);
|
||||||
@@ -10363,7 +10121,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (erroredFiles.size) {
|
if (erroredFiles.size) {
|
||||||
this.store.scheduleAction(CaptureUpdateAction.NEVER);
|
this.store.scheduleAction(CaptureUpdateAction.NEVER);
|
||||||
this.scene.replaceAllElements(
|
this.scene.replaceAllElements(
|
||||||
this.scene.getElementsIncludingDeleted().map((element) => {
|
elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
isInitializedImageElement(element) &&
|
isInitializedImageElement(element) &&
|
||||||
erroredFiles.has(element.fileId)
|
erroredFiles.has(element.fileId)
|
||||||
@@ -10684,7 +10442,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event.nativeEvent.pointerType === "pen" &&
|
event.nativeEvent.pointerType === "pen" &&
|
||||||
// always allow if user uses a pen secondary button
|
// always allow if user uses a pen secondary button
|
||||||
event.button !== POINTER_BUTTON.SECONDARY)) &&
|
event.button !== POINTER_BUTTON.SECONDARY)) &&
|
||||||
this.state.activeTool.type !== this.defaultSelectionTool
|
this.state.activeTool.type !== "selection"
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface ButtonProps
|
|||||||
HTMLButtonElement
|
HTMLButtonElement
|
||||||
> {
|
> {
|
||||||
type?: "button" | "submit" | "reset";
|
type?: "button" | "submit" | "reset";
|
||||||
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => any;
|
onSelect: () => any;
|
||||||
/** whether button is in active state */
|
/** whether button is in active state */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -34,7 +34,7 @@ export const Button = ({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={composeEventHandlers(rest.onClick, (event) => {
|
onClick={composeEventHandlers(rest.onClick, (event) => {
|
||||||
onSelect(event);
|
onSelect();
|
||||||
})}
|
})}
|
||||||
type={type}
|
type={type}
|
||||||
className={clsx("excalidraw-button", className, { selected })}
|
className={clsx("excalidraw-button", className, { selected })}
|
||||||
|
|||||||
@@ -30,18 +30,6 @@
|
|||||||
align-items: center;
|
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 {
|
.color-picker__button {
|
||||||
--radius: 4px;
|
--radius: 4px;
|
||||||
--size: 1.375rem;
|
--size: 1.375rem;
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import { PropertiesPopover } from "../PropertiesPopover";
|
|||||||
import { QuickSearch } from "../QuickSearch";
|
import { QuickSearch } from "../QuickSearch";
|
||||||
import { ScrollableList } from "../ScrollableList";
|
import { ScrollableList } from "../ScrollableList";
|
||||||
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||||
|
import DropdownMenuItem, {
|
||||||
|
DropDownMenuItemBadgeType,
|
||||||
|
DropDownMenuItemBadge,
|
||||||
|
} from "../dropdownMenu/DropdownMenuItem";
|
||||||
import {
|
import {
|
||||||
FontFamilyCodeIcon,
|
FontFamilyCodeIcon,
|
||||||
FontFamilyHeadingIcon,
|
FontFamilyHeadingIcon,
|
||||||
@@ -32,15 +36,8 @@ import {
|
|||||||
FreedrawIcon,
|
FreedrawIcon,
|
||||||
} from "../icons";
|
} from "../icons";
|
||||||
|
|
||||||
import { Ellipsify } from "../Ellipsify";
|
|
||||||
|
|
||||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||||
|
|
||||||
import {
|
|
||||||
FontPickerListItem,
|
|
||||||
FontPickerListItemBadgeType,
|
|
||||||
} from "./FontPickerListItem";
|
|
||||||
|
|
||||||
import type { JSX } from "react";
|
import type { JSX } from "react";
|
||||||
|
|
||||||
export interface FontDescriptor {
|
export interface FontDescriptor {
|
||||||
@@ -49,7 +46,7 @@ export interface FontDescriptor {
|
|||||||
text: string;
|
text: string;
|
||||||
deprecated?: true;
|
deprecated?: true;
|
||||||
badge?: {
|
badge?: {
|
||||||
type: ValueOf<typeof FontPickerListItemBadgeType>;
|
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -115,7 +112,7 @@ export const FontPickerList = React.memo(
|
|||||||
Object.assign(fontDescriptor, {
|
Object.assign(fontDescriptor, {
|
||||||
deprecated: metadata.deprecated,
|
deprecated: metadata.deprecated,
|
||||||
badge: {
|
badge: {
|
||||||
type: FontPickerListItemBadgeType.RED,
|
type: DropDownMenuItemBadgeType.RED,
|
||||||
placeholder: t("fontList.badge.old"),
|
placeholder: t("fontList.badge.old"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -230,7 +227,7 @@ export const FontPickerList = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderFont = (font: FontDescriptor, index: number) => (
|
const renderFont = (font: FontDescriptor, index: number) => (
|
||||||
<FontPickerListItem
|
<DropdownMenuItem
|
||||||
key={font.value}
|
key={font.value}
|
||||||
icon={font.icon}
|
icon={font.icon}
|
||||||
value={font.value}
|
value={font.value}
|
||||||
@@ -242,8 +239,8 @@ export const FontPickerList = React.memo(
|
|||||||
selected={font.value === selectedFontFamily}
|
selected={font.value === selectedFontFamily}
|
||||||
// allow to tab between search and selected font
|
// allow to tab between search and selected font
|
||||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
onSelect={() => {
|
onClick={(e) => {
|
||||||
onSelect(font.value);
|
onSelect(Number(e.currentTarget.value));
|
||||||
}}
|
}}
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
if (hoveredFont?.value !== font.value) {
|
if (hoveredFont?.value !== font.value) {
|
||||||
@@ -251,13 +248,13 @@ export const FontPickerList = React.memo(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ellipsify>{font.text}</Ellipsify>
|
{font.text}
|
||||||
{font.badge && (
|
{font.badge && (
|
||||||
<FontPickerListItem.Badge type={font.badge.type}>
|
<DropDownMenuItemBadge type={font.badge.type}>
|
||||||
{font.badge.placeholder}
|
{font.badge.placeholder}
|
||||||
</FontPickerListItem.Badge>
|
</DropDownMenuItemBadge>
|
||||||
)}
|
)}
|
||||||
</FontPickerListItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
const groups = [];
|
const groups = [];
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
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,10 +238,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||||
isOr={true}
|
isOr={true}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||||
label={t("toolBar.lock")}
|
|
||||||
shortcuts={[getShortcutFromShortcutName("toolLock")]}
|
|
||||||
/>
|
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("helpDialog.preventBinding")}
|
label={t("helpDialog.preventBinding")}
|
||||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
<DropdownMenu open={isLibraryMenuOpen}>
|
<DropdownMenu open={isLibraryMenuOpen}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||||
aria-label="Library menu"
|
|
||||||
>
|
>
|
||||||
{DotsIcon}
|
{DotsIcon}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -202,7 +201,6 @@ export const LibraryDropdownMenuButton: React.FC<{
|
|||||||
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
||||||
onSelect={() => setIsLibraryMenuOpen(false)}
|
onSelect={() => setIsLibraryMenuOpen(false)}
|
||||||
className="library-menu"
|
className="library-menu"
|
||||||
align="end"
|
|
||||||
>
|
>
|
||||||
{!itemsSelected && (
|
{!itemsSelected && (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
|
|||||||
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
|
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
|
||||||
}}
|
}}
|
||||||
icon={icon ?? brainIcon}
|
icon={icon ?? brainIcon}
|
||||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
|
||||||
>
|
>
|
||||||
{children ?? t("labels.textToDiagram")}
|
{children ?? t("labels.textToDiagram")}
|
||||||
|
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</TTDDialogTriggerTunnel.In>
|
</TTDDialogTriggerTunnel.In>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +1,20 @@
|
|||||||
@import "../../css/variables.module";
|
@import "../../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
|
|
||||||
z-index: 2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
max-width: 16rem;
|
position: absolute;
|
||||||
margin-top: 0.25rem;
|
top: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
&__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 {
|
&--mobile {
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
row-gap: 0.75rem;
|
||||||
|
|
||||||
.dropdown-menu-container {
|
.dropdown-menu-container {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
padding: 8px 8px;
|
padding: 8px 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--island-bg-color);
|
// background-color: var(--island-bg-color);
|
||||||
box-shadow: var(--shadow-island);
|
box-shadow: var(--shadow-island);
|
||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -55,14 +30,13 @@
|
|||||||
|
|
||||||
.dropdown-menu-container {
|
.dropdown-menu-container {
|
||||||
background-color: var(--island-bg-color);
|
background-color: var(--island-bg-color);
|
||||||
max-height: var(--radix-popper-available-height);
|
max-height: calc(100vh - 150px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--gap: 2;
|
--gap: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu-item-base {
|
.dropdown-menu-item-base {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 0.625rem;
|
|
||||||
column-gap: 0.625rem;
|
column-gap: 0.625rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
@@ -70,7 +44,6 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.manual-hover {
|
&.manual-hover {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
||||||
|
|
||||||
import DropdownMenuContent from "./DropdownMenuContent";
|
import DropdownMenuContent from "./DropdownMenuContent";
|
||||||
import DropdownMenuGroup from "./DropdownMenuGroup";
|
import DropdownMenuGroup from "./DropdownMenuGroup";
|
||||||
import DropdownMenuItem from "./DropdownMenuItem";
|
import DropdownMenuItem from "./DropdownMenuItem";
|
||||||
@@ -25,12 +23,11 @@ const DropdownMenu = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||||
const MenuContentComp = getMenuContentComponent(children);
|
const MenuContentComp = getMenuContentComponent(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Root open={open} modal={false}>
|
<>
|
||||||
{MenuTriggerComp}
|
{MenuTriggerComp}
|
||||||
{MenuContentComp}
|
{open && MenuContentComp}
|
||||||
</DropdownMenuPrimitive.Root>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import React, { useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { EVENT, KEYS } from "@excalidraw/common";
|
import { EVENT, KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
||||||
|
|
||||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||||
import { useStable } from "../../hooks/useStable";
|
import { useStable } from "../../hooks/useStable";
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
@@ -19,9 +17,6 @@ const MenuContent = ({
|
|||||||
className = "",
|
className = "",
|
||||||
onSelect,
|
onSelect,
|
||||||
style,
|
style,
|
||||||
sideOffset = 4,
|
|
||||||
align = "start",
|
|
||||||
collisionPadding,
|
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
onClickOutside?: () => void;
|
onClickOutside?: () => void;
|
||||||
@@ -31,11 +26,6 @@ const MenuContent = ({
|
|||||||
*/
|
*/
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
sideOffset?: number;
|
|
||||||
align?: "start" | "center" | "end";
|
|
||||||
collisionPadding?:
|
|
||||||
| number
|
|
||||||
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -72,15 +62,11 @@ const MenuContent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
||||||
<DropdownMenuPrimitive.Content
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={classNames}
|
className={classNames}
|
||||||
style={style}
|
style={style}
|
||||||
data-testid="dropdown-menu"
|
data-testid="dropdown-menu"
|
||||||
side="bottom"
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
align={align}
|
|
||||||
collisionPadding={collisionPadding}
|
|
||||||
>
|
>
|
||||||
{/* the zIndex ensures this menu has higher stacking order,
|
{/* the zIndex ensures this menu has higher stacking order,
|
||||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||||
@@ -95,7 +81,7 @@ const MenuContent = ({
|
|||||||
{children}
|
{children}
|
||||||
</Island>
|
</Island>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuPrimitive.Content>
|
</div>
|
||||||
</DropdownMenuContentPropsContext.Provider>
|
</DropdownMenuContentPropsContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import React, { useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { THEME } from "@excalidraw/common";
|
import { THEME } from "@excalidraw/common";
|
||||||
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
||||||
|
|
||||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { Button } from "../Button";
|
|
||||||
|
|
||||||
import { useExcalidrawAppState } from "../App";
|
import { useExcalidrawAppState } from "../App";
|
||||||
|
|
||||||
import MenuItemContent from "./DropdownMenuItemContent";
|
import MenuItemContent from "./DropdownMenuItemContent";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getDropdownMenuItemClassName,
|
getDropdownMenuItemClassName,
|
||||||
useHandleDropdownMenuItemClick,
|
useHandleDropdownMenuItemClick,
|
||||||
@@ -22,45 +17,55 @@ import type { JSX } from "react";
|
|||||||
const DropdownMenuItem = ({
|
const DropdownMenuItem = ({
|
||||||
icon,
|
icon,
|
||||||
value,
|
value,
|
||||||
badge,
|
|
||||||
order,
|
order,
|
||||||
children,
|
children,
|
||||||
shortcut,
|
shortcut,
|
||||||
className,
|
className,
|
||||||
|
hovered,
|
||||||
selected,
|
selected,
|
||||||
|
textStyle,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClick,
|
onClick,
|
||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
icon?: JSX.Element;
|
icon?: JSX.Element;
|
||||||
badge?: React.ReactNode;
|
|
||||||
value?: string | number | undefined;
|
value?: string | number | undefined;
|
||||||
order?: number;
|
order?: number;
|
||||||
onSelect?: (event: Event) => void;
|
onSelect?: (event: Event) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
|
hovered?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
className?: string;
|
className?: string;
|
||||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||||
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
||||||
const ref = useRef<HTMLButtonElement>(null);
|
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 (
|
return (
|
||||||
<DropdownMenuPrimitive.Item className="radix-menu-item">
|
<button
|
||||||
<Button
|
{...rest}
|
||||||
{...rest}
|
ref={ref}
|
||||||
ref={ref}
|
value={value}
|
||||||
onSelect={handleClick}
|
onClick={handleClick}
|
||||||
className={getDropdownMenuItemClassName(className)}
|
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||||
title={rest.title ?? rest["aria-label"]}
|
title={rest.title ?? rest["aria-label"]}
|
||||||
>
|
>
|
||||||
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
|
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||||
{children}
|
{children}
|
||||||
</MenuItemContent>
|
</MenuItemContent>
|
||||||
</Button>
|
</button>
|
||||||
</DropdownMenuPrimitive.Item>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||||
|
|||||||
@@ -2,24 +2,25 @@ import { useDevice } from "../App";
|
|||||||
|
|
||||||
import { Ellipsify } from "../Ellipsify";
|
import { Ellipsify } from "../Ellipsify";
|
||||||
|
|
||||||
|
import type { JSX } from "react";
|
||||||
|
|
||||||
const MenuItemContent = ({
|
const MenuItemContent = ({
|
||||||
|
textStyle,
|
||||||
icon,
|
icon,
|
||||||
badge,
|
|
||||||
shortcut,
|
shortcut,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
icon?: React.ReactNode;
|
icon?: JSX.Element;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
|
textStyle?: React.CSSProperties;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
badge?: React.ReactNode;
|
|
||||||
}) => {
|
}) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||||
<div className="dropdown-menu-item__text">
|
<div style={textStyle} className="dropdown-menu-item__text">
|
||||||
<Ellipsify>{children}</Ellipsify>
|
<Ellipsify>{children}</Ellipsify>
|
||||||
{badge}
|
|
||||||
</div>
|
</div>
|
||||||
{shortcut && !device.editor.isMobile && (
|
{shortcut && !device.editor.isMobile && (
|
||||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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";
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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,7 +1,5 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
|
||||||
|
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
const MenuTrigger = ({
|
const MenuTrigger = ({
|
||||||
@@ -25,8 +23,7 @@ const MenuTrigger = ({
|
|||||||
},
|
},
|
||||||
).trim();
|
).trim();
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<button
|
||||||
data-dropdown-menu-trigger
|
|
||||||
data-prevent-outside-click
|
data-prevent-outside-click
|
||||||
className={classNames}
|
className={classNames}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -36,7 +33,7 @@ const MenuTrigger = ({
|
|||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.Trigger>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const getMenuComponent = (component: string) => (children: React.ReactNode) => {
|
export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
||||||
const comp = React.Children.toArray(children).find(
|
const comp = React.Children.toArray(children).find(
|
||||||
(child) =>
|
(child) =>
|
||||||
React.isValidElement(child) &&
|
React.isValidElement(child) &&
|
||||||
@@ -8,7 +8,7 @@ const getMenuComponent = (component: string) => (children: React.ReactNode) => {
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
child?.type.displayName &&
|
child?.type.displayName &&
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
child.type.displayName === component,
|
child.type.displayName === "DropdownMenuTrigger",
|
||||||
);
|
);
|
||||||
if (!comp) {
|
if (!comp) {
|
||||||
return null;
|
return null;
|
||||||
@@ -17,11 +17,19 @@ const getMenuComponent = (component: string) => (children: React.ReactNode) => {
|
|||||||
return comp;
|
return comp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
|
export const getMenuContentComponent = (children: React.ReactNode) => {
|
||||||
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
|
const comp = React.Children.toArray(children).find(
|
||||||
export const getSubMenuTriggerComponent = getMenuComponent(
|
(child) =>
|
||||||
"DropdownMenuSubTrigger",
|
React.isValidElement(child) &&
|
||||||
);
|
typeof child.type !== "string" &&
|
||||||
export const getSubMenuContentComponent = getMenuComponent(
|
//@ts-ignore
|
||||||
"DropdownMenuSubContent",
|
child?.type.displayName &&
|
||||||
);
|
//@ts-ignore
|
||||||
|
child.type.displayName === "DropdownMenuContent",
|
||||||
|
);
|
||||||
|
if (!comp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
//@ts-ignore
|
||||||
|
return comp;
|
||||||
|
};
|
||||||
|
|||||||
@@ -72,15 +72,6 @@ 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
|
// tabler-icons: present
|
||||||
export const PlusPromoIcon = createIcon(
|
export const PlusPromoIcon = createIcon(
|
||||||
<g strokeWidth="1.5">
|
<g strokeWidth="1.5">
|
||||||
@@ -2278,21 +2269,3 @@ export const elementLinkIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
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,11 +9,8 @@ import {
|
|||||||
actionLoadScene,
|
actionLoadScene,
|
||||||
actionSaveToActiveFile,
|
actionSaveToActiveFile,
|
||||||
actionShortcuts,
|
actionShortcuts,
|
||||||
actionToggleGridMode,
|
|
||||||
actionToggleObjectsSnapMode,
|
|
||||||
actionToggleSearchMenu,
|
actionToggleSearchMenu,
|
||||||
actionToggleTheme,
|
actionToggleTheme,
|
||||||
actionToggleZenMode,
|
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
@@ -26,23 +23,13 @@ import {
|
|||||||
useExcalidrawActionManager,
|
useExcalidrawActionManager,
|
||||||
useExcalidrawElements,
|
useExcalidrawElements,
|
||||||
useAppProps,
|
useAppProps,
|
||||||
useApp,
|
|
||||||
} from "../App";
|
} from "../App";
|
||||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||||
import Trans from "../Trans";
|
import Trans from "../Trans";
|
||||||
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
||||||
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
||||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||||
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
|
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
|
||||||
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
|
|
||||||
import {
|
|
||||||
GithubIcon,
|
|
||||||
DiscordIcon,
|
|
||||||
XBrandIcon,
|
|
||||||
settingsIcon,
|
|
||||||
checkIcon,
|
|
||||||
emptyIcon,
|
|
||||||
} from "../icons";
|
|
||||||
import {
|
import {
|
||||||
boltIcon,
|
boltIcon,
|
||||||
DeviceDesktopIcon,
|
DeviceDesktopIcon,
|
||||||
@@ -326,10 +313,7 @@ export const ChangeCanvasBackground = () => {
|
|||||||
>
|
>
|
||||||
{t("labels.canvasBackground")}
|
{t("labels.canvasBackground")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ padding: "0 0.625rem" }}>
|
||||||
style={{ padding: "0 0.625rem" }}
|
|
||||||
id="canvas-bg-color-picker-container"
|
|
||||||
>
|
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -409,73 +393,3 @@ export const LiveCollaborationTrigger = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
LiveCollaborationTrigger.displayName = "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,12 +2,8 @@ import React from "react";
|
|||||||
|
|
||||||
import { composeEventHandlers } from "@excalidraw/common";
|
import { composeEventHandlers } from "@excalidraw/common";
|
||||||
|
|
||||||
import * as Portal from "@radix-ui/react-portal";
|
|
||||||
|
|
||||||
import { useTunnels } from "../../context/tunnels";
|
import { useTunnels } from "../../context/tunnels";
|
||||||
import { useUIAppState } from "../../context/ui-appState";
|
import { useUIAppState } from "../../context/ui-appState";
|
||||||
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
|
|
||||||
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { useDevice, useExcalidrawSetAppState } from "../App";
|
import { useDevice, useExcalidrawSetAppState } from "../App";
|
||||||
import { UserList } from "../UserList";
|
import { UserList } from "../UserList";
|
||||||
@@ -40,17 +36,6 @@ const MainMenu = Object.assign(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MainMenuTunnel.In>
|
<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 open={appState.openMenu === "canvas"}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
onToggle={() => {
|
onToggle={() => {
|
||||||
@@ -59,27 +44,15 @@ const MainMenu = Object.assign(
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
data-testid="main-menu-trigger"
|
data-testid="main-menu-trigger"
|
||||||
aria-label="Main menu"
|
|
||||||
className="main-menu-trigger"
|
className="main-menu-trigger"
|
||||||
>
|
>
|
||||||
{HamburgerMenuIcon}
|
{HamburgerMenuIcon}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Content
|
<DropdownMenu.Content
|
||||||
sideOffset={device.editor.isMobile ? 20 : undefined}
|
|
||||||
className="main-menu-content"
|
|
||||||
onClickOutside={onClickOutside}
|
onClickOutside={onClickOutside}
|
||||||
onSelect={composeEventHandlers(onSelect, () => {
|
onSelect={composeEventHandlers(onSelect, () => {
|
||||||
setAppState({ openMenu: null });
|
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}
|
{children}
|
||||||
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
||||||
@@ -105,7 +78,6 @@ const MainMenu = Object.assign(
|
|||||||
ItemCustom: DropdownMenu.ItemCustom,
|
ItemCustom: DropdownMenu.ItemCustom,
|
||||||
Group: DropdownMenu.Group,
|
Group: DropdownMenu.Group,
|
||||||
Separator: DropdownMenu.Separator,
|
Separator: DropdownMenu.Separator,
|
||||||
Sub: DropdownMenuSub,
|
|
||||||
DefaultItems,
|
DefaultItems,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
EraserIcon,
|
EraserIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
|
||||||
import type { AppClassProperties } from "../types";
|
|
||||||
|
|
||||||
export const SHAPES = [
|
export const SHAPES = [
|
||||||
{
|
{
|
||||||
icon: SelectionIcon,
|
icon: SelectionIcon,
|
||||||
@@ -88,23 +86,8 @@ export const SHAPES = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const getToolbarTools = (app: AppClassProperties) => {
|
export const findShapeByKey = (key: string) => {
|
||||||
return app.defaultSelectionTool === "lasso"
|
const shape = SHAPES.find((shape, index) => {
|
||||||
? ([
|
|
||||||
{
|
|
||||||
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 (
|
return (
|
||||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||||
(shape.key &&
|
(shape.key &&
|
||||||
|
|||||||
@@ -144,7 +144,6 @@
|
|||||||
--color-logo-icon: var(--color-primary);
|
--color-logo-icon: var(--color-primary);
|
||||||
--color-logo-text: #190064;
|
--color-logo-text: #190064;
|
||||||
|
|
||||||
--border-radius-sm: 0.25rem;
|
|
||||||
--border-radius-md: 0.375rem;
|
--border-radius-md: 0.375rem;
|
||||||
--border-radius-lg: 0.5rem;
|
--border-radius-lg: 0.5rem;
|
||||||
|
|
||||||
|
|||||||
@@ -170,11 +170,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
|||||||
},
|
},
|
||||||
localAppState,
|
localAppState,
|
||||||
localElements,
|
localElements,
|
||||||
{
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
repairBindings: true,
|
|
||||||
refreshDimensions: false,
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else if (isValidLibrary(data)) {
|
} else if (isValidLibrary(data)) {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
supported as nativeFileSystemSupported,
|
supported as nativeFileSystemSupported,
|
||||||
} from "browser-fs-access";
|
} from "browser-fs-access";
|
||||||
|
|
||||||
import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
import {
|
||||||
|
EVENT,
|
||||||
|
MIME_TYPES,
|
||||||
|
debounce,
|
||||||
|
isIOS,
|
||||||
|
isAndroid,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
|
|
||||||
@@ -13,6 +19,8 @@ import type { FileSystemHandle } from "browser-fs-access";
|
|||||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||||
|
|
||||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||||
|
// increase timeout for mobile devices to give more time for file selection
|
||||||
|
const MOBILE_INPUT_CHANGE_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||||
extensions?: FILE_EXTENSION[];
|
extensions?: FILE_EXTENSION[];
|
||||||
@@ -41,13 +49,22 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
mimeTypes,
|
mimeTypes,
|
||||||
multiple: opts.multiple ?? false,
|
multiple: opts.multiple ?? false,
|
||||||
legacySetup: (resolve, reject, input) => {
|
legacySetup: (resolve, reject, input) => {
|
||||||
const scheduleRejection = debounce(reject, INPUT_CHANGE_INTERVAL_MS);
|
const isMobile = isIOS || isAndroid;
|
||||||
|
const intervalMs = isMobile
|
||||||
|
? MOBILE_INPUT_CHANGE_INTERVAL_MS
|
||||||
|
: INPUT_CHANGE_INTERVAL_MS;
|
||||||
|
const scheduleRejection = debounce(reject, intervalMs);
|
||||||
|
|
||||||
const focusHandler = () => {
|
const focusHandler = () => {
|
||||||
checkForFile();
|
checkForFile();
|
||||||
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
// on mobile, be less aggressive with rejection
|
||||||
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
if (!isMobile) {
|
||||||
scheduleRejection();
|
document.addEventListener(EVENT.KEYUP, scheduleRejection);
|
||||||
|
document.addEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
|
scheduleRejection();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkForFile = () => {
|
const checkForFile = () => {
|
||||||
// this hack might not work when expecting multiple files
|
// this hack might not work when expecting multiple files
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
@@ -55,12 +72,15 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
resolve(ret as RetType);
|
resolve(ret as RetType);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
window.addEventListener(EVENT.FOCUS, focusHandler);
|
window.addEventListener(EVENT.FOCUS, focusHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
checkForFile();
|
checkForFile();
|
||||||
}, INPUT_CHANGE_INTERVAL_MS);
|
}, intervalMs);
|
||||||
|
|
||||||
return (rejectPromise) => {
|
return (rejectPromise) => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
scheduleRejection.cancel();
|
scheduleRejection.cancel();
|
||||||
@@ -69,7 +89,9 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
|||||||
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
document.removeEventListener(EVENT.POINTER_UP, scheduleRejection);
|
||||||
if (rejectPromise) {
|
if (rejectPromise) {
|
||||||
// so that something is shown in console if we need to debug this
|
// so that something is shown in console if we need to debug this
|
||||||
console.warn("Opening the file was canceled (legacy-fs).");
|
console.warn(
|
||||||
|
"Opening the file was canceled (legacy-fs). This may happen on mobile devices.",
|
||||||
|
);
|
||||||
rejectPromise(new AbortError());
|
rejectPromise(new AbortError());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
|||||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||||
MakeBrand<"RemoteExcalidrawElement">;
|
MakeBrand<"RemoteExcalidrawElement">;
|
||||||
|
|
||||||
export const shouldDiscardRemoteElement = (
|
const shouldDiscardRemoteElement = (
|
||||||
localAppState: AppState,
|
localAppState: AppState,
|
||||||
local: OrderedExcalidrawElement | undefined,
|
local: OrderedExcalidrawElement | undefined,
|
||||||
remote: RemoteExcalidrawElement,
|
remote: RemoteExcalidrawElement,
|
||||||
@@ -30,7 +30,7 @@ export const shouldDiscardRemoteElement = (
|
|||||||
// local element is being edited
|
// local element is being edited
|
||||||
(local.id === localAppState.editingTextElement?.id ||
|
(local.id === localAppState.editingTextElement?.id ||
|
||||||
local.id === localAppState.resizingElement?.id ||
|
local.id === localAppState.resizingElement?.id ||
|
||||||
local.id === localAppState.newElement?.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 element is newer
|
// local element is newer
|
||||||
local.version > remote.version ||
|
local.version > remote.version ||
|
||||||
// resolve conflicting edits deterministically by taking the one with
|
// resolve conflicting edits deterministically by taking the one with
|
||||||
|
|||||||
@@ -241,9 +241,8 @@ const restoreElementWithProperties = <
|
|||||||
return ret;
|
return ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const restoreElement = (
|
const restoreElement = (
|
||||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||||
opts?: { deleteInvisibleElements?: boolean },
|
|
||||||
): typeof element | null => {
|
): typeof element | null => {
|
||||||
element = { ...element };
|
element = { ...element };
|
||||||
|
|
||||||
@@ -291,8 +290,7 @@ export const restoreElement = (
|
|||||||
|
|
||||||
// if empty text, mark as deleted. We keep in array
|
// if empty text, mark as deleted. We keep in array
|
||||||
// for data integrity purposes (collab etc.)
|
// for data integrity purposes (collab etc.)
|
||||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
if (!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 = { ...element, originalText: text, isDeleted: true };
|
||||||
element = bumpVersion(element);
|
element = bumpVersion(element);
|
||||||
}
|
}
|
||||||
@@ -387,10 +385,7 @@ export const restoreElement = (
|
|||||||
elbowed: true,
|
elbowed: true,
|
||||||
startBinding: repairBinding(element, element.startBinding),
|
startBinding: repairBinding(element, element.startBinding),
|
||||||
endBinding: repairBinding(element, element.endBinding),
|
endBinding: repairBinding(element, element.endBinding),
|
||||||
fixedSegments:
|
fixedSegments: element.fixedSegments,
|
||||||
element.fixedSegments?.length && base.points.length >= 4
|
|
||||||
? element.fixedSegments
|
|
||||||
: null,
|
|
||||||
startIsSpecial: element.startIsSpecial,
|
startIsSpecial: element.startIsSpecial,
|
||||||
endIsSpecial: element.endIsSpecial,
|
endIsSpecial: element.endIsSpecial,
|
||||||
})
|
})
|
||||||
@@ -528,13 +523,7 @@ export const restoreElements = (
|
|||||||
elements: ImportedDataState["elements"],
|
elements: ImportedDataState["elements"],
|
||||||
/** NOTE doesn't serve for reconciliation */
|
/** NOTE doesn't serve for reconciliation */
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
opts?:
|
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||||
| {
|
|
||||||
refreshDimensions?: boolean;
|
|
||||||
repairBindings?: boolean;
|
|
||||||
deleteInvisibleElements?: boolean;
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
): OrderedExcalidrawElement[] => {
|
): OrderedExcalidrawElement[] => {
|
||||||
// used to detect duplicate top-level element ids
|
// used to detect duplicate top-level element ids
|
||||||
const existingIds = new Set<string>();
|
const existingIds = new Set<string>();
|
||||||
@@ -543,38 +532,24 @@ export const restoreElements = (
|
|||||||
(elements || []).reduce((elements, element) => {
|
(elements || []).reduce((elements, element) => {
|
||||||
// filtering out selection, which is legacy, no longer kept in elements,
|
// filtering out selection, which is legacy, no longer kept in elements,
|
||||||
// and causing issues if retained
|
// and causing issues if retained
|
||||||
if (element.type === "selection") {
|
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||||
return elements;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return elements;
|
||||||
}, [] as ExcalidrawElement[]),
|
}, [] as ExcalidrawElement[]),
|
||||||
);
|
);
|
||||||
@@ -815,11 +790,7 @@ export const restore = (
|
|||||||
*/
|
*/
|
||||||
localAppState: Partial<AppState> | null | undefined,
|
localAppState: Partial<AppState> | null | undefined,
|
||||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||||
elementsConfig?: {
|
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||||
refreshDimensions?: boolean;
|
|
||||||
repairBindings?: boolean;
|
|
||||||
deleteInvisibleElements?: boolean;
|
|
||||||
},
|
|
||||||
): RestoredDataState => {
|
): RestoredDataState => {
|
||||||
return {
|
return {
|
||||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export class History {
|
|||||||
let nextAppState = appState;
|
let nextAppState = appState;
|
||||||
let containsVisibleChange = false;
|
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) {
|
while (historyDelta) {
|
||||||
try {
|
try {
|
||||||
[nextElements, nextAppState, containsVisibleChange] =
|
[nextElements, nextAppState, containsVisibleChange] =
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
|||||||
export {
|
export {
|
||||||
restore,
|
restore,
|
||||||
restoreAppState,
|
restoreAppState,
|
||||||
restoreElement,
|
|
||||||
restoreElements,
|
restoreElements,
|
||||||
restoreLibraryItems,
|
restoreLibraryItems,
|
||||||
} from "./data/restore";
|
} from "./data/restore";
|
||||||
|
|||||||
@@ -171,9 +171,7 @@
|
|||||||
"linkToElement": "Link to object",
|
"linkToElement": "Link to object",
|
||||||
"wrapSelectionInFrame": "Wrap selection in frame",
|
"wrapSelectionInFrame": "Wrap selection in frame",
|
||||||
"tab": "Tab",
|
"tab": "Tab",
|
||||||
"shapeSwitch": "Switch shape",
|
"shapeSwitch": "Switch shape"
|
||||||
"preferences": "Preferences",
|
|
||||||
"preferences_toolLock": "Tool lock"
|
|
||||||
},
|
},
|
||||||
"elementLink": {
|
"elementLink": {
|
||||||
"title": "Link to object",
|
"title": "Link to object",
|
||||||
|
|||||||
@@ -81,13 +81,11 @@
|
|||||||
"@braintree/sanitize-url": "6.0.2",
|
"@braintree/sanitize-url": "6.0.2",
|
||||||
"@excalidraw/common": "0.18.0",
|
"@excalidraw/common": "0.18.0",
|
||||||
"@excalidraw/element": "0.18.0",
|
"@excalidraw/element": "0.18.0",
|
||||||
"@excalidraw/laser-pointer": "1.3.1",
|
|
||||||
"@excalidraw/math": "0.18.0",
|
"@excalidraw/math": "0.18.0",
|
||||||
"@excalidraw/mermaid-to-excalidraw": "1.1.3",
|
"@excalidraw/laser-pointer": "1.3.1",
|
||||||
|
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||||
"@excalidraw/random-username": "1.1.0",
|
"@excalidraw/random-username": "1.1.0",
|
||||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
|
||||||
"@radix-ui/react-popover": "1.1.6",
|
"@radix-ui/react-popover": "1.1.6",
|
||||||
"@radix-ui/react-portal": "1.1.9",
|
|
||||||
"@radix-ui/react-tabs": "1.1.3",
|
"@radix-ui/react-tabs": "1.1.3",
|
||||||
"browser-fs-access": "0.29.1",
|
"browser-fs-access": "0.29.1",
|
||||||
"canvas-roundrect-polyfill": "0.0.1",
|
"canvas-roundrect-polyfill": "0.0.1",
|
||||||
@@ -99,8 +97,8 @@
|
|||||||
"image-blob-reduce": "3.0.1",
|
"image-blob-reduce": "3.0.1",
|
||||||
"jotai": "2.11.0",
|
"jotai": "2.11.0",
|
||||||
"jotai-scope": "0.7.2",
|
"jotai-scope": "0.7.2",
|
||||||
"lodash.debounce": "4.0.8",
|
|
||||||
"lodash.throttle": "4.1.1",
|
"lodash.throttle": "4.1.1",
|
||||||
|
"lodash.debounce": "4.0.8",
|
||||||
"nanoid": "3.3.3",
|
"nanoid": "3.3.3",
|
||||||
"open-color": "1.9.1",
|
"open-color": "1.9.1",
|
||||||
"pako": "2.0.3",
|
"pako": "2.0.3",
|
||||||
|
|||||||
@@ -169,14 +169,8 @@ export const isSnappingEnabled = ({
|
|||||||
selectedElements: NonDeletedExcalidrawElement[];
|
selectedElements: NonDeletedExcalidrawElement[];
|
||||||
}) => {
|
}) => {
|
||||||
if (event) {
|
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 (
|
return (
|
||||||
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
|
app.state.activeTool.type !== "lasso" &&
|
||||||
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
||||||
(!app.state.objectsSnapModeEnabled &&
|
(!app.state.objectsSnapModeEnabled &&
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
|||||||
@@ -3682,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": 400692809,
|
"seed": 1116226695,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"versionNonce": 81784553,
|
"versionNonce": 23633383,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": 20,
|
"x": 20,
|
||||||
"y": 30,
|
"y": 30,
|
||||||
@@ -3714,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"opacity": 100,
|
"opacity": 100,
|
||||||
"roughness": 1,
|
"roughness": 1,
|
||||||
"roundness": null,
|
"roundness": null,
|
||||||
"seed": 449462985,
|
"seed": 1278240551,
|
||||||
"strokeColor": "#1e1e1e",
|
"strokeColor": "#1e1e1e",
|
||||||
"strokeStyle": "solid",
|
"strokeStyle": "solid",
|
||||||
"strokeWidth": 2,
|
"strokeWidth": 2,
|
||||||
"type": "rectangle",
|
"type": "rectangle",
|
||||||
"updated": 1,
|
"updated": 1,
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"versionNonce": 1150084233,
|
"versionNonce": 401146281,
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"x": -10,
|
"x": -10,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6394,16 +6394,15 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id9": true,
|
"id9": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id9",
|
||||||
"elementId": "id9",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id6": true,
|
"id6": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6471,19 +6470,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id12": true,
|
"id12": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id12",
|
||||||
"elementId": "id12",
|
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id9": true,
|
"id9": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id9",
|
||||||
"elementId": "id9",
|
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6549,16 +6542,15 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id15": true,
|
"id15": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id12": true,
|
"id12": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id12",
|
||||||
"elementId": "id12",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6685,13 +6677,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"appState": AppStateDelta {
|
"appState": AppStateDelta {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id15",
|
||||||
"elementId": "id15",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6709,16 +6700,15 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id22": true,
|
"id22": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id15": true,
|
"id15": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id15",
|
||||||
"elementId": "id15",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6843,13 +6833,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"appState": AppStateDelta {
|
"appState": AppStateDelta {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id22",
|
||||||
"elementId": "id22",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -6884,13 +6873,12 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
|||||||
"appState": AppStateDelta {
|
"appState": AppStateDelta {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id22",
|
||||||
"elementId": "id22",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -8731,14 +8719,13 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id0",
|
||||||
"elementId": "id0",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -8959,14 +8946,13 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id0",
|
||||||
"elementId": "id0",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9379,14 +9365,13 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id0",
|
||||||
"elementId": "id0",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -9785,14 +9770,13 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
|||||||
"selectedElementIds": {
|
"selectedElementIds": {
|
||||||
"id0": true,
|
"id0": true,
|
||||||
},
|
},
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id0",
|
||||||
"elementId": "id0",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedElementIds": {},
|
"selectedElementIds": {},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -14510,13 +14494,12 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
|
|||||||
"appState": AppStateDelta {
|
"appState": AppStateDelta {
|
||||||
"delta": Delta {
|
"delta": Delta {
|
||||||
"deleted": {
|
"deleted": {
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElementId": null,
|
||||||
|
"selectedLinearElementIsEditing": null,
|
||||||
},
|
},
|
||||||
"inserted": {
|
"inserted": {
|
||||||
"selectedLinearElement": {
|
"selectedLinearElementId": "id6",
|
||||||
"elementId": "id6",
|
"selectedLinearElementIsEditing": false,
|
||||||
"isEditing": false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -60,11 +60,7 @@ describe("restoreElements", () => {
|
|||||||
const rectElement = API.createElement({ type: "rectangle" });
|
const rectElement = API.createElement({ type: "rectangle" });
|
||||||
mockSizeHelper.mockImplementation(() => true);
|
mockSizeHelper.mockImplementation(() => true);
|
||||||
|
|
||||||
expect(
|
expect(restore.restoreElements([rectElement], null).length).toBe(0);
|
||||||
restore.restoreElements([rectElement], null, {
|
|
||||||
deleteInvisibleElements: true,
|
|
||||||
}),
|
|
||||||
).toEqual([expect.objectContaining({ isDeleted: true })]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should restore text element correctly passing value for each attribute", () => {
|
it("should restore text element correctly passing value for each attribute", () => {
|
||||||
@@ -89,23 +85,6 @@ 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", () => {
|
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
|
||||||
const textElement: any = API.createElement({
|
const textElement: any = API.createElement({
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -118,9 +97,10 @@ describe("restoreElements", () => {
|
|||||||
textElement.font = "10 unknown";
|
textElement.font = "10 unknown";
|
||||||
|
|
||||||
expect(textElement.isDeleted).toBe(false);
|
expect(textElement.isDeleted).toBe(false);
|
||||||
const restoredText = restore.restoreElements([textElement], null, {
|
const restoredText = restore.restoreElements(
|
||||||
deleteInvisibleElements: true,
|
[textElement],
|
||||||
})[0] as ExcalidrawTextElement;
|
null,
|
||||||
|
)[0] as ExcalidrawTextElement;
|
||||||
expect(restoredText.isDeleted).toBe(true);
|
expect(restoredText.isDeleted).toBe(true);
|
||||||
expect(restoredText).toMatchSnapshot({
|
expect(restoredText).toMatchSnapshot({
|
||||||
seed: expect.any(Number),
|
seed: expect.any(Number),
|
||||||
@@ -197,16 +177,13 @@ describe("restoreElements", () => {
|
|||||||
y: 0,
|
y: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoredElements = restore.restoreElements([arrowElement], null, {
|
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||||
deleteInvisibleElements: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const restoredArrow = restoredElements[0] as
|
const restoredArrow = restoredElements[0] as
|
||||||
| ExcalidrawArrowElement
|
| ExcalidrawArrowElement
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
expect(restoredArrow).not.toBeUndefined();
|
expect(restoredArrow).toBeUndefined();
|
||||||
expect(restoredArrow?.isDeleted).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||||
@@ -871,7 +848,6 @@ describe("repairing bindings", () => {
|
|||||||
let restoredElements = restore.restoreElements(
|
let restoredElements = restore.restoreElements(
|
||||||
[container, invisibleBoundElement, boundElement],
|
[container, invisibleBoundElement, boundElement],
|
||||||
null,
|
null,
|
||||||
{ deleteInvisibleElements: true },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(restoredElements).toEqual([
|
expect(restoredElements).toEqual([
|
||||||
@@ -879,11 +855,6 @@ describe("repairing bindings", () => {
|
|||||||
id: container.id,
|
id: container.id,
|
||||||
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
id: invisibleBoundElement.id,
|
|
||||||
containerId: container.id,
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: boundElement.id,
|
id: boundElement.id,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
@@ -893,7 +864,7 @@ describe("repairing bindings", () => {
|
|||||||
restoredElements = restore.restoreElements(
|
restoredElements = restore.restoreElements(
|
||||||
[container, invisibleBoundElement, boundElement],
|
[container, invisibleBoundElement, boundElement],
|
||||||
null,
|
null,
|
||||||
{ repairBindings: true, deleteInvisibleElements: true },
|
{ repairBindings: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(restoredElements).toEqual([
|
expect(restoredElements).toEqual([
|
||||||
@@ -901,11 +872,6 @@ describe("repairing bindings", () => {
|
|||||||
id: container.id,
|
id: container.id,
|
||||||
boundElements: [],
|
boundElements: [],
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
|
||||||
id: invisibleBoundElement.id,
|
|
||||||
containerId: container.id,
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: boundElement.id,
|
id: boundElement.id,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
|
|||||||
@@ -315,12 +315,7 @@ describe("Test dragCreate", () => {
|
|||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements.length).toEqual(0);
|
||||||
expect.objectContaining({
|
|
||||||
type: "arrow",
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line", async () => {
|
it("line", async () => {
|
||||||
@@ -349,12 +344,7 @@ describe("Test dragCreate", () => {
|
|||||||
);
|
);
|
||||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||||
expect(h.state.selectionElement).toBeNull();
|
expect(h.state.selectionElement).toBeNull();
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements.length).toEqual(0);
|
||||||
expect.objectContaining({
|
|
||||||
type: "line",
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { queryByText, queryByTestId } from "@testing-library/react";
|
import { queryByText, queryByTestId } from "@testing-library/react";
|
||||||
|
import React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { THEME } from "@excalidraw/common";
|
import { THEME } from "@excalidraw/common";
|
||||||
|
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { Excalidraw, Footer } from "..";
|
import { Excalidraw, Footer, MainMenu } from "../index";
|
||||||
import MainMenu from "../components/main-menu/MainMenu";
|
|
||||||
|
|
||||||
import {
|
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
|
||||||
render,
|
|
||||||
togglePopover,
|
|
||||||
fireEvent,
|
|
||||||
GlobalTestState,
|
|
||||||
} from "./test-utils";
|
|
||||||
|
|
||||||
const { h } = window;
|
const { h } = window;
|
||||||
|
|
||||||
@@ -20,7 +15,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
const menu = document.querySelector(".dropdown-menu");
|
const menu = document.querySelector(".dropdown-menu");
|
||||||
if (menu) {
|
if (menu) {
|
||||||
togglePopover("Main menu");
|
toggleMenu(document.querySelector(".excalidraw")!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +136,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
<Excalidraw UIOptions={undefined} />,
|
<Excalidraw UIOptions={undefined} />,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,7 +145,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
|
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
|
expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +154,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
|
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "json-export-button")).toBeNull();
|
expect(queryByTestId(container, "json-export-button")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,7 +163,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
|
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "image-export-button")).toBeNull();
|
expect(queryByTestId(container, "image-export-button")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +182,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "save-as-button")).toBeNull();
|
expect(queryByTestId(container, "save-as-button")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +193,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "save-button")).toBeNull();
|
expect(queryByTestId(container, "save-button")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,7 +204,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
||||||
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -225,7 +220,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
</Excalidraw>,
|
</Excalidraw>,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
||||||
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -235,7 +230,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
|
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
|
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,8 +251,8 @@ describe("<Excalidraw/>", () => {
|
|||||||
</Excalidraw>,
|
</Excalidraw>,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
|
toggleMenu(container);
|
||||||
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
|
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
|
||||||
togglePopover("Main menu");
|
|
||||||
expect(queryByTestId(container, "load-button")).toBeNull();
|
expect(queryByTestId(container, "load-button")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -268,7 +263,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
const { container } = await render(<Excalidraw />);
|
const { container } = await render(<Excalidraw />);
|
||||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||||
expect(darkModeToggle).toBeTruthy();
|
expect(darkModeToggle).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -278,7 +273,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
|
|
||||||
expect(h.state.theme).toBe(THEME.DARK);
|
expect(h.state.theme).toBe(THEME.DARK);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
|
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,7 +286,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
);
|
);
|
||||||
expect(h.state.theme).toBe(THEME.DARK);
|
expect(h.state.theme).toBe(THEME.DARK);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||||
expect(darkModeToggle).toBeTruthy();
|
expect(darkModeToggle).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -305,7 +300,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
);
|
);
|
||||||
expect(h.state.theme).toBe(THEME.DARK);
|
expect(h.state.theme).toBe(THEME.DARK);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||||
expect(darkModeToggle).toBe(null);
|
expect(darkModeToggle).toBe(null);
|
||||||
});
|
});
|
||||||
@@ -315,7 +310,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
it("should allow editing name", async () => {
|
it("should allow editing name", async () => {
|
||||||
const { container } = await render(<Excalidraw />);
|
const { container } = await render(<Excalidraw />);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
fireEvent.click(queryByTestId(container, "image-export-button")!);
|
fireEvent.click(queryByTestId(container, "image-export-button")!);
|
||||||
const textInput: HTMLInputElement | null = document.querySelector(
|
const textInput: HTMLInputElement | null = document.querySelector(
|
||||||
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
||||||
@@ -328,7 +323,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
const name = "test";
|
const name = "test";
|
||||||
const { container } = await render(<Excalidraw name={name} />);
|
const { container } = await render(<Excalidraw name={name} />);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
await fireEvent.click(queryByTestId(container, "image-export-button")!);
|
await fireEvent.click(queryByTestId(container, "image-export-button")!);
|
||||||
const textInput = document.querySelector(
|
const textInput = document.querySelector(
|
||||||
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
||||||
@@ -380,7 +375,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
</Excalidraw>,
|
</Excalidraw>,
|
||||||
);
|
);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -399,7 +394,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
|
|
||||||
const { container } = await render(<CustomExcalidraw />);
|
const { container } = await render(<CustomExcalidraw />);
|
||||||
//open menu
|
//open menu
|
||||||
togglePopover("Main menu");
|
toggleMenu(container);
|
||||||
|
|
||||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||||
|
|
||||||
|
|||||||
@@ -3043,14 +3043,15 @@ describe("history", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Keyboard.undo();
|
Keyboard.undo();
|
||||||
expect(API.getUndoStack().length).toBe(0);
|
expect(API.getUndoStack().length).toBe(3);
|
||||||
expect(API.getRedoStack().length).toBe(4);
|
expect(API.getRedoStack().length).toBe(1);
|
||||||
expect(h.state.selectedLinearElement).toBeNull();
|
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||||
|
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||||
|
|
||||||
Keyboard.redo();
|
Keyboard.redo();
|
||||||
expect(API.getUndoStack().length).toBe(4);
|
expect(API.getUndoStack().length).toBe(4);
|
||||||
expect(API.getRedoStack().length).toBe(0);
|
expect(API.getRedoStack().length).toBe(0);
|
||||||
expect(h.state.selectedLinearElement).toBeNull();
|
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4055,7 +4056,7 @@ describe("history", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: container.id,
|
id: container.id,
|
||||||
boundElements: [{ id: remoteText.id, type: "text" }],
|
boundElements: [{ id: remoteText.id, type: "text" }],
|
||||||
isDeleted: false,
|
isDeleted: false, // isDeleted got remotely updated to false
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: text.id,
|
id: text.id,
|
||||||
@@ -4064,6 +4065,7 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: remoteText.id,
|
id: remoteText.id,
|
||||||
|
// unbound
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
}),
|
}),
|
||||||
@@ -4354,8 +4356,8 @@ describe("history", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
...textProps,
|
...textProps,
|
||||||
// text element got redrawn!
|
// text element got redrawn!
|
||||||
x: 241.295259647664,
|
x: 205,
|
||||||
y: 247.59240920619527,
|
y: 205,
|
||||||
angle: 90,
|
angle: 90,
|
||||||
id: text.id,
|
id: text.id,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
@@ -4398,8 +4400,8 @@ describe("history", () => {
|
|||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
...textProps,
|
...textProps,
|
||||||
x: 241.295259647664,
|
x: 205,
|
||||||
y: 247.59240920619527,
|
y: 205,
|
||||||
angle: 90,
|
angle: 90,
|
||||||
id: text.id,
|
id: text.id,
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { queryByTestId } from "@testing-library/react";
|
import { act, queryByTestId } from "@testing-library/react";
|
||||||
import { act } from "@testing-library/react";
|
import React from "react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
|
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
|
||||||
@@ -13,11 +13,9 @@ import { serializeLibraryAsJSON } from "../data/json";
|
|||||||
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
import { Excalidraw } from "../index";
|
import { Excalidraw } from "../index";
|
||||||
|
|
||||||
import { fireEvent, render, togglePopover, waitFor } from "./test-utils";
|
|
||||||
|
|
||||||
import { API } from "./helpers/api";
|
import { API } from "./helpers/api";
|
||||||
import { UI } from "./helpers/ui";
|
import { UI } from "./helpers/ui";
|
||||||
import { getCloneByOrigId } from "./test-utils";
|
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
|
||||||
|
|
||||||
import type { LibraryItem, LibraryItems } from "../types";
|
import type { LibraryItem, LibraryItems } from "../types";
|
||||||
|
|
||||||
@@ -217,13 +215,12 @@ describe("library menu", () => {
|
|||||||
const libraryButton = container.querySelector(".sidebar-trigger");
|
const libraryButton = container.querySelector(".sidebar-trigger");
|
||||||
|
|
||||||
fireEvent.click(libraryButton!);
|
fireEvent.click(libraryButton!);
|
||||||
togglePopover("Library menu");
|
fireEvent.click(
|
||||||
// fireEvent.click(
|
queryByTestId(
|
||||||
// queryByTestId(
|
container.querySelector(".layer-ui__library")!,
|
||||||
// container.querySelector(".layer-ui__library")!,
|
"dropdown-menu-button",
|
||||||
// "dropdown-menu-button",
|
)!,
|
||||||
// )!,
|
);
|
||||||
// );
|
|
||||||
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
|
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
|
||||||
|
|
||||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||||
|
|||||||
@@ -2,46 +2,26 @@
|
|||||||
|
|
||||||
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
|
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
|
||||||
<div
|
<div
|
||||||
aria-labelledby="radix-:r65:"
|
class="dropdown-menu"
|
||||||
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"
|
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
|
<div
|
||||||
class="Island dropdown-menu-container"
|
class="Island dropdown-menu-container"
|
||||||
style="--padding: 1; z-index: 2;"
|
style="--padding: 2; z-index: 2;"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
class="radix-menu-item"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
data-orientation="vertical"
|
type="button"
|
||||||
data-radix-collection-item=""
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="-1"
|
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item__icon"
|
||||||
type="button"
|
/>
|
||||||
|
<div
|
||||||
|
class="dropdown-menu-item__text"
|
||||||
>
|
>
|
||||||
<div
|
Click me
|
||||||
class="dropdown-menu-item__icon"
|
</div>
|
||||||
/>
|
</button>
|
||||||
<div
|
|
||||||
class="dropdown-menu-item__text"
|
|
||||||
>
|
|
||||||
Click me
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<a
|
<a
|
||||||
class="dropdown-menu-item dropdown-menu-item-base"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
href="https://plus.excalidraw.com/blog"
|
href="https://plus.excalidraw.com/blog"
|
||||||
@@ -66,361 +46,301 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
|||||||
custom menu item
|
custom menu item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button
|
||||||
class="radix-menu-item"
|
aria-label="Help"
|
||||||
data-orientation="vertical"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
data-radix-collection-item=""
|
data-testid="help-menu-item"
|
||||||
role="menuitem"
|
title="Help"
|
||||||
tabindex="-1"
|
type="button"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
aria-label="Help"
|
class="dropdown-menu-item__icon"
|
||||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
|
||||||
data-testid="help-menu-item"
|
|
||||||
title="Help"
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
class="dropdown-menu-item__icon"
|
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
|
<g
|
||||||
aria-hidden="true"
|
stroke-width="1.5"
|
||||||
class=""
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<g
|
<path
|
||||||
stroke-width="1.5"
|
d="M0 0h24v24H0z"
|
||||||
>
|
fill="none"
|
||||||
<path
|
stroke="none"
|
||||||
d="M0 0h24v24H0z"
|
/>
|
||||||
fill="none"
|
<circle
|
||||||
stroke="none"
|
cx="12"
|
||||||
/>
|
cy="12"
|
||||||
<circle
|
r="9"
|
||||||
cx="12"
|
/>
|
||||||
cy="12"
|
<line
|
||||||
r="9"
|
x1="12"
|
||||||
/>
|
x2="12"
|
||||||
<line
|
y1="17"
|
||||||
x1="12"
|
y2="17.01"
|
||||||
x2="12"
|
/>
|
||||||
y1="17"
|
<path
|
||||||
y2="17.01"
|
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||||
/>
|
/>
|
||||||
<path
|
</g>
|
||||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
</svg>
|
||||||
/>
|
</div>
|
||||||
</g>
|
<div
|
||||||
</svg>
|
class="dropdown-menu-item__text"
|
||||||
</div>
|
>
|
||||||
<div
|
Help
|
||||||
class="dropdown-menu-item__text"
|
</div>
|
||||||
>
|
<div
|
||||||
Help
|
class="dropdown-menu-item__shortcut"
|
||||||
</div>
|
>
|
||||||
<div
|
?
|
||||||
class="dropdown-menu-item__shortcut"
|
</div>
|
||||||
>
|
</button>
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
|
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
|
||||||
<div
|
<div
|
||||||
aria-labelledby="radix-:rq:"
|
class="dropdown-menu"
|
||||||
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"
|
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
|
<div
|
||||||
class="Island dropdown-menu-container"
|
class="Island dropdown-menu-container"
|
||||||
style="--padding: 1; z-index: 2;"
|
style="--padding: 2; z-index: 2;"
|
||||||
>
|
>
|
||||||
<div
|
<button
|
||||||
class="radix-menu-item"
|
aria-label="Open"
|
||||||
data-orientation="vertical"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
data-radix-collection-item=""
|
data-testid="load-button"
|
||||||
role="menuitem"
|
title="Open"
|
||||||
tabindex="-1"
|
type="button"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
aria-label="Open"
|
class="dropdown-menu-item__icon"
|
||||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
|
||||||
data-testid="load-button"
|
|
||||||
title="Open"
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
class="dropdown-menu-item__icon"
|
aria-hidden="true"
|
||||||
|
class=""
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
aria-hidden="true"
|
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"
|
||||||
class=""
|
stroke-width="1.25"
|
||||||
fill="none"
|
/>
|
||||||
focusable="false"
|
</svg>
|
||||||
role="img"
|
</div>
|
||||||
stroke="currentColor"
|
<div
|
||||||
stroke-linecap="round"
|
class="dropdown-menu-item__text"
|
||||||
stroke-linejoin="round"
|
>
|
||||||
viewBox="0 0 20 20"
|
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"
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
d="M0 0h24v24H0z"
|
||||||
stroke-width="1.25"
|
fill="none"
|
||||||
|
stroke="none"
|
||||||
/>
|
/>
|
||||||
</svg>
|
<path
|
||||||
</div>
|
d="M15 8h.01"
|
||||||
<div
|
/>
|
||||||
class="dropdown-menu-item__text"
|
<path
|
||||||
>
|
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
|
||||||
Open
|
/>
|
||||||
</div>
|
<path
|
||||||
<div
|
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
|
||||||
class="dropdown-menu-item__shortcut"
|
/>
|
||||||
>
|
<path
|
||||||
Ctrl+O
|
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
|
||||||
</div>
|
/>
|
||||||
</button>
|
<path
|
||||||
</div>
|
d="M19 16v6"
|
||||||
<div
|
/>
|
||||||
class="radix-menu-item"
|
<path
|
||||||
data-orientation="vertical"
|
d="M22 19l-3 3l-3 -3"
|
||||||
data-radix-collection-item=""
|
/>
|
||||||
role="menuitem"
|
</g>
|
||||||
tabindex="-1"
|
</svg>
|
||||||
>
|
</div>
|
||||||
<button
|
<div
|
||||||
aria-label="Save to..."
|
class="dropdown-menu-item__text"
|
||||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
|
||||||
data-testid="json-export-button"
|
|
||||||
title="Save to..."
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<div
|
Export image...
|
||||||
class="dropdown-menu-item__icon"
|
</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
|
<g
|
||||||
aria-hidden="true"
|
stroke-width="1.5"
|
||||||
class=""
|
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
>
|
||||||
<path
|
<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"
|
d="M0 0h24v24H0z"
|
||||||
stroke-width="1.25"
|
fill="none"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="9"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="17"
|
||||||
|
y2="17.01"
|
||||||
/>
|
/>
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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
|
<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"
|
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||||
stroke-width="1.25"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</g>
|
||||||
</div>
|
</svg>
|
||||||
<div
|
</div>
|
||||||
class="dropdown-menu-item__text"
|
<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"
|
||||||
>
|
>
|
||||||
Reset the canvas
|
<path
|
||||||
</div>
|
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"
|
||||||
</button>
|
stroke-width="1.25"
|
||||||
</div>
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="dropdown-menu-item__text"
|
||||||
|
>
|
||||||
|
Reset the canvas
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
<div
|
<div
|
||||||
style="height: 1px; margin: .5rem 0px;"
|
style="height: 1px; margin: .5rem 0px;"
|
||||||
/>
|
/>
|
||||||
@@ -553,53 +473,45 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
<div
|
<div
|
||||||
style="height: 1px; margin: .5rem 0px;"
|
style="height: 1px; margin: .5rem 0px;"
|
||||||
/>
|
/>
|
||||||
<div
|
<button
|
||||||
class="radix-menu-item"
|
aria-label="Dark mode"
|
||||||
data-orientation="vertical"
|
class="dropdown-menu-item dropdown-menu-item-base"
|
||||||
data-radix-collection-item=""
|
data-testid="toggle-dark-mode"
|
||||||
role="menuitem"
|
title="Dark mode"
|
||||||
tabindex="-1"
|
type="button"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
aria-label="Dark mode"
|
class="dropdown-menu-item__icon"
|
||||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
|
||||||
data-testid="toggle-dark-mode"
|
|
||||||
title="Dark mode"
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
class="dropdown-menu-item__icon"
|
aria-hidden="true"
|
||||||
|
class=""
|
||||||
|
fill="none"
|
||||||
|
focusable="false"
|
||||||
|
role="img"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
aria-hidden="true"
|
clip-rule="evenodd"
|
||||||
class=""
|
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"
|
||||||
fill="none"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-linecap="round"
|
/>
|
||||||
stroke-linejoin="round"
|
</svg>
|
||||||
viewBox="0 0 20 20"
|
</div>
|
||||||
>
|
<div
|
||||||
<path
|
class="dropdown-menu-item__text"
|
||||||
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"
|
Dark mode
|
||||||
stroke="currentColor"
|
</div>
|
||||||
/>
|
<div
|
||||||
</svg>
|
class="dropdown-menu-item__shortcut"
|
||||||
</div>
|
>
|
||||||
<div
|
Shift+Alt+D
|
||||||
class="dropdown-menu-item__text"
|
</div>
|
||||||
>
|
</button>
|
||||||
Dark mode
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="dropdown-menu-item__shortcut"
|
|
||||||
>
|
|
||||||
Shift+Alt+D
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
style="margin-top: 0.5rem;"
|
style="margin-top: 0.5rem;"
|
||||||
>
|
>
|
||||||
@@ -610,7 +522,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
Canvas background
|
Canvas background
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="canvas-bg-color-picker-container"
|
|
||||||
style="padding: 0px 0.625rem;"
|
style="padding: 0px 0.625rem;"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -682,7 +593,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
style="width: 1px; height: 100%; margin: 0px auto;"
|
style="width: 1px; height: 100%; margin: 0px auto;"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
aria-controls="radix-:r12:"
|
aria-controls="radix-:r0:"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-label="Canvas background"
|
aria-label="Canvas background"
|
||||||
|
|||||||
@@ -248,10 +248,8 @@ export type ObservedElementsAppState = {
|
|||||||
editingGroupId: AppState["editingGroupId"];
|
editingGroupId: AppState["editingGroupId"];
|
||||||
selectedElementIds: AppState["selectedElementIds"];
|
selectedElementIds: AppState["selectedElementIds"];
|
||||||
selectedGroupIds: AppState["selectedGroupIds"];
|
selectedGroupIds: AppState["selectedGroupIds"];
|
||||||
selectedLinearElement: {
|
selectedLinearElementId: LinearElementEditor["elementId"] | null;
|
||||||
elementId: LinearElementEditor["elementId"];
|
selectedLinearElementIsEditing: boolean | null;
|
||||||
isEditing: boolean;
|
|
||||||
} | null;
|
|
||||||
croppingElementId: AppState["croppingElementId"];
|
croppingElementId: AppState["croppingElementId"];
|
||||||
lockedMultiSelections: AppState["lockedMultiSelections"];
|
lockedMultiSelections: AppState["lockedMultiSelections"];
|
||||||
activeLockedId: AppState["activeLockedId"];
|
activeLockedId: AppState["activeLockedId"];
|
||||||
@@ -731,8 +729,6 @@ export type AppClassProperties = {
|
|||||||
|
|
||||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||||
updateEditorAtom: App["updateEditorAtom"];
|
updateEditorAtom: App["updateEditorAtom"];
|
||||||
|
|
||||||
defaultSelectionTool: "selection" | "lasso";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointerDownState = Readonly<{
|
export type PointerDownState = Readonly<{
|
||||||
@@ -782,10 +778,6 @@ export type PointerDownState = Readonly<{
|
|||||||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||||||
// to current pointer position at time of duplication.
|
// to current pointer position at time of duplication.
|
||||||
origin: { x: number; y: number };
|
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
|
// We need to have these in the state so that we can unsubscribe them
|
||||||
eventListeners: {
|
eventListeners: {
|
||||||
@@ -807,7 +799,6 @@ export type UnsubscribeCallback = () => void;
|
|||||||
|
|
||||||
export interface ExcalidrawImperativeAPI {
|
export interface ExcalidrawImperativeAPI {
|
||||||
updateScene: InstanceType<typeof App>["updateScene"];
|
updateScene: InstanceType<typeof App>["updateScene"];
|
||||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
|
||||||
mutateElement: InstanceType<typeof App>["mutateElement"];
|
mutateElement: InstanceType<typeof App>["mutateElement"];
|
||||||
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
||||||
resetScene: InstanceType<typeof App>["resetScene"];
|
resetScene: InstanceType<typeof App>["resetScene"];
|
||||||
|
|||||||
@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
|
|||||||
rectangle.x + rectangle.width / 2,
|
rectangle.x + rectangle.width / 2,
|
||||||
rectangle.y + rectangle.height / 2,
|
rectangle.y + rectangle.height / 2,
|
||||||
);
|
);
|
||||||
expect(h.elements.length).toBe(3);
|
expect(h.elements.length).toBe(2);
|
||||||
|
|
||||||
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||||
expect(text.type).toBe("text");
|
expect(text.type).toBe("text");
|
||||||
@@ -1198,11 +1198,7 @@ describe("textWysiwyg", () => {
|
|||||||
updateTextEditor(editor, " ");
|
updateTextEditor(editor, " ");
|
||||||
Keyboard.exitTextEditor(editor);
|
Keyboard.exitTextEditor(editor);
|
||||||
expect(rectangle.boundElements).toStrictEqual([]);
|
expect(rectangle.boundElements).toStrictEqual([]);
|
||||||
expect(h.elements[1]).toEqual(
|
expect(h.elements[1]).toBeUndefined();
|
||||||
expect.objectContaining({
|
|
||||||
isDeleted: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||||
|
|||||||
@@ -215,12 +215,11 @@ export const textWysiwyg = ({
|
|||||||
);
|
);
|
||||||
app.scene.mutateElement(container, { height: targetContainerHeight });
|
app.scene.mutateElement(container, { height: targetContainerHeight });
|
||||||
} else {
|
} else {
|
||||||
const { x, y } = computeBoundTextPosition(
|
const { y } = computeBoundTextPosition(
|
||||||
container,
|
container,
|
||||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
coordX = x;
|
|
||||||
coordY = y;
|
coordY = y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const exportToCanvas = ({
|
|||||||
{ elements, appState },
|
{ elements, appState },
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{ deleteInvisibleElements: true },
|
|
||||||
);
|
);
|
||||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||||
return _exportToCanvas(
|
return _exportToCanvas(
|
||||||
@@ -180,7 +179,6 @@ export const exportToSvg = async ({
|
|||||||
{ elements, appState },
|
{ elements, appState },
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{ deleteInvisibleElements: true },
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportAppState = {
|
const exportAppState = {
|
||||||
|
|||||||
+2
-2
@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rl.question(
|
rl.question(
|
||||||
"Would you like to commit these changes to git? (Y/n): ",
|
"Do you want to commit these changes to git? (Y/n): ",
|
||||||
(answer) => {
|
(answer) => {
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rl.question(
|
rl.question(
|
||||||
"Would you like to publish these changes to npm? (Y/n): ",
|
"Do you want to publish these changes to npm? (Y/n): ",
|
||||||
(answer) => {
|
(answer) => {
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user