Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06dae6edf2 | |||
| ecbaeb1701 | |||
| 40c5c743b1 | |||
| ae89608985 | |||
| 3085f4af81 | |||
| 531f3e5524 | |||
| 90ec2739ae | |||
| f29e9df72d | |||
| b5ad7ae4e3 | |||
| c78e4aab7f | |||
| b4903a7eab | |||
| c6f8ef9ad2 | |||
| 2535d73054 | |||
| dda3affcb0 | |||
| 54c148f390 | |||
| cc8e490c75 | |||
| 9036812b6d | |||
| df25de7e68 | |||
| a3763648fe | |||
| 178eca5828 | |||
| cb33de25f4 | |||
| 37ad85cbaf | |||
| d6a934ed19 | |||
| 416da62138 | |||
| f38f381989 | |||
| e5e07260c6 | |||
| 8492b144b0 | |||
| e46f038132 | |||
| 678dff25ed | |||
| 0cfa53b764 | |||
| cde46793f8 | |||
| 2d127f8c22 | |||
| 4eadb891f8 | |||
| 5082142b36 | |||
| 74cb027fd7 | |||
| bc09ac757f | |||
| 66e347f7d2 | |||
| d5974e66b2 | |||
| 2a1b22a504 | |||
| b3d241ba7f | |||
| 8ff1ac8097 | |||
| d967123383 | |||
| 05cd1a79cc | |||
| bd08bdf4c7 | |||
| 011b268dde | |||
| b6a7f05761 | |||
| 8787c7d8cf | |||
| 6d21d7cab1 | |||
| c9df3e143b | |||
| 5b11660cc0 | |||
| bf0b2965e6 | |||
| 8f8b6e7144 | |||
| b63d17045e | |||
| 70d48d5472 | |||
| 097000a2b7 | |||
| 461661afc6 | |||
| c88f3c84eb | |||
| 7d791b86f8 | |||
| e615056302 | |||
| 14ad745d00 | |||
| 9c3ff73a73 | |||
| 79cf71cccb | |||
| e094b8b539 |
@@ -33,6 +33,7 @@ const ExcalidrawScope = {
|
||||
initialData,
|
||||
useI18n: ExcalidrawComp.useI18n,
|
||||
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
|
||||
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
|
||||
};
|
||||
|
||||
export default ExcalidrawScope;
|
||||
|
||||
@@ -615,6 +615,52 @@ export default function ExampleApp({
|
||||
const renderMenu = () => {
|
||||
return (
|
||||
<MainMenu>
|
||||
<MainMenu.Sub>
|
||||
<MainMenu.Sub.Trigger
|
||||
title="Custom trigger"
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Submenu trigger
|
||||
</MainMenu.Sub.Trigger>
|
||||
<MainMenu.Sub.Content>
|
||||
<MainMenu.Sub.Item
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
onSelect={() => window.alert("You clicked on sub item")}
|
||||
>
|
||||
Sub item
|
||||
</MainMenu.Sub.Item>
|
||||
</MainMenu.Sub.Content>
|
||||
</MainMenu.Sub>
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.Separator />
|
||||
@@ -622,10 +668,57 @@ export default function ExampleApp({
|
||||
isCollaborating={isCollaborating}
|
||||
onSelect={() => window.alert("You clicked on collab button")}
|
||||
/>
|
||||
<MainMenu.Sub>
|
||||
<MainMenu.Sub.Trigger>Trigger</MainMenu.Sub.Trigger>
|
||||
<MainMenu.Sub.Content>
|
||||
<MainMenu.Sub.Item
|
||||
onSelect={() => window.alert("You clicked on sub item")}
|
||||
>
|
||||
Sub item
|
||||
</MainMenu.Sub.Item>
|
||||
</MainMenu.Sub.Content>
|
||||
</MainMenu.Sub>
|
||||
<MainMenu.Group title="Excalidraw links">
|
||||
<MainMenu.DefaultItems.Socials />
|
||||
</MainMenu.Group>
|
||||
<MainMenu.Separator />
|
||||
{/* <MainMenu.Separator /> */}
|
||||
<MainMenu.Sub>
|
||||
<MainMenu.Sub.Trigger className="custom-classname">
|
||||
Another submenu trigger
|
||||
</MainMenu.Sub.Trigger>
|
||||
<MainMenu.Sub.Content className="custom-classname-for-content">
|
||||
<MainMenu.Sub.Item
|
||||
title="Sub item"
|
||||
onSelect={() => window.alert("You clicked on sub item")}
|
||||
>
|
||||
Sub item
|
||||
</MainMenu.Sub.Item>
|
||||
</MainMenu.Sub.Content>
|
||||
</MainMenu.Sub>
|
||||
<MainMenu.Sub>
|
||||
<MainMenu.Sub.Trigger>Trigger me</MainMenu.Sub.Trigger>
|
||||
<MainMenu.Sub.Content>
|
||||
<MainMenu.Sub>
|
||||
<MainMenu.Sub.Trigger>Trigger me inside</MainMenu.Sub.Trigger>
|
||||
<MainMenu.Sub.Content>
|
||||
<MainMenu.Sub.Item
|
||||
onSelect={() => {
|
||||
alert("wow, nested submenus!");
|
||||
}}
|
||||
>
|
||||
Item wow
|
||||
</MainMenu.Sub.Item>
|
||||
</MainMenu.Sub.Content>
|
||||
</MainMenu.Sub>
|
||||
<MainMenu.Sub.Item
|
||||
onSelect={() => {
|
||||
alert("wow, nested submenus! very cool");
|
||||
}}
|
||||
>
|
||||
Another one
|
||||
</MainMenu.Sub.Item>
|
||||
</MainMenu.Sub.Content>
|
||||
</MainMenu.Sub>
|
||||
<MainMenu.ItemCustom>
|
||||
<button
|
||||
style={{ height: "2rem" }}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
THEME,
|
||||
TITLE_TIMEOUT,
|
||||
VERSION_TIMEOUT,
|
||||
debounce,
|
||||
getVersion,
|
||||
@@ -499,11 +498,6 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const titleTimeout = setTimeout(
|
||||
() => (document.title = APP_NAME),
|
||||
TITLE_TIMEOUT,
|
||||
);
|
||||
|
||||
const syncData = debounce(() => {
|
||||
if (isTestEnv()) {
|
||||
return;
|
||||
@@ -594,7 +588,6 @@ const ExcalidrawWrapper = () => {
|
||||
visibilityChange,
|
||||
false,
|
||||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export const AppMainMenu: React.FC<{
|
||||
<MainMenu.DefaultItems.SearchMenu />
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.DefaultItems.Preferences />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.ItemLink
|
||||
icon={ExcalLogo}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@excalidraw/excalidraw/renderer/helpers";
|
||||
import { type AppState } from "@excalidraw/excalidraw/types";
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
import { useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import {
|
||||
isLineSegment,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "@excalidraw/math";
|
||||
import { isCurve } from "@excalidraw/math/curve";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import type { Curve } from "@excalidraw/math";
|
||||
|
||||
import type { DebugElement } from "@excalidraw/utils/visualdebug";
|
||||
@@ -113,10 +115,6 @@ const _debugRenderer = (
|
||||
scale,
|
||||
);
|
||||
|
||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const context = bootstrapCanvas({
|
||||
canvas,
|
||||
scale,
|
||||
@@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
|
||||
interface DebugCanvasProps {
|
||||
appState: AppState;
|
||||
scale: number;
|
||||
ref?: React.Ref<HTMLCanvasElement>;
|
||||
}
|
||||
|
||||
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
|
||||
const { width, height } = appState;
|
||||
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
|
||||
({ appState, scale }, ref) => {
|
||||
const { width, height } = appState;
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
|
||||
ref,
|
||||
() => canvasRef.current,
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={canvasRef}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<canvas
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: "absolute",
|
||||
zIndex: 2,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
width={width * scale}
|
||||
height={height * scale}
|
||||
ref={ref}
|
||||
>
|
||||
Debug Canvas
|
||||
</canvas>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default DebugCanvas;
|
||||
|
||||
@@ -259,7 +259,9 @@ export const loadFromFirebase = async (
|
||||
}
|
||||
const storedScene = docSnap.data() as FirebaseStoredScene;
|
||||
const elements = getSyncableElements(
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null),
|
||||
restoreElements(await decryptElements(storedScene, roomKey), null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket) {
|
||||
|
||||
@@ -258,11 +258,16 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
repairBindings: true,
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</title>
|
||||
<title>Excalidraw Whiteboard</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta
|
||||
name="title"
|
||||
content="Excalidraw — Collaborative whiteboarding made easy"
|
||||
content="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
|
||||
@@ -18,13 +18,20 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
|
||||
export const isSafari =
|
||||
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
|
||||
export const isIOS =
|
||||
/iPad|iPhone/.test(navigator.platform) ||
|
||||
/iPad|iPhone/i.test(navigator.platform) ||
|
||||
// iPadOS 13+
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||
// keeping function so it can be mocked in test
|
||||
export const isBrave = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const isMobile =
|
||||
isIOS ||
|
||||
/android|webos|ipod|blackberry|iemobile|opera mini/i.test(
|
||||
navigator.userAgent,
|
||||
) ||
|
||||
/android|ios|ipod|blackberry|windows phone/i.test(navigator.platform);
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
@@ -36,6 +43,7 @@ export const APP_NAME = "Excalidraw";
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const MINIMUM_ARROW_SIZE = 20; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
export const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
@@ -514,3 +522,5 @@ export enum UserIdleState {
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||
|
||||
@@ -164,9 +164,14 @@ export class Scene {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
constructor(elements: ElementsMapOrArray | null = null) {
|
||||
constructor(
|
||||
elements: ElementsMapOrArray | null = null,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
if (elements) {
|
||||
this.replaceAllElements(elements);
|
||||
this.replaceAllElements(elements, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,12 +268,19 @@ export class Scene {
|
||||
return didChange;
|
||||
}
|
||||
|
||||
replaceAllElements(nextElements: ElementsMapOrArray) {
|
||||
replaceAllElements(
|
||||
nextElements: ElementsMapOrArray,
|
||||
options?: {
|
||||
skipValidation?: true;
|
||||
},
|
||||
) {
|
||||
// we do trust the insertion order on the map, though maybe we shouldn't and should prefer order defined by fractional indices
|
||||
const _nextElements = toArray(nextElements);
|
||||
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
|
||||
|
||||
validateIndicesThrottled(_nextElements);
|
||||
if (!options?.skipValidation) {
|
||||
validateIndicesThrottled(_nextElements);
|
||||
}
|
||||
|
||||
this.elements = syncInvalidIndices(_nextElements);
|
||||
this.elementsMap.clear();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { updateBoundElements } from "./binding";
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
@@ -16,11 +18,12 @@ export const alignElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
alignment: Alignment,
|
||||
scene: Scene,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const groups: ExcalidrawElement[][] = getMaximumGroups(
|
||||
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
appState,
|
||||
);
|
||||
const selectionBoundingBox = getCommonBoundingBox(selectedElements);
|
||||
|
||||
|
||||
+360
-135
@@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
|
||||
|
||||
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import { StoreSnapshot } from "./store";
|
||||
|
||||
import { Scene } from "./Scene";
|
||||
|
||||
import type { BindableProp, BindingProp } from "./binding";
|
||||
|
||||
import type { ElementUpdate } from "./mutateElement";
|
||||
@@ -150,13 +150,27 @@ export class Delta<T> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two deltas into a new one.
|
||||
*/
|
||||
public static merge<T>(
|
||||
delta1: Delta<T>,
|
||||
delta2: Delta<T>,
|
||||
delta3: Delta<T> = Delta.empty(),
|
||||
) {
|
||||
return Delta.create(
|
||||
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
|
||||
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges deleted and inserted object partials.
|
||||
*/
|
||||
public static mergeObjects<T extends { [key: string]: unknown }>(
|
||||
prev: T,
|
||||
added: T,
|
||||
removed: T,
|
||||
removed: T = {} as T,
|
||||
) {
|
||||
const cloned = { ...prev };
|
||||
|
||||
@@ -496,6 +510,11 @@ export interface DeltaContainer<T> {
|
||||
*/
|
||||
applyTo(previous: T, ...options: unknown[]): [T, boolean];
|
||||
|
||||
/**
|
||||
* Squashes the current delta with the given one.
|
||||
*/
|
||||
squash(delta: DeltaContainer<T>): this;
|
||||
|
||||
/**
|
||||
* Checks whether all `Delta`s are empty.
|
||||
*/
|
||||
@@ -503,7 +522,11 @@ export interface DeltaContainer<T> {
|
||||
}
|
||||
|
||||
export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
private constructor(public readonly delta: Delta<ObservedAppState>) {}
|
||||
private constructor(public delta: Delta<ObservedAppState>) {}
|
||||
|
||||
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
|
||||
return new AppStateDelta(delta);
|
||||
}
|
||||
|
||||
public static calculate<T extends ObservedAppState>(
|
||||
prevAppState: T,
|
||||
@@ -534,53 +557,124 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return new AppStateDelta(inversedDelta);
|
||||
}
|
||||
|
||||
public squash(delta: AppStateDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const mergedDeletedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedElementIds ?? {},
|
||||
delta.delta.deleted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedElementIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedElementIds ?? {},
|
||||
delta.delta.inserted.selectedElementIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.deleted.selectedGroupIds ?? {},
|
||||
delta.delta.deleted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedSelectedGroupIds = Delta.mergeObjects(
|
||||
this.delta.inserted.selectedGroupIds ?? {},
|
||||
delta.delta.inserted.selectedGroupIds ?? {},
|
||||
);
|
||||
|
||||
const mergedDeletedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.deleted.lockedMultiSelections ?? {},
|
||||
delta.delta.deleted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInsertedLockedMultiSelections = Delta.mergeObjects(
|
||||
this.delta.inserted.lockedMultiSelections ?? {},
|
||||
delta.delta.inserted.lockedMultiSelections ?? {},
|
||||
);
|
||||
|
||||
const mergedInserted: Partial<ObservedAppState> = {};
|
||||
const mergedDeleted: Partial<ObservedAppState> = {};
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedElementIds).length ||
|
||||
Object.keys(mergedInsertedSelectedElementIds).length
|
||||
) {
|
||||
mergedDeleted.selectedElementIds = mergedDeletedSelectedElementIds;
|
||||
mergedInserted.selectedElementIds = mergedInsertedSelectedElementIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedSelectedGroupIds).length ||
|
||||
Object.keys(mergedInsertedSelectedGroupIds).length
|
||||
) {
|
||||
mergedDeleted.selectedGroupIds = mergedDeletedSelectedGroupIds;
|
||||
mergedInserted.selectedGroupIds = mergedInsertedSelectedGroupIds;
|
||||
}
|
||||
|
||||
if (
|
||||
Object.keys(mergedDeletedLockedMultiSelections).length ||
|
||||
Object.keys(mergedInsertedLockedMultiSelections).length
|
||||
) {
|
||||
mergedDeleted.lockedMultiSelections = mergedDeletedLockedMultiSelections;
|
||||
mergedInserted.lockedMultiSelections =
|
||||
mergedInsertedLockedMultiSelections;
|
||||
}
|
||||
|
||||
this.delta = Delta.merge(
|
||||
this.delta,
|
||||
delta.delta,
|
||||
Delta.create(mergedDeleted, mergedInserted),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public applyTo(
|
||||
appState: AppState,
|
||||
nextElements: SceneElementsMap,
|
||||
): [AppState, boolean] {
|
||||
try {
|
||||
const {
|
||||
selectedElementIds: removedSelectedElementIds = {},
|
||||
selectedGroupIds: removedSelectedGroupIds = {},
|
||||
selectedElementIds: deletedSelectedElementIds = {},
|
||||
selectedGroupIds: deletedSelectedGroupIds = {},
|
||||
lockedMultiSelections: deletedLockedMultiSelections = {},
|
||||
} = this.delta.deleted;
|
||||
|
||||
const {
|
||||
selectedElementIds: addedSelectedElementIds = {},
|
||||
selectedGroupIds: addedSelectedGroupIds = {},
|
||||
selectedLinearElementId,
|
||||
editingLinearElementId,
|
||||
selectedElementIds: insertedSelectedElementIds = {},
|
||||
selectedGroupIds: insertedSelectedGroupIds = {},
|
||||
lockedMultiSelections: insertedLockedMultiSelections = {},
|
||||
selectedLinearElement: insertedSelectedLinearElement,
|
||||
...directlyApplicablePartial
|
||||
} = this.delta.inserted;
|
||||
|
||||
const mergedSelectedElementIds = Delta.mergeObjects(
|
||||
appState.selectedElementIds,
|
||||
addedSelectedElementIds,
|
||||
removedSelectedElementIds,
|
||||
insertedSelectedElementIds,
|
||||
deletedSelectedElementIds,
|
||||
);
|
||||
|
||||
const mergedSelectedGroupIds = Delta.mergeObjects(
|
||||
appState.selectedGroupIds,
|
||||
addedSelectedGroupIds,
|
||||
removedSelectedGroupIds,
|
||||
insertedSelectedGroupIds,
|
||||
deletedSelectedGroupIds,
|
||||
);
|
||||
|
||||
const mergedLockedMultiSelections = Delta.mergeObjects(
|
||||
appState.lockedMultiSelections,
|
||||
insertedLockedMultiSelections,
|
||||
deletedLockedMultiSelections,
|
||||
);
|
||||
|
||||
const selectedLinearElement =
|
||||
selectedLinearElementId && nextElements.has(selectedLinearElementId)
|
||||
insertedSelectedLinearElement &&
|
||||
nextElements.has(insertedSelectedLinearElement.elementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
selectedLinearElementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
)
|
||||
: null;
|
||||
|
||||
const editingLinearElement =
|
||||
editingLinearElementId && nextElements.has(editingLinearElementId)
|
||||
? new LinearElementEditor(
|
||||
nextElements.get(
|
||||
editingLinearElementId,
|
||||
insertedSelectedLinearElement.elementId,
|
||||
) as NonDeleted<ExcalidrawLinearElement>,
|
||||
nextElements,
|
||||
insertedSelectedLinearElement.isEditing,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -589,14 +683,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
...directlyApplicablePartial,
|
||||
selectedElementIds: mergedSelectedElementIds,
|
||||
selectedGroupIds: mergedSelectedGroupIds,
|
||||
lockedMultiSelections: mergedLockedMultiSelections,
|
||||
selectedLinearElement:
|
||||
typeof selectedLinearElementId !== "undefined"
|
||||
? selectedLinearElement // element was either inserted or deleted
|
||||
: appState.selectedLinearElement, // otherwise assign what we had before
|
||||
editingLinearElement:
|
||||
typeof editingLinearElementId !== "undefined"
|
||||
? editingLinearElement // element was either inserted or deleted
|
||||
: appState.editingLinearElement, // otherwise assign what we had before
|
||||
typeof insertedSelectedLinearElement !== "undefined"
|
||||
? selectedLinearElement
|
||||
: appState.selectedLinearElement,
|
||||
};
|
||||
|
||||
const constainsVisibleChanges = this.filterInvisibleChanges(
|
||||
@@ -725,52 +816,53 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
}
|
||||
|
||||
break;
|
||||
case "selectedLinearElementId":
|
||||
case "editingLinearElementId":
|
||||
const appStateKey = AppStateDelta.convertToAppStateKey(key);
|
||||
const linearElement = nextAppState[appStateKey];
|
||||
case "selectedLinearElement":
|
||||
const nextLinearElement = nextAppState[key];
|
||||
|
||||
if (!linearElement) {
|
||||
if (!nextLinearElement) {
|
||||
// previously there was a linear element (assuming visible), now there is none
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
const element = nextElements.get(linearElement.elementId);
|
||||
const element = nextElements.get(nextLinearElement.elementId);
|
||||
|
||||
if (element && !element.isDeleted) {
|
||||
// previously there wasn't a linear element, now there is one which is visible
|
||||
visibleDifferenceFlag.value = true;
|
||||
} else {
|
||||
// there was assigned a linear element now, but it's deleted
|
||||
nextAppState[appStateKey] = null;
|
||||
nextAppState[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case "lockedMultiSelections": {
|
||||
case "lockedMultiSelections":
|
||||
const prevLockedUnits = prevAppState[key] || {};
|
||||
const nextLockedUnits = nextAppState[key] || {};
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (!isShallowEqual(prevLockedUnits, nextLockedUnits)) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeLockedId": {
|
||||
case "activeLockedId":
|
||||
const prevHitLockedId = prevAppState[key] || null;
|
||||
const nextHitLockedId = nextAppState[key] || null;
|
||||
|
||||
// TODO: this seems wrong, we are already doing this comparison generically above,
|
||||
// hence instead we should check whether elements are actually visible,
|
||||
// so that once these changes are applied they actually result in a visible change to the user
|
||||
if (prevHitLockedId !== nextHitLockedId) {
|
||||
visibleDifferenceFlag.value = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
default:
|
||||
assertNever(
|
||||
key,
|
||||
`Unknown ObservedElementsAppState's key "${key}"`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,20 +870,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
return visibleDifferenceFlag.value;
|
||||
}
|
||||
|
||||
private static convertToAppStateKey(
|
||||
key: keyof Pick<
|
||||
ObservedElementsAppState,
|
||||
"selectedLinearElementId" | "editingLinearElementId"
|
||||
>,
|
||||
): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
|
||||
switch (key) {
|
||||
case "selectedLinearElementId":
|
||||
return "selectedLinearElement";
|
||||
case "editingLinearElementId":
|
||||
return "editingLinearElement";
|
||||
}
|
||||
}
|
||||
|
||||
private static filterSelectedElements(
|
||||
selectedElementIds: AppState["selectedElementIds"],
|
||||
elements: SceneElementsMap,
|
||||
@@ -856,8 +934,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
editingGroupId,
|
||||
selectedGroupIds,
|
||||
selectedElementIds,
|
||||
editingLinearElementId,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
croppingElementId,
|
||||
lockedMultiSelections,
|
||||
activeLockedId,
|
||||
@@ -911,12 +988,6 @@ export class AppStateDelta implements DeltaContainer<AppState> {
|
||||
"lockedMultiSelections",
|
||||
(prevValue) => (prevValue ?? {}) as ValueOf<T["lockedMultiSelections"]>,
|
||||
);
|
||||
Delta.diffObjects(
|
||||
deleted,
|
||||
inserted,
|
||||
"activeLockedId",
|
||||
(prevValue) => (prevValue ?? null) as ValueOf<T["activeLockedId"]>,
|
||||
);
|
||||
} catch (e) {
|
||||
// if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it
|
||||
console.error(`Couldn't postprocess appstate change deltas.`);
|
||||
@@ -945,12 +1016,13 @@ type ElementPartial<TElement extends ExcalidrawElement = ExcalidrawElement> =
|
||||
Omit<Partial<Ordered<TElement>>, "id" | "updated" | "seed">;
|
||||
|
||||
export type ApplyToOptions = {
|
||||
excludedProperties: Set<keyof ElementPartial>;
|
||||
excludedProperties?: Set<keyof ElementPartial>;
|
||||
};
|
||||
|
||||
type ApplyToFlags = {
|
||||
containsVisibleDifference: boolean;
|
||||
containsZindexDifference: boolean;
|
||||
applyDirection: "forward" | "backward" | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1039,18 +1111,27 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
inserted,
|
||||
}: Delta<ElementPartial>) =>
|
||||
!!(
|
||||
deleted.version &&
|
||||
inserted.version &&
|
||||
// versions are required integers
|
||||
Number.isInteger(deleted.version) &&
|
||||
Number.isInteger(inserted.version) &&
|
||||
// versions should be positive, zero included
|
||||
deleted.version >= 0 &&
|
||||
inserted.version >= 0 &&
|
||||
// versions should never be the same
|
||||
deleted.version !== inserted.version
|
||||
(
|
||||
Number.isInteger(deleted.version) &&
|
||||
Number.isInteger(inserted.version) &&
|
||||
// versions should be positive, zero included
|
||||
deleted.version! >= 0 &&
|
||||
inserted.version! >= 0 &&
|
||||
// versions should never be the same
|
||||
deleted.version !== inserted.version
|
||||
)
|
||||
);
|
||||
|
||||
private static satisfiesUniqueInvariants = (
|
||||
elementsDelta: ElementsDelta,
|
||||
id: string,
|
||||
) => {
|
||||
const { added, removed, updated } = elementsDelta;
|
||||
// it's required that there is only one unique delta type per element
|
||||
return [added[id], removed[id], updated[id]].filter(Boolean).length === 1;
|
||||
};
|
||||
|
||||
private static validate(
|
||||
elementsDelta: ElementsDelta,
|
||||
type: "added" | "removed" | "updated",
|
||||
@@ -1059,6 +1140,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
for (const [id, delta] of Object.entries(elementsDelta[type])) {
|
||||
if (
|
||||
!this.satisfiesCommmonInvariants(delta) ||
|
||||
!this.satisfiesUniqueInvariants(elementsDelta, id) ||
|
||||
!satifiesSpecialInvariants(delta)
|
||||
) {
|
||||
console.error(
|
||||
@@ -1095,7 +1177,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const nextElement = nextElements.get(prevElement.id);
|
||||
|
||||
if (!nextElement) {
|
||||
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
|
||||
const deleted = { ...prevElement } as ElementPartial;
|
||||
|
||||
const inserted = {
|
||||
isDeleted: true,
|
||||
@@ -1109,7 +1191,11 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
removed[prevElement.id] = delta;
|
||||
if (!prevElement.isDeleted) {
|
||||
removed[prevElement.id] = delta;
|
||||
} else {
|
||||
updated[prevElement.id] = delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1125,7 +1211,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
|
||||
const inserted = {
|
||||
...nextElement,
|
||||
isDeleted: false,
|
||||
} as ElementPartial;
|
||||
|
||||
const delta = Delta.create(
|
||||
@@ -1134,7 +1219,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
ElementsDelta.stripIrrelevantProps,
|
||||
);
|
||||
|
||||
added[nextElement.id] = delta;
|
||||
// ignore updates which would "delete" already deleted element
|
||||
if (!nextElement.isDeleted) {
|
||||
added[nextElement.id] = delta;
|
||||
} else {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
@@ -1163,10 +1253,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// making sure there are at least some changes
|
||||
if (!Delta.isEmpty(delta)) {
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
updated[nextElement.id] = delta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,8 +1268,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
|
||||
|
||||
for (const [id, delta] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
|
||||
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
|
||||
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
|
||||
}
|
||||
|
||||
return inversedDeltas;
|
||||
@@ -1301,9 +1388,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
public applyTo(
|
||||
elements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
|
||||
options: ApplyToOptions = {
|
||||
excludedProperties: new Set(),
|
||||
},
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, boolean] {
|
||||
let nextElements = new Map(elements) as SceneElementsMap;
|
||||
let changedElements: Map<string, OrderedExcalidrawElement>;
|
||||
@@ -1311,22 +1396,28 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const flags: ApplyToFlags = {
|
||||
containsVisibleDifference: false,
|
||||
containsZindexDifference: false,
|
||||
applyDirection: undefined,
|
||||
};
|
||||
|
||||
// mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation)
|
||||
try {
|
||||
const applyDeltas = ElementsDelta.createApplier(
|
||||
elements,
|
||||
nextElements,
|
||||
snapshot,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
const addedElements = applyDeltas(this.added);
|
||||
const removedElements = applyDeltas(this.removed);
|
||||
const updatedElements = applyDeltas(this.updated);
|
||||
|
||||
const affectedElements = this.resolveConflicts(elements, nextElements);
|
||||
const affectedElements = this.resolveConflicts(
|
||||
elements,
|
||||
nextElements,
|
||||
flags.applyDirection,
|
||||
);
|
||||
|
||||
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
|
||||
changedElements = new Map([
|
||||
@@ -1350,22 +1441,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
|
||||
try {
|
||||
// the following reorder performs also mutations, but only on new instances of changed elements
|
||||
// (unless something goes really bad and it fallbacks to fixing all invalid indices)
|
||||
// the following reorder performs mutations, but only on new instances of changed elements,
|
||||
// unless something goes really bad and it fallbacks to fixing all invalid indices
|
||||
nextElements = ElementsDelta.reorderElements(
|
||||
nextElements,
|
||||
changedElements,
|
||||
flags,
|
||||
);
|
||||
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements);
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
// Need ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
ElementsDelta.redrawElements(nextElements, changedElements);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Couldn't mutate elements after applying elements change`,
|
||||
@@ -1380,12 +1464,113 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
}
|
||||
}
|
||||
|
||||
public squash(delta: ElementsDelta): this {
|
||||
if (delta.isEmpty()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const { added, removed, updated } = delta;
|
||||
|
||||
const mergeBoundElements = (
|
||||
prevDelta: Delta<ElementPartial>,
|
||||
nextDelta: Delta<ElementPartial>,
|
||||
) => {
|
||||
const mergedDeletedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.deleted.boundElements ?? [],
|
||||
nextDelta.deleted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
const mergedInsertedBoundElements =
|
||||
Delta.mergeArrays(
|
||||
prevDelta.inserted.boundElements ?? [],
|
||||
nextDelta.inserted.boundElements ?? [],
|
||||
undefined,
|
||||
(x) => x.id,
|
||||
) ?? [];
|
||||
|
||||
if (
|
||||
!mergedDeletedBoundElements.length &&
|
||||
!mergedInsertedBoundElements.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Delta.create(
|
||||
{
|
||||
boundElements: mergedDeletedBoundElements,
|
||||
},
|
||||
{
|
||||
boundElements: mergedInsertedBoundElements,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(added)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.added[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.removed[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(removed)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.removed[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
delete this.added[id];
|
||||
delete this.updated[id];
|
||||
|
||||
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nextDelta] of Object.entries(updated)) {
|
||||
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
|
||||
|
||||
if (!prevDelta) {
|
||||
this.updated[id] = nextDelta;
|
||||
} else {
|
||||
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
|
||||
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
|
||||
|
||||
if (prevDelta === this.added[id]) {
|
||||
this.added[id] = updatedDelta;
|
||||
} else if (prevDelta === this.removed[id]) {
|
||||
this.removed[id] = updatedDelta;
|
||||
} else {
|
||||
this.updated[id] = updatedDelta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
|
||||
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
|
||||
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
private static createApplier =
|
||||
(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
snapshot: StoreSnapshot["elements"],
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) =>
|
||||
(deltas: Record<string, Delta<ElementPartial>>) => {
|
||||
const getElement = ElementsDelta.createGetter(
|
||||
@@ -1398,15 +1583,26 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
const element = getElement(id, delta.inserted);
|
||||
|
||||
if (element) {
|
||||
const newElement = ElementsDelta.applyDelta(
|
||||
const nextElement = ElementsDelta.applyDelta(
|
||||
element,
|
||||
delta,
|
||||
options,
|
||||
flags,
|
||||
options,
|
||||
);
|
||||
|
||||
nextElements.set(newElement.id, newElement);
|
||||
acc.set(newElement.id, newElement);
|
||||
nextElements.set(nextElement.id, nextElement);
|
||||
acc.set(nextElement.id, nextElement);
|
||||
|
||||
if (!flags.applyDirection) {
|
||||
const prevElement = prevElements.get(id);
|
||||
|
||||
if (prevElement) {
|
||||
flags.applyDirection =
|
||||
prevElement.version > nextElement.version
|
||||
? "backward"
|
||||
: "forward";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
@@ -1451,8 +1647,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private static applyDelta(
|
||||
element: OrderedExcalidrawElement,
|
||||
delta: Delta<ElementPartial>,
|
||||
options: ApplyToOptions,
|
||||
flags: ApplyToFlags,
|
||||
options?: ApplyToOptions,
|
||||
) {
|
||||
const directlyApplicablePartial: Mutable<ElementPartial> = {};
|
||||
|
||||
@@ -1466,7 +1662,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (options.excludedProperties.has(key)) {
|
||||
if (options?.excludedProperties?.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1506,7 +1702,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
delta.deleted.index !== delta.inserted.index;
|
||||
}
|
||||
|
||||
return newElementWith(element, directlyApplicablePartial);
|
||||
return newElementWith(element, directlyApplicablePartial, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1546,6 +1742,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
private resolveConflicts(
|
||||
prevElements: SceneElementsMap,
|
||||
nextElements: SceneElementsMap,
|
||||
applyDirection: "forward" | "backward" = "forward",
|
||||
) {
|
||||
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
|
||||
const updater = (
|
||||
@@ -1557,21 +1754,36 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevElement = prevElements.get(element.id);
|
||||
const nextVersion =
|
||||
applyDirection === "forward"
|
||||
? nextElement.version + 1
|
||||
: nextElement.version - 1;
|
||||
|
||||
const elementUpdates = updates as ElementUpdate<OrderedExcalidrawElement>;
|
||||
|
||||
let affectedElement: OrderedExcalidrawElement;
|
||||
|
||||
if (prevElements.get(element.id) === nextElement) {
|
||||
if (prevElement === nextElement) {
|
||||
// create the new element instance in case we didn't modify the element yet
|
||||
// so that we won't end up in an incosistent state in case we would fail in the middle of mutations
|
||||
affectedElement = newElementWith(
|
||||
nextElement,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
{
|
||||
...elementUpdates,
|
||||
version: nextVersion,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
affectedElement = mutateElement(
|
||||
nextElement,
|
||||
nextElements,
|
||||
updates as ElementUpdate<OrderedExcalidrawElement>,
|
||||
);
|
||||
affectedElement = mutateElement(nextElement, nextElements, {
|
||||
...elementUpdates,
|
||||
// don't modify the version further, if it's already different
|
||||
version:
|
||||
prevElement?.version !== nextElement.version
|
||||
? nextElement.version
|
||||
: nextVersion,
|
||||
});
|
||||
}
|
||||
|
||||
nextAffectedElements.set(affectedElement.id, affectedElement);
|
||||
@@ -1609,25 +1821,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
|
||||
);
|
||||
|
||||
// calculate complete deltas for affected elements, and assign them back to all the deltas
|
||||
// technically we could do better here if perf. would become an issue
|
||||
const { added, removed, updated } = ElementsDelta.calculate(
|
||||
prevAffectedElements,
|
||||
nextAffectedElements,
|
||||
// calculate complete deltas for affected elements, and squash them back to the current deltas
|
||||
this.squash(
|
||||
// technically we could do better here if perf. would become an issue
|
||||
ElementsDelta.calculate(prevAffectedElements, nextAffectedElements),
|
||||
);
|
||||
|
||||
for (const [id, delta] of Object.entries(added)) {
|
||||
this.added[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(removed)) {
|
||||
this.removed[id] = delta;
|
||||
}
|
||||
|
||||
for (const [id, delta] of Object.entries(updated)) {
|
||||
this.updated[id] = delta;
|
||||
}
|
||||
|
||||
return nextAffectedElements;
|
||||
}
|
||||
|
||||
@@ -1689,6 +1888,31 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
BindableElement.rebindAffected(nextElements, nextElement(), updater);
|
||||
}
|
||||
|
||||
public static redrawElements(
|
||||
nextElements: SceneElementsMap,
|
||||
changedElements: Map<string, OrderedExcalidrawElement>,
|
||||
) {
|
||||
try {
|
||||
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
|
||||
// we also don't have a scene on the server
|
||||
// so we are creating a temp scene just to query and mutate elements
|
||||
const tempScene = new Scene(nextElements, { skipValidation: true });
|
||||
|
||||
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
|
||||
|
||||
// needs ordered nextElements to avoid z-index binding issues
|
||||
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
|
||||
} catch (e) {
|
||||
console.error(`Couldn't redraw elements`, e);
|
||||
|
||||
if (isTestEnv() || isDevEnv()) {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
return nextElements;
|
||||
}
|
||||
}
|
||||
|
||||
private static redrawTextBoundingBoxes(
|
||||
scene: Scene,
|
||||
changed: Map<string, OrderedExcalidrawElement>,
|
||||
@@ -1743,6 +1967,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
|
||||
) {
|
||||
for (const element of changed.values()) {
|
||||
if (!element.isDeleted && isBindableElement(element)) {
|
||||
// TODO: with precise bindings this is quite expensive, so consider optimisation so it's only triggered when the arrow does not intersect (imprecise) element bounds
|
||||
updateBoundElements(element, scene, {
|
||||
changedElements: changed,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBoundingBox } from "./bounds";
|
||||
import { newElementWith } from "./mutateElement";
|
||||
|
||||
import { getMaximumGroups } from "./groups";
|
||||
import { getSelectedElementsByGroup } from "./groups";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
@@ -14,6 +16,7 @@ export const distributeElements = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
distribution: Distribution,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[] => {
|
||||
const [start, mid, end, extent] =
|
||||
distribution.axis === "x"
|
||||
@@ -21,7 +24,11 @@ export const distributeElements = (
|
||||
: (["minY", "midY", "maxY", "height"] as const);
|
||||
|
||||
const bounds = getCommonBoundingBox(selectedElements);
|
||||
const groups = getMaximumGroups(selectedElements, elementsMap)
|
||||
const groups = getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
elementsMap,
|
||||
appState,
|
||||
)
|
||||
.map((group) => [group, getCommonBoundingBox(group)] as const)
|
||||
.sort((a, b) => a[1][mid] - b[1][mid]);
|
||||
|
||||
|
||||
@@ -359,6 +359,12 @@ const handleSegmentRelease = (
|
||||
null,
|
||||
);
|
||||
|
||||
if (!restoredPoints || restoredPoints.length < 2) {
|
||||
throw new Error(
|
||||
"Property 'points' is required in the update returned by normalizeArrowElementUpdate()",
|
||||
);
|
||||
}
|
||||
|
||||
const nextPoints: GlobalPoint[] = [];
|
||||
|
||||
// First part of the arrow are the old points
|
||||
@@ -706,7 +712,7 @@ const handleEndpointDrag = (
|
||||
endGlobalPoint: GlobalPoint,
|
||||
hoveredStartElement: ExcalidrawBindableElement | null,
|
||||
hoveredEndElement: ExcalidrawBindableElement | null,
|
||||
) => {
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
let startIsSpecial = arrow.startIsSpecial ?? null;
|
||||
let endIsSpecial = arrow.endIsSpecial ?? null;
|
||||
const globalUpdatedPoints = updatedPoints.map((p, i) =>
|
||||
@@ -741,8 +747,15 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second point connection and add the start point
|
||||
{
|
||||
const secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
|
||||
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
|
||||
const secondPoint = globalUpdatedPoints.at(startIsSpecial ? 2 : 1);
|
||||
const thirdPoint = globalUpdatedPoints.at(startIsSpecial ? 3 : 2);
|
||||
|
||||
if (!secondPoint || !thirdPoint) {
|
||||
throw new Error(
|
||||
`Second and third points must exist when handling endpoint drag (${startIsSpecial})`,
|
||||
);
|
||||
}
|
||||
|
||||
const startIsHorizontal = headingIsHorizontal(startHeading);
|
||||
const secondIsHorizontal = headingIsHorizontal(
|
||||
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
|
||||
@@ -801,10 +814,19 @@ const handleEndpointDrag = (
|
||||
|
||||
// Calculate the moving second to last point connection
|
||||
{
|
||||
const secondToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
|
||||
const thirdToLastPoint =
|
||||
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
|
||||
const secondToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 3 : 2),
|
||||
);
|
||||
const thirdToLastPoint = globalUpdatedPoints.at(
|
||||
globalUpdatedPoints.length - (endIsSpecial ? 4 : 3),
|
||||
);
|
||||
|
||||
if (!secondToLastPoint || !thirdToLastPoint) {
|
||||
throw new Error(
|
||||
`Second and third to last points must exist when handling endpoint drag (${endIsSpecial})`,
|
||||
);
|
||||
}
|
||||
|
||||
const endIsHorizontal = headingIsHorizontal(endHeading);
|
||||
const secondIsHorizontal = headingForPointIsHorizontal(
|
||||
thirdToLastPoint,
|
||||
@@ -2071,16 +2093,7 @@ const normalizeArrowElementUpdate = (
|
||||
nextFixedSegments: readonly FixedSegment[] | null,
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
|
||||
): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fixedSegments: readonly FixedSegment[] | null;
|
||||
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
|
||||
} => {
|
||||
): ElementUpdate<ExcalidrawElbowArrowElement> => {
|
||||
const offsetX = global[0][0];
|
||||
const offsetY = global[0][1];
|
||||
let points = global.map((p) =>
|
||||
|
||||
@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
|
||||
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
|
||||
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
@@ -56,6 +56,35 @@ const RE_REDDIT =
|
||||
const RE_REDDIT_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
|
||||
|
||||
const parseYouTubeTimestamp = (url: string): number => {
|
||||
let timeParam: string | null | undefined;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
|
||||
timeParam =
|
||||
urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
|
||||
} catch (error) {
|
||||
const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
|
||||
timeParam = timeMatch?.[1];
|
||||
}
|
||||
|
||||
if (!timeParam) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(timeParam)) {
|
||||
return parseInt(timeParam, 10);
|
||||
}
|
||||
|
||||
const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
|
||||
if (!timeMatch) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
|
||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||
};
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
@@ -113,7 +142,8 @@ export const getEmbedLink = (
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||
const startTime = parseYouTubeTimestamp(originalLink);
|
||||
const time = startTime > 0 ? `&start=${startTime}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
|
||||
import { isBoundToContainer } from "./typeChecks";
|
||||
|
||||
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
|
||||
|
||||
import type {
|
||||
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
|
||||
|
||||
return copy;
|
||||
};
|
||||
|
||||
// given a list of selected elements, return the element grouped by their immediate group selected state
|
||||
// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
|
||||
export const getSelectedElementsByGroup = (
|
||||
selectedElements: ExcalidrawElement[],
|
||||
elementsMap: ElementsMap,
|
||||
appState: Readonly<AppState>,
|
||||
): ExcalidrawElement[][] => {
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
const unboundElements = selectedElements.filter(
|
||||
(element) => !isBoundToContainer(element),
|
||||
);
|
||||
const groups: Map<string, ExcalidrawElement[]> = new Map();
|
||||
const elements: Map<string, ExcalidrawElement[]> = new Map();
|
||||
|
||||
// helper function to add an element to the elements map
|
||||
const addToElementsMap = (element: ExcalidrawElement) => {
|
||||
// elements
|
||||
const currentElementMembers = elements.get(element.id) || [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
currentElementMembers.push(boundTextElement);
|
||||
}
|
||||
elements.set(element.id, [...currentElementMembers, element]);
|
||||
};
|
||||
|
||||
// helper function to add an element to the groups map
|
||||
const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
|
||||
// groups
|
||||
const currentGroupMembers = groups.get(groupId) || [];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
currentGroupMembers.push(boundTextElement);
|
||||
}
|
||||
groups.set(groupId, [...currentGroupMembers, element]);
|
||||
};
|
||||
|
||||
// helper function to handle the case where a single group is selected
|
||||
// and all elements selected are within the group, it will respect group hierarchy in accordance to
|
||||
// their nested grouping order
|
||||
const handleSingleSelectedGroupCase = (
|
||||
element: ExcalidrawElement,
|
||||
selectedGroupId: GroupId,
|
||||
) => {
|
||||
const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
|
||||
const nestedGroupCount = element.groupIds.slice(
|
||||
0,
|
||||
indexOfSelectedGroupId,
|
||||
).length;
|
||||
return nestedGroupCount > 0
|
||||
? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
|
||||
: addToElementsMap(element);
|
||||
};
|
||||
|
||||
const isAllInSameGroup = selectedElements.every((element) =>
|
||||
isSelectedViaGroup(appState, element),
|
||||
);
|
||||
|
||||
unboundElements.forEach((element) => {
|
||||
const selectedGroupId = getSelectedGroupIdForElement(
|
||||
element,
|
||||
appState.selectedGroupIds,
|
||||
);
|
||||
if (!selectedGroupId) {
|
||||
addToElementsMap(element);
|
||||
} else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
|
||||
handleSingleSelectedGroupCase(element, selectedGroupId);
|
||||
} else {
|
||||
addToGroupsMap(element, selectedGroupId);
|
||||
}
|
||||
});
|
||||
return Array.from(groups.values()).concat(Array.from(elements.values()));
|
||||
};
|
||||
|
||||
@@ -149,10 +149,12 @@ export class LinearElementEditor {
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
public readonly customLineAngle: number | null;
|
||||
public readonly isEditing: boolean;
|
||||
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
isEditing: boolean = false,
|
||||
) {
|
||||
this.elementId = element.id as string & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -187,6 +189,7 @@ export class LinearElementEditor {
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
this.isEditing = isEditing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -194,6 +197,7 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POINT_HANDLE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
* statically guarantee this method returns an ExcalidrawLinearElement)
|
||||
@@ -215,11 +219,14 @@ export class LinearElementEditor {
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) {
|
||||
if (!appState.editingLinearElement || !appState.selectionElement) {
|
||||
if (
|
||||
!appState.selectedLinearElement?.isEditing ||
|
||||
!appState.selectionElement
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const { editingLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId } = editingLinearElement;
|
||||
const { selectedLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId } = selectedLinearElement;
|
||||
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
@@ -260,8 +267,8 @@ export class LinearElementEditor {
|
||||
});
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...selectedLinearElement,
|
||||
selectedPointsIndices: nextSelectedPoints.length
|
||||
? nextSelectedPoints
|
||||
: null,
|
||||
@@ -479,9 +486,6 @@ export class LinearElementEditor {
|
||||
|
||||
return {
|
||||
...app.state,
|
||||
editingLinearElement: app.state.editingLinearElement
|
||||
? newLinearElementEditor
|
||||
: null,
|
||||
selectedLinearElement: newLinearElementEditor,
|
||||
suggestedBindings,
|
||||
};
|
||||
@@ -618,7 +622,7 @@ export class LinearElementEditor {
|
||||
// Since its not needed outside editor unless 2 pointer lines or bound text
|
||||
if (
|
||||
!isElbowArrow(element) &&
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
element.points.length > 2 &&
|
||||
!boundText
|
||||
) {
|
||||
@@ -684,7 +688,7 @@ export class LinearElementEditor {
|
||||
);
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
!isElbowArrow(element)
|
||||
) {
|
||||
return null;
|
||||
@@ -881,7 +885,7 @@ export class LinearElementEditor {
|
||||
segmentMidpoint,
|
||||
elementsMap,
|
||||
);
|
||||
} else if (event.altKey && appState.editingLinearElement) {
|
||||
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
scene.mutateElement(element, {
|
||||
points: [
|
||||
@@ -1023,14 +1027,14 @@ export class LinearElementEditor {
|
||||
app: AppClassProperties,
|
||||
): LinearElementEditor | null {
|
||||
const appState = app.state;
|
||||
if (!appState.editingLinearElement) {
|
||||
if (!appState.selectedLinearElement?.isEditing) {
|
||||
return null;
|
||||
}
|
||||
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
|
||||
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
return appState.editingLinearElement;
|
||||
return appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -1040,10 +1044,12 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
return appState.selectedLinearElement?.lastUncommittedPoint
|
||||
? {
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: null,
|
||||
}
|
||||
: appState.selectedLinearElement;
|
||||
}
|
||||
|
||||
let newPoint: LocalPoint;
|
||||
@@ -1067,8 +1073,8 @@ export class LinearElementEditor {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
elementsMap,
|
||||
scenePointerX - appState.editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.editingLinearElement.pointerOffset.y,
|
||||
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
|
||||
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
|
||||
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
|
||||
? null
|
||||
: app.getEffectiveGridSize(),
|
||||
@@ -1092,7 +1098,7 @@ export class LinearElementEditor {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
...appState.selectedLinearElement,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@@ -1251,12 +1257,12 @@ export class LinearElementEditor {
|
||||
// ---------------------------------------------------------------------------
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
appState.selectedLinearElement?.isEditing,
|
||||
"Not currently editing a linear element",
|
||||
);
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
|
||||
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
invariant(
|
||||
@@ -1318,8 +1324,8 @@ export class LinearElementEditor {
|
||||
|
||||
return {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
selectedPointsIndices: nextSelectedIndices,
|
||||
},
|
||||
};
|
||||
@@ -1331,8 +1337,9 @@ export class LinearElementEditor {
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
const isUncommittedPoint =
|
||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
app.state.selectedLinearElement?.isEditing &&
|
||||
app.state.selectedLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
|
||||
const nextPoints = element.points.filter((_, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
@@ -1505,7 +1512,7 @@ export class LinearElementEditor {
|
||||
pointFrom(pointerCoords.x, pointerCoords.y),
|
||||
);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
dist < DRAGGING_THRESHOLD / appState.zoom.value
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
|
||||
return element.strokeWidth * 12;
|
||||
case "text":
|
||||
return element.fontSize / 2;
|
||||
case "arrow":
|
||||
if (element.endArrowhead || element.endArrowhead) {
|
||||
return 40;
|
||||
}
|
||||
return 20;
|
||||
default:
|
||||
return 20;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
getMinTextElementWidth,
|
||||
@@ -225,7 +226,16 @@ const rotateSingleElement = (
|
||||
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
|
||||
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
scene.mutateElement(textElement, { angle });
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
textElement,
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
scene.mutateElement(textElement, {
|
||||
angle,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -416,9 +426,15 @@ const rotateMultipleElements = (
|
||||
|
||||
const boundText = getBoundTextElement(element, elementsMap);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
element,
|
||||
boundText,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
scene.mutateElement(boundText, {
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
x,
|
||||
y,
|
||||
angle: normalizeRadians((centerAngle + origAngle) as Radians),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
isImageElement,
|
||||
} from "./index";
|
||||
|
||||
import type { ApplyToOptions } from "./delta";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
OrderedExcalidrawElement,
|
||||
@@ -74,8 +76,9 @@ type MicroActionsQueue = (() => void)[];
|
||||
* Store which captures the observed changes and emits them as `StoreIncrement` events.
|
||||
*/
|
||||
export class Store {
|
||||
// internally used by history
|
||||
// for internal use by history
|
||||
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
|
||||
// for public use as part of onIncrement API
|
||||
public readonly onStoreIncrementEmitter = new Emitter<
|
||||
[DurableIncrement | EphemeralIncrement]
|
||||
>();
|
||||
@@ -237,7 +240,6 @@ export class Store {
|
||||
if (!storeDelta.isEmpty()) {
|
||||
const increment = new DurableIncrement(storeChange, storeDelta);
|
||||
|
||||
// Notify listeners with the increment
|
||||
this.onDurableIncrementEmitter.trigger(increment);
|
||||
this.onStoreIncrementEmitter.trigger(increment);
|
||||
}
|
||||
@@ -550,10 +552,26 @@ export class StoreDelta {
|
||||
public static load({
|
||||
id,
|
||||
elements: { added, removed, updated },
|
||||
appState: { delta: appStateDelta },
|
||||
}: DTO<StoreDelta>) {
|
||||
const elements = ElementsDelta.create(added, removed, updated);
|
||||
const appState = AppStateDelta.create(appStateDelta);
|
||||
|
||||
return new this(id, elements, AppStateDelta.empty());
|
||||
return new this(id, elements, appState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash the passed deltas into the aggregated delta instance.
|
||||
*/
|
||||
public static squash(...deltas: StoreDelta[]) {
|
||||
const aggregatedDelta = StoreDelta.empty();
|
||||
|
||||
for (const delta of deltas) {
|
||||
aggregatedDelta.elements.squash(delta.elements);
|
||||
aggregatedDelta.appState.squash(delta.appState);
|
||||
}
|
||||
|
||||
return aggregatedDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -570,9 +588,13 @@ export class StoreDelta {
|
||||
delta: StoreDelta,
|
||||
elements: SceneElementsMap,
|
||||
appState: AppState,
|
||||
options?: ApplyToOptions,
|
||||
): [SceneElementsMap, AppState, boolean] {
|
||||
const [nextElements, elementsContainVisibleChange] =
|
||||
delta.elements.applyTo(elements);
|
||||
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
|
||||
elements,
|
||||
StoreSnapshot.empty().elements,
|
||||
options,
|
||||
);
|
||||
|
||||
const [nextAppState, appStateContainsVisibleChange] =
|
||||
delta.appState.applyTo(appState, nextElements);
|
||||
@@ -605,6 +627,10 @@ export class StoreDelta {
|
||||
);
|
||||
}
|
||||
|
||||
public static empty() {
|
||||
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
|
||||
}
|
||||
|
||||
public isEmpty() {
|
||||
return this.elements.isEmpty() && this.appState.isEmpty();
|
||||
}
|
||||
@@ -970,8 +996,7 @@ const getDefaultObservedAppState = (): ObservedAppState => {
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
croppingElementId: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
@@ -990,14 +1015,12 @@ export const getObservedAppState = (
|
||||
croppingElementId: appState.croppingElementId,
|
||||
activeLockedId: appState.activeLockedId,
|
||||
lockedMultiSelections: appState.lockedMultiSelections,
|
||||
editingLinearElementId:
|
||||
(appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
|
||||
(appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
|
||||
null,
|
||||
selectedLinearElementId:
|
||||
(appState as AppState).selectedLinearElement?.elementId ??
|
||||
(appState as ObservedAppState).selectedLinearElementId ??
|
||||
null,
|
||||
selectedLinearElement: appState.selectedLinearElement
|
||||
? {
|
||||
elementId: appState.selectedLinearElement.elementId,
|
||||
isEditing: !!appState.selectedLinearElement.isEditing,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
invariant,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
|
||||
|
||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { ExtractSetType } from "@excalidraw/common/utility-types";
|
||||
|
||||
import type { Radians } from "@excalidraw/math";
|
||||
|
||||
import {
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
@@ -254,6 +254,26 @@ export const computeBoundTextPosition = (
|
||||
x =
|
||||
containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
|
||||
}
|
||||
const angle = (container.angle ?? 0) as Radians;
|
||||
|
||||
if (angle !== 0) {
|
||||
const contentCenter = pointFrom(
|
||||
containerCoords.x + maxContainerWidth / 2,
|
||||
containerCoords.y + maxContainerHeight / 2,
|
||||
);
|
||||
const textCenter = pointFrom(
|
||||
x + boundTextElement.width / 2,
|
||||
y + boundTextElement.height / 2,
|
||||
);
|
||||
|
||||
const [rx, ry] = pointRotateRads(textCenter, contentCenter, angle);
|
||||
|
||||
return {
|
||||
x: rx - boundTextElement.width / 2,
|
||||
y: ry - boundTextElement.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
|
||||
@@ -589,4 +589,424 @@ describe("aligning", () => {
|
||||
expect(API.getSelectedElements()[2].x).toEqual(250);
|
||||
expect(API.getSelectedElements()[3].x).toEqual(150);
|
||||
});
|
||||
|
||||
const createGroupAndSelectInEditGroupMode = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
mouse.reset();
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.doubleClick();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns elements within a group while in group edit mode correctly to the top", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the left", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the right", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
});
|
||||
it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
|
||||
createGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
});
|
||||
|
||||
const createNestedGroupAndSelectInEditGroupMode = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(200, 200);
|
||||
// create third element
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// third element is already selected, select the initial group and group together
|
||||
mouse.reset();
|
||||
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
// double click to enter edit mode
|
||||
mouse.doubleClick();
|
||||
|
||||
// select nested group and other element within the group
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(200, 200);
|
||||
mouse.click();
|
||||
});
|
||||
};
|
||||
|
||||
it("aligns element and nested group while in group edit mode correctly to the top", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the left", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the right", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
|
||||
createNestedGroupAndSelectInEditGroupMode();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
|
||||
const createAndSelectSingleGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
};
|
||||
|
||||
it("aligns elements within a single-selected group correctly to the top", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the bottom", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the left", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the right", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the vertical center", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
});
|
||||
it("aligns elements within a single-selected group correctly to the horizontal center", () => {
|
||||
createAndSelectSingleGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
});
|
||||
|
||||
const createAndSelectSingleGroupWithNestedGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Select the first element.
|
||||
// The second rectangle is already reselected because it was the last element created
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.moveTo(10, 0);
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
API.executeAction(actionGroup);
|
||||
|
||||
mouse.reset();
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
|
||||
// Add group to current selection
|
||||
mouse.restorePosition(10, 0);
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
// Create the nested group
|
||||
API.executeAction(actionGroup);
|
||||
};
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignTop);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignBottom);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignLeft);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(0);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignRight);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(200);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignVerticallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(100);
|
||||
});
|
||||
it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
|
||||
createAndSelectSingleGroupWithNestedGroup();
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(100);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(actionAlignHorizontallyCentered);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,10 +155,10 @@ describe("element binding", () => {
|
||||
// NOTE this mouse down/up + await needs to be done in order to repro
|
||||
// the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
|
||||
mouse.reset();
|
||||
expect(h.state.editingLinearElement).not.toBe(null);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
mouse.down(0, 0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(h.state.editingLinearElement).toBe(null);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
mouse.up();
|
||||
expect(API.getSelectedElement().type).toBe("rectangle");
|
||||
|
||||
@@ -1,13 +1,345 @@
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
|
||||
import type { LinearElementEditor } from "@excalidraw/element";
|
||||
import type { SceneElementsMap } from "@excalidraw/element/types";
|
||||
|
||||
import { AppStateDelta } from "../src/delta";
|
||||
import { AppStateDelta, Delta, ElementsDelta } from "../src/delta";
|
||||
|
||||
describe("ElementsDelta", () => {
|
||||
describe("elements delta calculation", () => {
|
||||
it("should not throw when element gets removed but was already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map([[element.id, element]]);
|
||||
const nextElements = new Map();
|
||||
|
||||
expect(() =>
|
||||
ElementsDelta.calculate(prevElements, nextElements),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw when adding element as already deleted", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
const prevElements = new Map();
|
||||
const nextElements = new Map([[element.id, element]]);
|
||||
|
||||
expect(() =>
|
||||
ElementsDelta.calculate(prevElements, nextElements),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should create updated delta even when there is only version and versionNonce change", () => {
|
||||
const baseElement = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
const modifiedElement = {
|
||||
...baseElement,
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
};
|
||||
|
||||
// Create maps for the delta calculation
|
||||
const prevElements = new Map([[baseElement.id, baseElement]]);
|
||||
const nextElements = new Map([[modifiedElement.id, modifiedElement]]);
|
||||
|
||||
// Calculate the delta
|
||||
const delta = ElementsDelta.calculate(
|
||||
prevElements as SceneElementsMap,
|
||||
nextElements as SceneElementsMap,
|
||||
);
|
||||
|
||||
expect(delta).toEqual(
|
||||
ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
[baseElement.id]: Delta.create(
|
||||
{
|
||||
version: baseElement.version,
|
||||
versionNonce: baseElement.versionNonce,
|
||||
},
|
||||
{
|
||||
version: baseElement.version + 1,
|
||||
versionNonce: baseElement.versionNonce + 1,
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id1: updatedDelta },
|
||||
);
|
||||
const elementsDelta2 = ElementsDelta.empty();
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.updated.id1).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash mutually exclusive delta types", () => {
|
||||
const addedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
);
|
||||
|
||||
const removedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
);
|
||||
|
||||
const updatedDelta = Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
);
|
||||
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{ id1: addedDelta },
|
||||
{ id2: removedDelta },
|
||||
{},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{ id3: updatedDelta },
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toBe(addedDelta);
|
||||
expect(elementsDelta.removed.id2).toBe(removedDelta);
|
||||
expect(elementsDelta.updated.id3).toBe(updatedDelta);
|
||||
});
|
||||
|
||||
it("should squash the same delta types", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1 },
|
||||
{ x: 200, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added.id1).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.removed.id2).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
);
|
||||
expect(elementsDelta.updated.id3).toEqual(
|
||||
Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2 },
|
||||
{ x: 200, y: 200, version: 3, versionNonce: 3 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash different delta types ", () => {
|
||||
// id1: added -> updated => added
|
||||
// id2: removed -> added => added
|
||||
// id3: updated -> removed => removed
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ x: 100, version: 1, versionNonce: 1, isDeleted: true },
|
||||
{ x: 101, version: 2, versionNonce: 2, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ x: 200, version: 1, versionNonce: 1, isDeleted: false },
|
||||
{ x: 201, version: 2, versionNonce: 2, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ x: 300, version: 1, versionNonce: 1 },
|
||||
{ x: 301, version: 2, versionNonce: 2 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{
|
||||
id2: Delta.create(
|
||||
{ y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
id3: Delta.create(
|
||||
{ y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{ y: 100, version: 2, versionNonce: 2 },
|
||||
{ y: 101, version: 3, versionNonce: 3 },
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.isEmpty()).toBeFalsy();
|
||||
expect(elementsDelta).toBe(elementsDelta1);
|
||||
expect(elementsDelta.added).toEqual({
|
||||
id1: Delta.create(
|
||||
{ x: 100, y: 100, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 101, y: 101, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
id2: Delta.create(
|
||||
{ x: 200, y: 200, version: 2, versionNonce: 2, isDeleted: true },
|
||||
{ x: 201, y: 201, version: 3, versionNonce: 3, isDeleted: false },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.removed).toEqual({
|
||||
id3: Delta.create(
|
||||
{ x: 300, y: 300, version: 2, versionNonce: 2, isDeleted: false },
|
||||
{ x: 301, y: 301, version: 3, versionNonce: 3, isDeleted: true },
|
||||
),
|
||||
});
|
||||
expect(elementsDelta.updated).toEqual({});
|
||||
});
|
||||
|
||||
it("should squash bound elements", () => {
|
||||
const elementsDelta1 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 1,
|
||||
versionNonce: 1,
|
||||
boundElements: [{ id: "t1", type: "text" }],
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "t2", type: "text" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta2 = ElementsDelta.create(
|
||||
{},
|
||||
{},
|
||||
{
|
||||
id1: Delta.create(
|
||||
{
|
||||
version: 2,
|
||||
versionNonce: 2,
|
||||
boundElements: [{ id: "a1", type: "arrow" }],
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
versionNonce: 3,
|
||||
boundElements: [{ id: "a2", type: "arrow" }],
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const elementsDelta = elementsDelta1.squash(elementsDelta2);
|
||||
|
||||
expect(elementsDelta.updated.id1.deleted.boundElements).toEqual([
|
||||
{ id: "t1", type: "text" },
|
||||
{ id: "a1", type: "arrow" },
|
||||
]);
|
||||
expect(elementsDelta.updated.id1.inserted.boundElements).toEqual([
|
||||
{ id: "t2", type: "text" },
|
||||
{ id: "a2", type: "arrow" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppStateDelta", () => {
|
||||
describe("ensure stable delta properties order", () => {
|
||||
it("should maintain stable order for root properties", () => {
|
||||
const name = "untitled scene";
|
||||
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
|
||||
const selectedLinearElement = {
|
||||
elementId: "id1" as LinearElementEditor["elementId"],
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
const commonAppState = {
|
||||
viewBackgroundColor: "#ffffff",
|
||||
@@ -16,6 +348,7 @@ describe("AppStateDelta", () => {
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElementIsEditing: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
};
|
||||
@@ -23,23 +356,23 @@ describe("AppStateDelta", () => {
|
||||
const prevAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name: "",
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
|
||||
const nextAppState1: ObservedAppState = {
|
||||
...commonAppState,
|
||||
name,
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
};
|
||||
|
||||
const prevAppState2: ObservedAppState = {
|
||||
selectedLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
name: "",
|
||||
...commonAppState,
|
||||
};
|
||||
|
||||
const nextAppState2: ObservedAppState = {
|
||||
selectedLinearElementId,
|
||||
selectedLinearElement,
|
||||
name,
|
||||
...commonAppState,
|
||||
};
|
||||
@@ -57,8 +390,7 @@ describe("AppStateDelta", () => {
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -104,8 +436,7 @@ describe("AppStateDelta", () => {
|
||||
selectedElementIds: {},
|
||||
editingGroupId: null,
|
||||
croppingElementId: null,
|
||||
selectedLinearElementId: null,
|
||||
editingLinearElementId: null,
|
||||
selectedLinearElement: null,
|
||||
activeLockedId: null,
|
||||
lockedMultiSelections: {},
|
||||
};
|
||||
@@ -146,4 +477,97 @@ describe("AppStateDelta", () => {
|
||||
expect(JSON.stringify(delta1)).toBe(JSON.stringify(delta2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("squash", () => {
|
||||
it("should not squash when second delta is empty", () => {
|
||||
const delta = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta);
|
||||
const appStateDelta2 = AppStateDelta.empty();
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toBe(delta);
|
||||
});
|
||||
|
||||
it("should squash exclusive properties", () => {
|
||||
const delta1 = Delta.create(
|
||||
{ name: "untitled scene" },
|
||||
{ name: "titled scene" },
|
||||
);
|
||||
const delta2 = Delta.create(
|
||||
{ viewBackgroundColor: "#ffffff" },
|
||||
{ viewBackgroundColor: "#000000" },
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create(
|
||||
{ name: "untitled scene", viewBackgroundColor: "#ffffff" },
|
||||
{ name: "titled scene", viewBackgroundColor: "#000000" },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should squash selectedElementIds, selectedGroupIds and lockedMultiSelections", () => {
|
||||
const delta1 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true },
|
||||
selectedGroupIds: {},
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
);
|
||||
const delta2 = Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
selectedElementIds: { id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: {},
|
||||
},
|
||||
{
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
);
|
||||
|
||||
const appStateDelta1 = AppStateDelta.create(delta1);
|
||||
const appStateDelta2 = AppStateDelta.create(delta2);
|
||||
const appStateDelta = appStateDelta1.squash(appStateDelta2);
|
||||
|
||||
expect(appStateDelta.isEmpty()).toBeFalsy();
|
||||
expect(appStateDelta).toBe(appStateDelta1);
|
||||
expect(appStateDelta.delta).toEqual(
|
||||
Delta.create<Partial<ObservedAppState>>(
|
||||
{
|
||||
name: "untitled scene",
|
||||
selectedElementIds: { id1: true, id3: true },
|
||||
selectedGroupIds: { g1: true },
|
||||
lockedMultiSelections: { g1: true },
|
||||
},
|
||||
{
|
||||
name: "titled scene",
|
||||
selectedElementIds: { id2: true },
|
||||
selectedGroupIds: { g1: true, g2: true, g3: true },
|
||||
lockedMultiSelections: { g3: true },
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
distributeHorizontally,
|
||||
distributeVertically,
|
||||
} from "@excalidraw/excalidraw/actions";
|
||||
import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
act,
|
||||
unmountComponent,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
// Scenario: three rectangles that will be distributed with gaps
|
||||
const createAndSelectThreeRectanglesWithGap = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(300, 300);
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
// Last rectangle is selected by default
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(0, 10);
|
||||
mouse.click(10, 0);
|
||||
});
|
||||
};
|
||||
|
||||
// Scenario: three rectangles that will be distributed by their centers
|
||||
const createAndSelectThreeRectanglesWithoutGap = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(10, 10);
|
||||
mouse.up(200, 200);
|
||||
mouse.reset();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(200, 200);
|
||||
mouse.up(100, 100);
|
||||
mouse.reset();
|
||||
|
||||
// Last rectangle is selected by default
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click(0, 10);
|
||||
mouse.click(10, 0);
|
||||
});
|
||||
};
|
||||
|
||||
describe("distributing", () => {
|
||||
beforeEach(async () => {
|
||||
unmountComponent();
|
||||
mouse.reset();
|
||||
|
||||
await act(() => {
|
||||
return setLanguage(defaultLang);
|
||||
});
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("should distribute selected elements horizontally", async () => {
|
||||
createAndSelectThreeRectanglesWithGap();
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||
|
||||
API.executeAction(distributeHorizontally);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(300);
|
||||
});
|
||||
|
||||
it("should distribute selected elements vertically", async () => {
|
||||
createAndSelectThreeRectanglesWithGap();
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||
|
||||
API.executeAction(distributeVertically);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(150);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(300);
|
||||
});
|
||||
|
||||
it("should distribute selected elements horizontally based on their centers", async () => {
|
||||
createAndSelectThreeRectanglesWithoutGap();
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
|
||||
API.executeAction(distributeHorizontally);
|
||||
|
||||
expect(API.getSelectedElements()[0].x).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].x).toEqual(50);
|
||||
expect(API.getSelectedElements()[2].x).toEqual(200);
|
||||
});
|
||||
|
||||
it("should distribute selected elements vertically with based on their centers", async () => {
|
||||
createAndSelectThreeRectanglesWithoutGap();
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(10);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
|
||||
API.executeAction(distributeVertically);
|
||||
|
||||
expect(API.getSelectedElements()[0].y).toEqual(0);
|
||||
expect(API.getSelectedElements()[1].y).toEqual(50);
|
||||
expect(API.getSelectedElements()[2].y).toEqual(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getEmbedLink } from "../src/embeddable";
|
||||
|
||||
describe("YouTube timestamp parsing", () => {
|
||||
it("should parse YouTube URLs with timestamp in seconds", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
|
||||
expectedStart: 90,
|
||||
},
|
||||
{
|
||||
url: "https://youtu.be/dQw4w9WgXcQ?t=120",
|
||||
expectedStart: 120,
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
|
||||
expectedStart: 150,
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse YouTube URLs with timestamp in time format", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
|
||||
expectedStart: 90, // 1*60 + 30
|
||||
},
|
||||
{
|
||||
url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
|
||||
expectedStart: 165, // 2*60 + 45
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
|
||||
expectedStart: 3723, // 1*3600 + 2*60 + 3
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
|
||||
expectedStart: 45,
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
|
||||
expectedStart: 300, // 5*60
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
|
||||
expectedStart: 7200, // 2*3600
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle YouTube URLs without timestamps", () => {
|
||||
const testCases = [
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
];
|
||||
|
||||
testCases.forEach((url) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).not.toContain("start=");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle YouTube shorts URLs with timestamps", () => {
|
||||
const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=30");
|
||||
}
|
||||
// Shorts should have portrait aspect ratio
|
||||
expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
|
||||
});
|
||||
|
||||
it("should handle playlist URLs with timestamps", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=60");
|
||||
expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle malformed or edge case timestamps", () => {
|
||||
const testCases = [
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
|
||||
expectedStart: 0, // Invalid timestamp should default to 0
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
|
||||
expectedStart: 0, // Empty timestamp should default to 0
|
||||
},
|
||||
{
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
|
||||
expectedStart: 0, // Zero timestamp should be handled
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ url, expectedStart }) => {
|
||||
const result = getEmbedLink(url);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
if (expectedStart === 0) {
|
||||
expect(result.link).not.toContain("start=");
|
||||
} else {
|
||||
expect(result.link).toContain(`start=${expectedStart}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve other URL parameters", () => {
|
||||
const url =
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
|
||||
const result = getEmbedLink(url);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.type).toBe("video");
|
||||
if (result?.type === "video" || result?.type === "generic") {
|
||||
expect(result.link).toContain("start=90");
|
||||
expect(result.link).toContain("enablejsapi=1");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,8 @@ describe("Test Linear Elements", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
|
||||
};
|
||||
|
||||
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
|
||||
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
|
||||
});
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
|
||||
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor via enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
// ctrl+enter alias (to align with arrows)
|
||||
it("should enter line editor via ctrl+enter (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor via ctrl+enter (arrow)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.clickAt(midpoint[0], midpoint[1]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on ctrl+dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
mouse.doubleClick();
|
||||
});
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should enter line editor on dblclick (line)", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
|
||||
});
|
||||
|
||||
it("should not enter line editor on dblclick (arrow)", async () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement).toEqual(null);
|
||||
expect(h.state.selectedLinearElement).toBe(null);
|
||||
await getTextEditor();
|
||||
});
|
||||
|
||||
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
enterLineEditingMode(arrow);
|
||||
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||
|
||||
mouse.doubleClick();
|
||||
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
|
||||
expect(h.elements.length).toEqual(1);
|
||||
|
||||
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
|
||||
@@ -367,7 +377,7 @@ describe("Test Linear Elements", () => {
|
||||
// drag line from midpoint
|
||||
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -469,7 +479,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(startPoint, endPoint);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -537,7 +547,7 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -588,7 +598,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -629,7 +639,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
@@ -677,7 +687,7 @@ describe("Test Linear Elements", () => {
|
||||
deletePoint(points[2]);
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
`17`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
@@ -735,7 +745,7 @@ describe("Test Linear Elements", () => {
|
||||
),
|
||||
);
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
`14`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
@@ -833,7 +843,7 @@ describe("Test Linear Elements", () => {
|
||||
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
|
||||
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
`11`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getLineHeight } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "../src/textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||
|
||||
@@ -207,3 +208,172 @@ describe("Test getDefaultLineHeight", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeBoundTextPosition", () => {
|
||||
const createMockElementsMap = () => new Map();
|
||||
|
||||
// Helper function to create rectangle test case with 90-degree rotation
|
||||
const createRotatedRectangleTestCase = (
|
||||
textAlign: string,
|
||||
verticalAlign: string,
|
||||
) => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
angle: (Math.PI / 2) as any, // 90 degrees
|
||||
});
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
width: 80,
|
||||
height: 40,
|
||||
text: "hello darkness my old friend",
|
||||
textAlign: textAlign as any,
|
||||
verticalAlign: verticalAlign as any,
|
||||
containerId: container.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
const elementsMap = createMockElementsMap();
|
||||
|
||||
return { container, boundTextElement, elementsMap };
|
||||
};
|
||||
|
||||
describe("90-degree rotation with all alignment combinations", () => {
|
||||
// Test all 9 combinations of horizontal (left, center, right) and vertical (top, middle, bottom) alignment
|
||||
|
||||
it("should position text with LEFT + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with LEFT + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with LEFT + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.LEFT, VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(75, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.CENTER, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(
|
||||
TEXT_ALIGN.CENTER,
|
||||
VERTICAL_ALIGN.MIDDLE,
|
||||
);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with CENTER + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(
|
||||
TEXT_ALIGN.CENTER,
|
||||
VERTICAL_ALIGN.BOTTOM,
|
||||
);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(130, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + TOP alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.TOP);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(185, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + MIDDLE alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(160, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
|
||||
it("should position text with RIGHT + BOTTOM alignment at 90-degree rotation", () => {
|
||||
const { container, boundTextElement, elementsMap } =
|
||||
createRotatedRectangleTestCase(TEXT_ALIGN.RIGHT, VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
const result = computeBoundTextPosition(
|
||||
container,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
expect(result.x).toBeCloseTo(135, 1);
|
||||
expect(result.y).toBeCloseTo(185, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Alignment } from "@excalidraw/element";
|
||||
@@ -38,7 +40,11 @@ export const alignActionsPredicate = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -52,7 +58,12 @@ const alignSelectedElements = (
|
||||
) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment, app.scene);
|
||||
const updatedElements = alignElements(
|
||||
selectedElements,
|
||||
alignment,
|
||||
app.scene,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export const actionClearCanvas = register({
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: "selection" }
|
||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -494,13 +494,13 @@ export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
label: "toolBar.eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
@@ -530,6 +530,9 @@ export const actionToggleLassoTool = register({
|
||||
label: "toolBar.lasso",
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return app.defaultSelectionTool !== "lasso";
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
|
||||
@@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
|
||||
icon: TrashIcon,
|
||||
trackEvent: { category: "element", action: "delete" },
|
||||
perform: (elements, appState, formData, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
const {
|
||||
elementId,
|
||||
selectedPointsIndices,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
} = appState.editingLinearElement;
|
||||
} = appState.selectedLinearElement;
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
if (!element) {
|
||||
const linearElement = LinearElementEditor.getElement(
|
||||
elementId,
|
||||
elementsMap,
|
||||
);
|
||||
if (!linearElement) {
|
||||
return false;
|
||||
}
|
||||
// case: no point selected → do nothing, as deleting the whole element
|
||||
@@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
|
||||
return false;
|
||||
}
|
||||
|
||||
// case: deleting last remaining point
|
||||
if (element.points.length < 2) {
|
||||
// case: deleting all points
|
||||
if (selectedPointsIndices.length >= linearElement.points.length) {
|
||||
const nextElements = elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
if (el.id === linearElement.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
@@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -252,20 +255,24 @@ export const actionDeleteSelected = register({
|
||||
? null
|
||||
: startBindingElement,
|
||||
endBindingElement: selectedPointsIndices?.includes(
|
||||
element.points.length - 1,
|
||||
linearElement.points.length - 1,
|
||||
)
|
||||
? null
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
linearElement,
|
||||
app,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
selectedLinearElement: {
|
||||
...appState.selectedLinearElement,
|
||||
...binding,
|
||||
selectedPointsIndices:
|
||||
selectedPointsIndices?.[0] > 0
|
||||
@@ -291,7 +298,9 @@ export const actionDeleteSelected = register({
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
selectedLinearElement: null,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { getSelectedElementsByGroup } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { Distribution } from "@excalidraw/element";
|
||||
@@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
getSelectedElementsByGroup(
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
appState as Readonly<AppState>,
|
||||
).length > 2 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => isFrameLikeElement(el))
|
||||
);
|
||||
@@ -49,6 +55,7 @@ const distributeSelectedElements = (
|
||||
selectedElements,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
distribution,
|
||||
appState,
|
||||
);
|
||||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
@@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
|
||||
}
|
||||
|
||||
// duplicate selected point(s) if editing a line
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
|
||||
try {
|
||||
const newAppState = LinearElementEditor.duplicateSelectedPoints(
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isValidPolygon,
|
||||
LinearElementEditor,
|
||||
newElementWith,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
@@ -78,7 +82,14 @@ export const actionFinalize = register({
|
||||
let newElements = elements;
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, {
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
@@ -94,9 +105,9 @@ export const actionFinalize = register({
|
||||
}
|
||||
}
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
appState.selectedLinearElement;
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
|
||||
if (element) {
|
||||
@@ -117,12 +128,21 @@ export const actionFinalize = register({
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
? elements.filter((el) => el.id !== element.id)
|
||||
? elements.map((el) => {
|
||||
if (el.id === element.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
})
|
||||
: undefined,
|
||||
appState: {
|
||||
...appState,
|
||||
cursorButton: "up",
|
||||
editingLinearElement: null,
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
element,
|
||||
arrayToMap(elementsMap),
|
||||
false, // exit editing mode
|
||||
),
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
@@ -154,11 +174,7 @@ export const actionFinalize = register({
|
||||
|
||||
if (element) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
if (appState.multiElement && element.type !== "freedraw") {
|
||||
const { points, lastCommittedPoint } = element;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
@@ -172,7 +188,12 @@ export const actionFinalize = register({
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.map((el) => {
|
||||
if (el.id === element?.id) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
@@ -240,13 +261,13 @@ export const actionFinalize = register({
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "selection",
|
||||
type: app.defaultSelectionTool,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -289,7 +310,7 @@ export const actionFinalize = register({
|
||||
},
|
||||
keyTest: (event, appState) =>
|
||||
(event.key === KEYS.ESCAPE &&
|
||||
(appState.editingLinearElement !== null ||
|
||||
(appState.selectedLinearElement?.isEditing ||
|
||||
(!appState.newElement && appState.multiElement === null))) ||
|
||||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
|
||||
appState.multiElement !== null),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
import { arrayToMap, invariant } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
toggleLinePolygonState,
|
||||
@@ -46,7 +45,7 @@ export const actionToggleLinearEditor = register({
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
!isElbowArrow(selectedElements[0])
|
||||
@@ -61,14 +60,25 @@ export const actionToggleLinearEditor = register({
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? null
|
||||
: new LinearElementEditor(selectedElement, arrayToMap(elements));
|
||||
invariant(selectedElement, "No selected element found");
|
||||
invariant(
|
||||
appState.selectedLinearElement,
|
||||
"No selected linear element found",
|
||||
);
|
||||
invariant(
|
||||
selectedElement.id === appState.selectedLinearElement.elementId,
|
||||
"Selected element ID and linear editor elementId does not match",
|
||||
);
|
||||
|
||||
const selectedLinearElement = {
|
||||
...appState.selectedLinearElement,
|
||||
isEditing: !appState.selectedLinearElement.isEditing,
|
||||
};
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
editingLinearElement,
|
||||
selectedLinearElement,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ export const actionSelectAll = register({
|
||||
trackEvent: { category: "canvas" },
|
||||
viewMode: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ export type ShortcutName =
|
||||
| "saveScene"
|
||||
| "imageExport"
|
||||
| "commandPalette"
|
||||
| "searchMenu";
|
||||
| "searchMenu"
|
||||
| "toolLock";
|
||||
|
||||
const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleTheme: [getShortcutKey("Shift+Alt+D")],
|
||||
@@ -116,6 +117,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleShortcuts: [getShortcutKey("?")],
|
||||
searchMenu: [getShortcutKey("CtrlOrCmd+F")],
|
||||
wrapSelectionInFrame: [],
|
||||
toolLock: [getShortcutKey("Q")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
|
||||
|
||||
@@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit<
|
||||
newElement: null,
|
||||
editingTextElement: null,
|
||||
editingGroupId: null,
|
||||
editingLinearElement: null,
|
||||
activeTool: {
|
||||
type: "selection",
|
||||
customType: null,
|
||||
@@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
newElement: { browser: false, export: false, server: false },
|
||||
editingTextElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { SHAPES } from "./shapes";
|
||||
import { getToolbarTools } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
@@ -140,7 +140,7 @@ export const SelectedShapeActions = ({
|
||||
targetElements.length === 1 || isSingleElementBoundContainer;
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.editingLinearElement &&
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
@@ -295,7 +295,8 @@ export const ShapesSwitcher = ({
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const lassoToolSelected = activeTool.type === "lasso";
|
||||
const lassoToolSelected =
|
||||
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
|
||||
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
@@ -303,63 +304,68 @@ export const ShapesSwitcher = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
{getToolbarTools(app).map(
|
||||
({ value, icon, key, numericKey, fillable }, index) => {
|
||||
if (
|
||||
UIOptions.tools?.[
|
||||
value as Extract<
|
||||
typeof value,
|
||||
keyof AppProps["UIOptions"]["tools"]
|
||||
>
|
||||
] === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
app.togglePenMode(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
if (value === "selection") {
|
||||
if (appState.activeTool.type === "selection") {
|
||||
app.setActiveTool({ type: "lasso" });
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
}
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
if (value === "image") {
|
||||
app.setActiveTool({
|
||||
type: value,
|
||||
});
|
||||
} else {
|
||||
app.setActiveTool({ type: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
@@ -391,6 +397,7 @@ export const ShapesSwitcher = ({
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||
@@ -418,14 +425,16 @@ export const ShapesSwitcher = ({
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
{app.defaultSelectionTool !== "lasso" && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "lasso" })}
|
||||
icon={LassoIcon}
|
||||
data-testid="toolbar-lasso"
|
||||
selected={lassoToolSelected}
|
||||
>
|
||||
{t("toolBar.lasso")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
@@ -442,10 +451,10 @@ export const ShapesSwitcher = ({
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||
data-testid="toolbar-magicframe"
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
@@ -505,15 +514,3 @@ export const ExitZenModeAction = ({
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const FinalizeAction = ({
|
||||
renderAction,
|
||||
className,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`finalize-button ${className}`}>
|
||||
{renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ interface ButtonProps
|
||||
HTMLButtonElement
|
||||
> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => any;
|
||||
/** whether button is in active state */
|
||||
selected?: boolean;
|
||||
children: React.ReactNode;
|
||||
@@ -34,7 +34,7 @@ export const Button = ({
|
||||
return (
|
||||
<button
|
||||
onClick={composeEventHandlers(rest.onClick, (event) => {
|
||||
onSelect();
|
||||
onSelect(event);
|
||||
})}
|
||||
type={type}
|
||||
className={clsx("excalidraw-button", className, { selected })}
|
||||
|
||||
@@ -30,6 +30,18 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#canvas-bg-color-picker-container {
|
||||
.color-picker__top-picks {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-picker-container {
|
||||
@include isMobile {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__button {
|
||||
--radius: 4px;
|
||||
--size: 1.375rem;
|
||||
|
||||
@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
|
||||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
|
||||
import "./CommandPalette.scss";
|
||||
@@ -964,7 +966,7 @@ const CommandItem = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{command.label}
|
||||
<Ellipsify>{command.label}</Ellipsify>
|
||||
</div>
|
||||
{showShortcut && command.shortcut && (
|
||||
<CommandShortcutHint shortcut={command.shortcut} />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const Ellipsify = ({
|
||||
children,
|
||||
...rest
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
{...rest}
|
||||
style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
...rest.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -25,10 +25,6 @@ import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { QuickSearch } from "../QuickSearch";
|
||||
import { ScrollableList } from "../ScrollableList";
|
||||
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||
import DropdownMenuItem, {
|
||||
DropDownMenuItemBadgeType,
|
||||
DropDownMenuItemBadge,
|
||||
} from "../dropdownMenu/DropdownMenuItem";
|
||||
import {
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyHeadingIcon,
|
||||
@@ -36,8 +32,15 @@ import {
|
||||
FreedrawIcon,
|
||||
} from "../icons";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||
|
||||
import {
|
||||
FontPickerListItem,
|
||||
FontPickerListItemBadgeType,
|
||||
} from "./FontPickerListItem";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
export interface FontDescriptor {
|
||||
@@ -46,7 +49,7 @@ export interface FontDescriptor {
|
||||
text: string;
|
||||
deprecated?: true;
|
||||
badge?: {
|
||||
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||
type: ValueOf<typeof FontPickerListItemBadgeType>;
|
||||
placeholder: string;
|
||||
};
|
||||
}
|
||||
@@ -112,7 +115,7 @@ export const FontPickerList = React.memo(
|
||||
Object.assign(fontDescriptor, {
|
||||
deprecated: metadata.deprecated,
|
||||
badge: {
|
||||
type: DropDownMenuItemBadgeType.RED,
|
||||
type: FontPickerListItemBadgeType.RED,
|
||||
placeholder: t("fontList.badge.old"),
|
||||
},
|
||||
});
|
||||
@@ -227,7 +230,7 @@ export const FontPickerList = React.memo(
|
||||
);
|
||||
|
||||
const renderFont = (font: FontDescriptor, index: number) => (
|
||||
<DropdownMenuItem
|
||||
<FontPickerListItem
|
||||
key={font.value}
|
||||
icon={font.icon}
|
||||
value={font.value}
|
||||
@@ -239,8 +242,8 @@ export const FontPickerList = React.memo(
|
||||
selected={font.value === selectedFontFamily}
|
||||
// allow to tab between search and selected font
|
||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
onSelect(Number(e.currentTarget.value));
|
||||
onSelect={() => {
|
||||
onSelect(font.value);
|
||||
}}
|
||||
onMouseMove={() => {
|
||||
if (hoveredFont?.value !== font.value) {
|
||||
@@ -248,13 +251,13 @@ export const FontPickerList = React.memo(
|
||||
}
|
||||
}}
|
||||
>
|
||||
{font.text}
|
||||
<Ellipsify>{font.text}</Ellipsify>
|
||||
{font.badge && (
|
||||
<DropDownMenuItemBadge type={font.badge.type}>
|
||||
<FontPickerListItem.Badge type={font.badge.type}>
|
||||
{font.badge.placeholder}
|
||||
</DropDownMenuItemBadge>
|
||||
</FontPickerListItem.Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</FontPickerListItem>
|
||||
);
|
||||
|
||||
const groups = [];
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { THEME } from "@excalidraw/common";
|
||||
|
||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { Button } from "../Button";
|
||||
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
|
||||
import { getDropdownMenuItemClassName } from "../dropdownMenu/common";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const MenuItemContent = ({
|
||||
textStyle,
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
shortcut?: string;
|
||||
textStyle?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<div style={textStyle} className="dropdown-menu-item__text">
|
||||
{children}
|
||||
</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const FontPickerListItem = ({
|
||||
icon,
|
||||
value,
|
||||
order,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
hovered,
|
||||
selected,
|
||||
textStyle,
|
||||
onSelect,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
value?: string | number | undefined;
|
||||
order?: number;
|
||||
onSelect: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
hovered?: boolean;
|
||||
selected?: boolean;
|
||||
textStyle?: React.CSSProperties;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered) {
|
||||
if (order === 0) {
|
||||
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
||||
ref.current?.scrollIntoView({ block: "end" });
|
||||
} else {
|
||||
ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [hovered, order]);
|
||||
|
||||
return (
|
||||
<div className="radix-menu-item">
|
||||
<Button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
onSelect={onSelect}
|
||||
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
FontPickerListItem.displayName = "FontPickerListItem";
|
||||
|
||||
export const FontPickerListItemBadgeType = {
|
||||
GREEN: "green",
|
||||
RED: "red",
|
||||
BLUE: "blue",
|
||||
} as const;
|
||||
|
||||
export const FontPickerListItemBadge = ({
|
||||
type = FontPickerListItemBadgeType.BLUE,
|
||||
children,
|
||||
}: {
|
||||
type?: ValueOf<typeof FontPickerListItemBadgeType>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { theme } = useExcalidrawAppState();
|
||||
const style = {
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
padding: "2px 4px",
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontFamily: "Cascadia, monospace",
|
||||
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case FontPickerListItemBadgeType.GREEN:
|
||||
Object.assign(style, {
|
||||
backgroundColor: "var(--background-color-badge)",
|
||||
color: "var(--color-badge)",
|
||||
});
|
||||
break;
|
||||
case FontPickerListItemBadgeType.RED:
|
||||
Object.assign(style, {
|
||||
backgroundColor: "pink",
|
||||
color: "darkred",
|
||||
});
|
||||
break;
|
||||
case FontPickerListItemBadgeType.BLUE:
|
||||
default:
|
||||
Object.assign(style, {
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DropDownMenuItemBadge" style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
FontPickerListItemBadge.displayName = "DropdownMenuItemBadge";
|
||||
|
||||
FontPickerListItem.Badge = FontPickerListItemBadge;
|
||||
@@ -238,7 +238,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[getShortcutKey("Enter"), getShortcutKey("Escape")]}
|
||||
isOr={true}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.lock")}
|
||||
shortcuts={[getShortcutFromShortcutName("toolLock")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||
|
||||
@@ -115,7 +115,7 @@ const getHints = ({
|
||||
appState.selectionElement &&
|
||||
!selectedElements.length &&
|
||||
!appState.editingTextElement &&
|
||||
!appState.editingLinearElement
|
||||
!appState.selectedLinearElement?.isEditing
|
||||
) {
|
||||
return [t("hints.deepBoxSelect")];
|
||||
}
|
||||
@@ -130,8 +130,8 @@ const getHints = ({
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
if (isLinearElement(selectedElements[0])) {
|
||||
if (appState.editingLinearElement) {
|
||||
return appState.editingLinearElement.selectedPointsIndices
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return appState.selectedLinearElement.selectedPointsIndices
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||
display: "inline-block",
|
||||
lineHeight: 0,
|
||||
verticalAlign: "middle",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -194,6 +194,7 @@ export const LibraryDropdownMenuButton: React.FC<{
|
||||
<DropdownMenu open={isLibraryMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||
aria-label="Library menu"
|
||||
>
|
||||
{DotsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -201,6 +202,7 @@ export const LibraryDropdownMenuButton: React.FC<{
|
||||
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
||||
onSelect={() => setIsLibraryMenuOpen(false)}
|
||||
className="library-menu"
|
||||
align="end"
|
||||
>
|
||||
{!itemsSelected && (
|
||||
<DropdownMenu.Item
|
||||
|
||||
@@ -26,9 +26,9 @@ export const TTDDialogTrigger = ({
|
||||
setAppState({ openDialog: { name: "ttd", tab: "text-to-diagram" } });
|
||||
}}
|
||||
icon={icon ?? brainIcon}
|
||||
badge={<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>}
|
||||
>
|
||||
{children ?? t("labels.textToDiagram")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
</TTDDialogTriggerTunnel.In>
|
||||
);
|
||||
|
||||
@@ -192,7 +192,6 @@ const getRelevantAppStateProps = (
|
||||
viewModeEnabled: appState.viewModeEnabled,
|
||||
openDialog: appState.openDialog,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
editingLinearElement: appState.editingLinearElement,
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
offsetLeft: appState.offsetLeft,
|
||||
|
||||
@@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const isComponentMounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
props.canvas.style.width = `${props.appState.width}px`;
|
||||
props.canvas.style.height = `${props.appState.height}px`;
|
||||
props.canvas.width = props.appState.width * props.scale;
|
||||
props.canvas.height = props.appState.height * props.scale;
|
||||
}, [props.appState.height, props.appState.width, props.canvas, props.scale]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) {
|
||||
@@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
|
||||
canvas.classList.add("excalidraw__canvas", "static");
|
||||
}
|
||||
|
||||
const widthString = `${props.appState.width}px`;
|
||||
const heightString = `${props.appState.height}px`;
|
||||
if (canvas.style.width !== widthString) {
|
||||
canvas.style.width = widthString;
|
||||
}
|
||||
if (canvas.style.height !== heightString) {
|
||||
canvas.style.height = heightString;
|
||||
}
|
||||
|
||||
const scaledWidth = props.appState.width * props.scale;
|
||||
const scaledHeight = props.appState.height * props.scale;
|
||||
// setting width/height resets the canvas even if dimensions not changed,
|
||||
// which would cause flicker when we skip frame (due to throttling)
|
||||
if (canvas.width !== scaledWidth) {
|
||||
canvas.width = scaledWidth;
|
||||
}
|
||||
if (canvas.height !== scaledHeight) {
|
||||
canvas.height = scaledHeight;
|
||||
}
|
||||
|
||||
renderStaticScene(
|
||||
{
|
||||
canvas,
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
@import "../../css/variables.module.scss";
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
[data-dropdown-menu-trigger] + [data-radix-popper-content-wrapper] {
|
||||
z-index: 2 !important;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 0.5rem;
|
||||
max-width: 16rem;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&__submenu-trigger {
|
||||
&[aria-expanded="true"] {
|
||||
.dropdown-menu-item {
|
||||
background-color: var(--button-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__submenu-trigger-icon {
|
||||
margin-left: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.radix-menu-item {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-submenu {
|
||||
margin-left: -0.75rem;
|
||||
min-width: 16rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
&--mobile {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
row-gap: 0.75rem;
|
||||
|
||||
.dropdown-menu-container {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
// background-color: var(--island-bg-color);
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
@@ -28,13 +55,14 @@
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--island-bg-color);
|
||||
max-height: calc(100vh - 150px);
|
||||
max-height: var(--radix-popper-available-height);
|
||||
overflow-y: auto;
|
||||
--gap: 2;
|
||||
}
|
||||
|
||||
.dropdown-menu-item-base {
|
||||
display: flex;
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-on-surface);
|
||||
@@ -42,6 +70,7 @@
|
||||
box-sizing: border-box;
|
||||
font-weight: 400;
|
||||
font-family: inherit;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&.manual-hover {
|
||||
@@ -100,6 +129,7 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
flex: 1 0 auto;
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import DropdownMenuContent from "./DropdownMenuContent";
|
||||
import DropdownMenuGroup from "./DropdownMenuGroup";
|
||||
import DropdownMenuItem from "./DropdownMenuItem";
|
||||
@@ -23,11 +25,12 @@ const DropdownMenu = ({
|
||||
}) => {
|
||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||
const MenuContentComp = getMenuContentComponent(children);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuPrimitive.Root open={open} modal={false}>
|
||||
{MenuTriggerComp}
|
||||
{open && MenuContentComp}
|
||||
</>
|
||||
{MenuContentComp}
|
||||
</DropdownMenuPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { EVENT, KEYS } from "@excalidraw/common";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import { useOutsideClick } from "../../hooks/useOutsideClick";
|
||||
import { useStable } from "../../hooks/useStable";
|
||||
import { useDevice } from "../App";
|
||||
@@ -17,6 +19,9 @@ const MenuContent = ({
|
||||
className = "",
|
||||
onSelect,
|
||||
style,
|
||||
sideOffset = 4,
|
||||
align = "start",
|
||||
collisionPadding,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClickOutside?: () => void;
|
||||
@@ -26,6 +31,11 @@ const MenuContent = ({
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
style?: React.CSSProperties;
|
||||
sideOffset?: number;
|
||||
align?: "start" | "center" | "end";
|
||||
collisionPadding?:
|
||||
| number
|
||||
| Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -62,11 +72,15 @@ const MenuContent = ({
|
||||
|
||||
return (
|
||||
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
||||
<div
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={menuRef}
|
||||
className={classNames}
|
||||
style={style}
|
||||
data-testid="dropdown-menu"
|
||||
side="bottom"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
collisionPadding={collisionPadding}
|
||||
>
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
@@ -81,7 +95,7 @@ const MenuContent = ({
|
||||
{children}
|
||||
</Island>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuContentPropsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
|
||||
import { THEME } from "@excalidraw/common";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { Button } from "../Button";
|
||||
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
|
||||
import {
|
||||
getDropdownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
@@ -17,55 +22,45 @@ import type { JSX } from "react";
|
||||
const DropdownMenuItem = ({
|
||||
icon,
|
||||
value,
|
||||
badge,
|
||||
order,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
hovered,
|
||||
selected,
|
||||
textStyle,
|
||||
onSelect,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
badge?: React.ReactNode;
|
||||
value?: string | number | undefined;
|
||||
order?: number;
|
||||
onSelect?: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
hovered?: boolean;
|
||||
|
||||
selected?: boolean;
|
||||
textStyle?: React.CSSProperties;
|
||||
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered) {
|
||||
if (order === 0) {
|
||||
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
||||
ref.current?.scrollIntoView({ block: "end" });
|
||||
} else {
|
||||
ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [hovered, order]);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onClick={handleClick}
|
||||
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</button>
|
||||
<DropdownMenuPrimitive.Item className="radix-menu-item">
|
||||
<Button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
onSelect={handleClick}
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut} badge={badge}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
};
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useDevice } from "../App";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
const MenuItemContent = ({
|
||||
textStyle,
|
||||
icon,
|
||||
badge,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
icon?: React.ReactNode;
|
||||
shortcut?: string;
|
||||
textStyle?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<div style={textStyle} className="dropdown-menu-item__text">
|
||||
{children}
|
||||
<div className="dropdown-menu-item__text">
|
||||
<Ellipsify>{children}</Ellipsify>
|
||||
{badge}
|
||||
</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import {
|
||||
getSubMenuContentComponent,
|
||||
getSubMenuTriggerComponent,
|
||||
} from "./dropdownMenuUtils";
|
||||
import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger";
|
||||
import DropdownMenuSubContent from "./DropdownMenuSubContent";
|
||||
import DropdownMenuSubItem from "./DropdownMenuSubItem";
|
||||
|
||||
const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => {
|
||||
const MenuTriggerComp = getSubMenuTriggerComponent(children);
|
||||
const MenuContentComp = getSubMenuContentComponent(children);
|
||||
return (
|
||||
<DropdownMenuPrimitive.Sub>
|
||||
{MenuTriggerComp}
|
||||
{MenuContentComp}
|
||||
</DropdownMenuPrimitive.Sub>
|
||||
);
|
||||
};
|
||||
|
||||
DropdownMenuSub.Trigger = DropdownMenuSubTrigger;
|
||||
DropdownMenuSub.Content = DropdownMenuSubContent;
|
||||
DropdownMenuSub.Item = DropdownMenuSubItem;
|
||||
|
||||
export default DropdownMenuSub;
|
||||
DropdownMenuSub.displayName = "DropdownMenuSub";
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
import Stack from "../Stack";
|
||||
import { Island } from "../Island";
|
||||
|
||||
const DropdownMenuSubContent = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
|
||||
const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, {
|
||||
"dropdown-menu--mobile": device.editor.isMobile,
|
||||
}).trim();
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
className={classNames}
|
||||
sideOffset={8}
|
||||
alignOffset={-4}
|
||||
>
|
||||
{device.editor.isMobile ? (
|
||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||
) : (
|
||||
<Island
|
||||
className="dropdown-menu-container"
|
||||
padding={1}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</Island>
|
||||
)}
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuSubContent;
|
||||
DropdownMenuSubContent.displayName = "DropdownMenuSubContent";
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import { Button } from "../Button";
|
||||
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
import {
|
||||
getDropdownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
|
||||
const DropdownMenuSubItem = ({
|
||||
icon,
|
||||
onSelect,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item className="radix-menu-item">
|
||||
<Button
|
||||
{...rest}
|
||||
onSelect={handleClick}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</Button>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuSubItem;
|
||||
DropdownMenuSubItem.displayName = "DropdownMenuSubItem";
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { ChevronRight } from "../icons";
|
||||
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
import { getDropdownMenuItemClassName } from "./common";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
const DropdownMenuSubTrigger = ({
|
||||
children,
|
||||
icon,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
icon?: JSX.Element;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger className="radix-menu-item dropdown-menu__submenu-trigger">
|
||||
<div
|
||||
{...rest}
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon}>{children}</MenuItemContent>
|
||||
<div className="dropdown-menu__submenu-trigger-icon">
|
||||
{ChevronRight}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuSubTrigger;
|
||||
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger";
|
||||
@@ -1,5 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
@@ -23,7 +25,8 @@ const MenuTrigger = ({
|
||||
},
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-dropdown-menu-trigger
|
||||
data-prevent-outside-click
|
||||
className={classNames}
|
||||
onClick={onToggle}
|
||||
@@ -33,7 +36,7 @@ const MenuTrigger = ({
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
||||
const getMenuComponent = (component: string) => (children: React.ReactNode) => {
|
||||
const comp = React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
@@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
||||
//@ts-ignore
|
||||
child?.type.displayName &&
|
||||
//@ts-ignore
|
||||
child.type.displayName === "DropdownMenuTrigger",
|
||||
child.type.displayName === component,
|
||||
);
|
||||
if (!comp) {
|
||||
return null;
|
||||
@@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
||||
return comp;
|
||||
};
|
||||
|
||||
export const getMenuContentComponent = (children: React.ReactNode) => {
|
||||
const comp = React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
//@ts-ignore
|
||||
child?.type.displayName &&
|
||||
//@ts-ignore
|
||||
child.type.displayName === "DropdownMenuContent",
|
||||
);
|
||||
if (!comp) {
|
||||
return null;
|
||||
}
|
||||
//@ts-ignore
|
||||
return comp;
|
||||
};
|
||||
export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger");
|
||||
export const getMenuContentComponent = getMenuComponent("DropdownMenuContent");
|
||||
export const getSubMenuTriggerComponent = getMenuComponent(
|
||||
"DropdownMenuSubTrigger",
|
||||
);
|
||||
export const getSubMenuContentComponent = getMenuComponent(
|
||||
"DropdownMenuSubContent",
|
||||
);
|
||||
|
||||
@@ -2,13 +2,7 @@ import clsx from "clsx";
|
||||
|
||||
import { actionShortcuts } from "../../actions";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
UndoRedoActions,
|
||||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
@@ -29,10 +23,6 @@ const Footer = ({
|
||||
}) => {
|
||||
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
@@ -60,15 +50,6 @@ const Footer = ({
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{showFinalize && (
|
||||
<FinalizeAction
|
||||
renderAction={actionManager.renderAction}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,15 @@ const modifiedTablerIconProps: Opts = {
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
//tabler-icons: chevron-right
|
||||
export const ChevronRight = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: present
|
||||
export const PlusPromoIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
@@ -2269,3 +2278,21 @@ export const elementLinkIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const settingsIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 6l8 0" />
|
||||
<path d="M16 6l4 0" />
|
||||
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 12l2 0" />
|
||||
<path d="M10 12l10 0" />
|
||||
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 18l11 0" />
|
||||
<path d="M19 18l1 0" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const emptyIcon = <div style={{ width: "1rem", height: "1rem" }} />;
|
||||
|
||||
@@ -9,8 +9,11 @@ import {
|
||||
actionLoadScene,
|
||||
actionSaveToActiveFile,
|
||||
actionShortcuts,
|
||||
actionToggleGridMode,
|
||||
actionToggleObjectsSnapMode,
|
||||
actionToggleSearchMenu,
|
||||
actionToggleTheme,
|
||||
actionToggleZenMode,
|
||||
} from "../../actions";
|
||||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { trackEvent } from "../../analytics";
|
||||
@@ -23,13 +26,23 @@ import {
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawElements,
|
||||
useAppProps,
|
||||
useApp,
|
||||
} from "../App";
|
||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||
import Trans from "../Trans";
|
||||
import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
|
||||
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
|
||||
import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink";
|
||||
import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons";
|
||||
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
|
||||
import { actionToggleViewMode } from "../../actions/actionToggleViewMode";
|
||||
import {
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
XBrandIcon,
|
||||
settingsIcon,
|
||||
checkIcon,
|
||||
emptyIcon,
|
||||
} from "../icons";
|
||||
import {
|
||||
boltIcon,
|
||||
DeviceDesktopIcon,
|
||||
@@ -313,7 +326,10 @@ export const ChangeCanvasBackground = () => {
|
||||
>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
<div
|
||||
style={{ padding: "0 0.625rem" }}
|
||||
id="canvas-bg-color-picker-container"
|
||||
>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,3 +409,73 @@ export const LiveCollaborationTrigger = ({
|
||||
};
|
||||
|
||||
LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";
|
||||
|
||||
export const Preferences = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const appState = useUIAppState();
|
||||
const app = useApp();
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSub.Trigger icon={settingsIcon}>
|
||||
{t("labels.preferences")}
|
||||
</DropdownMenuSub.Trigger>
|
||||
<DropdownMenuSub.Content className="excalidraw-main-menu-preferences-submenu">
|
||||
<DropdownMenuSub.Item
|
||||
icon={appState.activeTool.locked ? checkIcon : emptyIcon}
|
||||
shortcut={getShortcutFromShortcutName("toolLock")}
|
||||
onSelect={(event) => {
|
||||
app.toggleLock();
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("labels.preferences_toolLock")}
|
||||
</DropdownMenuSub.Item>
|
||||
<DropdownMenuSub.Item
|
||||
icon={appState.objectsSnapModeEnabled ? checkIcon : emptyIcon}
|
||||
shortcut={getShortcutFromShortcutName("objectsSnapMode")}
|
||||
onSelect={(event) => {
|
||||
actionManager.executeAction(actionToggleObjectsSnapMode);
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("buttons.objectsSnapMode")}
|
||||
</DropdownMenuSub.Item>
|
||||
<DropdownMenuSub.Item
|
||||
icon={appState.gridModeEnabled ? checkIcon : emptyIcon}
|
||||
shortcut={getShortcutFromShortcutName("gridMode")}
|
||||
onSelect={(event) => {
|
||||
actionManager.executeAction(actionToggleGridMode);
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("labels.toggleGrid")}
|
||||
</DropdownMenuSub.Item>
|
||||
<DropdownMenuSub.Item
|
||||
icon={appState.zenModeEnabled ? checkIcon : emptyIcon}
|
||||
shortcut={getShortcutFromShortcutName("zenMode")}
|
||||
onSelect={(event) => {
|
||||
actionManager.executeAction(actionToggleZenMode);
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("buttons.zenMode")}
|
||||
</DropdownMenuSub.Item>
|
||||
<DropdownMenuSub.Item
|
||||
icon={appState.viewModeEnabled ? checkIcon : emptyIcon}
|
||||
shortcut={getShortcutFromShortcutName("viewMode")}
|
||||
onSelect={(event) => {
|
||||
actionManager.executeAction(actionToggleViewMode);
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t("labels.viewMode")}
|
||||
</DropdownMenuSub.Item>
|
||||
{children}
|
||||
</DropdownMenuSub.Content>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
};
|
||||
|
||||
Preferences.displayName = "Preferences";
|
||||
|
||||
@@ -2,8 +2,12 @@ import React from "react";
|
||||
|
||||
import { composeEventHandlers } from "@excalidraw/common";
|
||||
|
||||
import * as Portal from "@radix-ui/react-portal";
|
||||
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { useDevice, useExcalidrawSetAppState } from "../App";
|
||||
import { UserList } from "../UserList";
|
||||
@@ -36,6 +40,17 @@ const MainMenu = Object.assign(
|
||||
|
||||
return (
|
||||
<MainMenuTunnel.In>
|
||||
{appState.openMenu === "canvas" && device.editor.isMobile && (
|
||||
<Portal.Root
|
||||
style={{
|
||||
backgroundColor: "rgba(18, 18, 18, 0.2)",
|
||||
position: "fixed",
|
||||
inset: "0px",
|
||||
// zIndex: "var(--zIndex-layerUI)",
|
||||
}}
|
||||
onClick={() => setAppState({ openMenu: null })}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu open={appState.openMenu === "canvas"}>
|
||||
<DropdownMenu.Trigger
|
||||
onToggle={() => {
|
||||
@@ -44,15 +59,27 @@ const MainMenu = Object.assign(
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
aria-label="Main menu"
|
||||
className="main-menu-trigger"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
sideOffset={device.editor.isMobile ? 20 : undefined}
|
||||
className="main-menu-content"
|
||||
onClickOutside={onClickOutside}
|
||||
onSelect={composeEventHandlers(onSelect, () => {
|
||||
setAppState({ openMenu: null });
|
||||
})}
|
||||
collisionPadding={
|
||||
// accounting for
|
||||
// - editor footer on desktop
|
||||
// - toolbar on mobile
|
||||
// we probably don't want the menu to overlay these elements
|
||||
!device.editor.isMobile
|
||||
? { bottom: 90, top: 10 }
|
||||
: { top: 90, bottom: 10 }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
||||
@@ -78,6 +105,7 @@ const MainMenu = Object.assign(
|
||||
ItemCustom: DropdownMenu.ItemCustom,
|
||||
Group: DropdownMenu.Group,
|
||||
Separator: DropdownMenu.Separator,
|
||||
Sub: DropdownMenuSub,
|
||||
DefaultItems,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EraserIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
export const SHAPES = [
|
||||
{
|
||||
icon: SelectionIcon,
|
||||
@@ -86,8 +88,23 @@ export const SHAPES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const findShapeByKey = (key: string) => {
|
||||
const shape = SHAPES.find((shape, index) => {
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.defaultSelectionTool === "lasso"
|
||||
? ([
|
||||
{
|
||||
value: "lasso",
|
||||
icon: SelectionIcon,
|
||||
key: KEYS.V,
|
||||
numericKey: KEYS["1"],
|
||||
fillable: true,
|
||||
},
|
||||
...SHAPES.slice(1),
|
||||
] as const)
|
||||
: SHAPES;
|
||||
};
|
||||
|
||||
export const findShapeByKey = (key: string, app: AppClassProperties) => {
|
||||
const shape = getToolbarTools(app).find((shape, index) => {
|
||||
return (
|
||||
(shape.numericKey != null && key === shape.numericKey.toString()) ||
|
||||
(shape.key &&
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
--color-logo-icon: var(--color-primary);
|
||||
--color-logo-text: #190064;
|
||||
|
||||
--border-radius-sm: 0.25rem;
|
||||
--border-radius-md: 0.375rem;
|
||||
--border-radius-lg: 0.5rem;
|
||||
|
||||
|
||||
@@ -170,7 +170,11 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{
|
||||
repairBindings: true,
|
||||
refreshDimensions: false,
|
||||
deleteInvisibleElements: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
|
||||
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
|
||||
MakeBrand<"RemoteExcalidrawElement">;
|
||||
|
||||
const shouldDiscardRemoteElement = (
|
||||
export const shouldDiscardRemoteElement = (
|
||||
localAppState: AppState,
|
||||
local: OrderedExcalidrawElement | undefined,
|
||||
remote: RemoteExcalidrawElement,
|
||||
@@ -30,7 +30,7 @@ const shouldDiscardRemoteElement = (
|
||||
// local element is being edited
|
||||
(local.id === localAppState.editingTextElement?.id ||
|
||||
local.id === localAppState.resizingElement?.id ||
|
||||
local.id === localAppState.newElement?.id || // TODO: Is this still valid? As newElement is selection element, which is never part of the elements array
|
||||
local.id === localAppState.newElement?.id ||
|
||||
// local element is newer
|
||||
local.version > remote.version ||
|
||||
// resolve conflicting edits deterministically by taking the one with
|
||||
|
||||
@@ -241,8 +241,9 @@ const restoreElementWithProperties = <
|
||||
return ret;
|
||||
};
|
||||
|
||||
const restoreElement = (
|
||||
export const restoreElement = (
|
||||
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||||
opts?: { deleteInvisibleElements?: boolean },
|
||||
): typeof element | null => {
|
||||
element = { ...element };
|
||||
|
||||
@@ -290,7 +291,8 @@ const restoreElement = (
|
||||
|
||||
// if empty text, mark as deleted. We keep in array
|
||||
// for data integrity purposes (collab etc.)
|
||||
if (!text && !element.isDeleted) {
|
||||
if (opts?.deleteInvisibleElements && !text && !element.isDeleted) {
|
||||
// TODO: we should not do this since it breaks sync / versioning when we exchange / apply just deltas and restore the elements (deletion isn't recorded)
|
||||
element = { ...element, originalText: text, isDeleted: true };
|
||||
element = bumpVersion(element);
|
||||
}
|
||||
@@ -385,7 +387,10 @@ const restoreElement = (
|
||||
elbowed: true,
|
||||
startBinding: repairBinding(element, element.startBinding),
|
||||
endBinding: repairBinding(element, element.endBinding),
|
||||
fixedSegments: element.fixedSegments,
|
||||
fixedSegments:
|
||||
element.fixedSegments?.length && base.points.length >= 4
|
||||
? element.fixedSegments
|
||||
: null,
|
||||
startIsSpecial: element.startIsSpecial,
|
||||
endIsSpecial: element.endIsSpecial,
|
||||
})
|
||||
@@ -523,7 +528,13 @@ export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
|
||||
opts?:
|
||||
| {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
}
|
||||
| undefined,
|
||||
): OrderedExcalidrawElement[] => {
|
||||
// used to detect duplicate top-level element ids
|
||||
const existingIds = new Set<string>();
|
||||
@@ -532,24 +543,38 @@ export const restoreElements = (
|
||||
(elements || []).reduce((elements, element) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
// and causing issues if retained
|
||||
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element);
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
if (localElement && localElement.version > migratedElement.version) {
|
||||
migratedElement = bumpVersion(
|
||||
migratedElement,
|
||||
localElement.version,
|
||||
);
|
||||
}
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
if (element.type === "selection") {
|
||||
return elements;
|
||||
}
|
||||
|
||||
let migratedElement: ExcalidrawElement | null = restoreElement(element, {
|
||||
deleteInvisibleElements: opts?.deleteInvisibleElements,
|
||||
});
|
||||
if (migratedElement) {
|
||||
const localElement = localElementsMap?.get(element.id);
|
||||
|
||||
const shouldMarkAsDeleted =
|
||||
opts?.deleteInvisibleElements && isInvisiblySmallElement(element);
|
||||
|
||||
if (
|
||||
shouldMarkAsDeleted ||
|
||||
(localElement && localElement.version > migratedElement.version)
|
||||
) {
|
||||
migratedElement = bumpVersion(migratedElement, localElement?.version);
|
||||
}
|
||||
|
||||
if (shouldMarkAsDeleted) {
|
||||
migratedElement = { ...migratedElement, isDeleted: true };
|
||||
}
|
||||
|
||||
if (existingIds.has(migratedElement.id)) {
|
||||
migratedElement = { ...migratedElement, id: randomId() };
|
||||
}
|
||||
existingIds.add(migratedElement.id);
|
||||
|
||||
elements.push(migratedElement);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}, [] as ExcalidrawElement[]),
|
||||
);
|
||||
@@ -790,7 +815,11 @@ export const restore = (
|
||||
*/
|
||||
localAppState: Partial<AppState> | null | undefined,
|
||||
localElements: readonly ExcalidrawElement[] | null | undefined,
|
||||
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
|
||||
elementsConfig?: {
|
||||
refreshDimensions?: boolean;
|
||||
repairBindings?: boolean;
|
||||
deleteInvisibleElements?: boolean;
|
||||
},
|
||||
): RestoredDataState => {
|
||||
return {
|
||||
elements: restoreElements(data?.elements, localElements, elementsConfig),
|
||||
|
||||
@@ -175,7 +175,7 @@ export class History {
|
||||
let nextAppState = appState;
|
||||
let containsVisibleChange = false;
|
||||
|
||||
// iterate through the history entries in case ;they result in no visible changes
|
||||
// iterate through the history entries in case they result in no visible changes
|
||||
while (historyDelta) {
|
||||
try {
|
||||
[nextElements, nextAppState, containsVisibleChange] =
|
||||
|
||||
@@ -229,6 +229,7 @@ export { defaultLang, useI18n, languages } from "./i18n";
|
||||
export {
|
||||
restore,
|
||||
restoreAppState,
|
||||
restoreElement,
|
||||
restoreElements,
|
||||
restoreLibraryItems,
|
||||
} from "./data/restore";
|
||||
@@ -281,6 +282,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
|
||||
export { Button } from "./components/Button";
|
||||
export { Footer };
|
||||
export { MainMenu };
|
||||
export { Ellipsify } from "./components/Ellipsify";
|
||||
export { useDevice } from "./components/App";
|
||||
export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
|
||||
@@ -171,7 +171,9 @@
|
||||
"linkToElement": "Link to object",
|
||||
"wrapSelectionInFrame": "Wrap selection in frame",
|
||||
"tab": "Tab",
|
||||
"shapeSwitch": "Switch shape"
|
||||
"shapeSwitch": "Switch shape",
|
||||
"preferences": "Preferences",
|
||||
"preferences_toolLock": "Tool lock"
|
||||
},
|
||||
"elementLink": {
|
||||
"title": "Link to object",
|
||||
|
||||
@@ -81,11 +81,13 @@
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/common": "0.18.0",
|
||||
"@excalidraw/element": "0.18.0",
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
|
||||
"@excalidraw/math": "0.18.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.1.3",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-popover": "1.1.6",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-tabs": "1.1.3",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"canvas-roundrect-polyfill": "0.0.1",
|
||||
@@ -97,8 +99,8 @@
|
||||
"image-blob-reduce": "3.0.1",
|
||||
"jotai": "2.11.0",
|
||||
"jotai-scope": "0.7.2",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"nanoid": "3.3.3",
|
||||
"open-color": "1.9.1",
|
||||
"pako": "2.0.3",
|
||||
|
||||
@@ -118,7 +118,8 @@ const renderLinearElementPointHighlight = (
|
||||
) => {
|
||||
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
|
||||
if (
|
||||
appState.editingLinearElement?.selectedPointsIndices?.includes(
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement?.selectedPointsIndices?.includes(
|
||||
hoverPointIndex,
|
||||
)
|
||||
) {
|
||||
@@ -180,7 +181,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
point[0],
|
||||
point[1],
|
||||
(isOverlappingPoint
|
||||
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||
? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
|
||||
: radius) / appState.zoom.value,
|
||||
!isPhantomPoint,
|
||||
!isOverlappingPoint || isSelected,
|
||||
@@ -448,7 +449,7 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
const { POINT_HANDLE_SIZE } = LinearElementEditor;
|
||||
const radius = appState.editingLinearElement
|
||||
const radius = appState.selectedLinearElement?.isEditing
|
||||
? POINT_HANDLE_SIZE
|
||||
: POINT_HANDLE_SIZE / 2;
|
||||
|
||||
@@ -470,7 +471,8 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
let isSelected =
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
!!appState.selectedLinearElement?.isEditing &&
|
||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
// when element is a polygon, highlight the last point as well if first
|
||||
// point is selected since they overlap and the last point tends to be
|
||||
// rendered on top
|
||||
@@ -479,7 +481,8 @@ const renderLinearPointHandles = (
|
||||
element.polygon &&
|
||||
!isSelected &&
|
||||
idx === element.points.length - 1 &&
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
|
||||
!!appState.selectedLinearElement?.isEditing &&
|
||||
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
|
||||
) {
|
||||
isSelected = true;
|
||||
}
|
||||
@@ -535,7 +538,7 @@ const renderLinearPointHandles = (
|
||||
);
|
||||
|
||||
midPoints.forEach((segmentMidPoint) => {
|
||||
if (appState.editingLinearElement || points.length === 2) {
|
||||
if (appState.selectedLinearElement?.isEditing || points.length === 2) {
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
@@ -760,7 +763,10 @@ const _renderInteractiveScene = ({
|
||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||
// ShapeCache returns empty hence making sure that we get the
|
||||
// correct element from visible elements
|
||||
if (appState.editingLinearElement?.elementId === element.id) {
|
||||
if (
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement.elementId === element.id
|
||||
) {
|
||||
if (element) {
|
||||
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
|
||||
}
|
||||
@@ -853,7 +859,8 @@ const _renderInteractiveScene = ({
|
||||
// correct element from visible elements
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
appState.editingLinearElement?.elementId === selectedElements[0].id
|
||||
appState.selectedLinearElement?.isEditing &&
|
||||
appState.selectedLinearElement.elementId === selectedElements[0].id
|
||||
) {
|
||||
renderLinearPointHandles(
|
||||
context,
|
||||
@@ -884,7 +891,7 @@ const _renderInteractiveScene = ({
|
||||
}
|
||||
|
||||
// Paint selected elements
|
||||
if (!appState.multiElement && !appState.editingLinearElement) {
|
||||
if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
|
||||
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
|
||||
|
||||
const isSingleLinearElementSelected =
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { throttleRAF } from "@excalidraw/common";
|
||||
|
||||
import { renderElement } from "@excalidraw/element";
|
||||
import {
|
||||
getTargetFrame,
|
||||
isInvisiblySmallElement,
|
||||
renderElement,
|
||||
shouldApplyFrameClip,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
|
||||
|
||||
import { frameClip } from "./staticScene";
|
||||
|
||||
import type { NewElementSceneRenderConfig } from "../scene/types";
|
||||
|
||||
const _renderNewElementScene = ({
|
||||
@@ -29,11 +36,37 @@ const _renderNewElementScene = ({
|
||||
normalizedHeight,
|
||||
});
|
||||
|
||||
// Apply zoom
|
||||
context.save();
|
||||
|
||||
// Apply zoom
|
||||
context.scale(appState.zoom.value, appState.zoom.value);
|
||||
|
||||
if (newElement && newElement.type !== "selection") {
|
||||
// e.g. when creating arrows and we're still below the arrow drag distance
|
||||
// threshold
|
||||
// (for now we skip render only with elements while we're creating to be
|
||||
// safe)
|
||||
if (isInvisiblySmallElement(newElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameId = newElement.frameId || appState.frameToHighlight?.id;
|
||||
|
||||
if (
|
||||
frameId &&
|
||||
appState.frameRendering.enabled &&
|
||||
appState.frameRendering.clip
|
||||
) {
|
||||
const frame = getTargetFrame(newElement, elementsMap, appState);
|
||||
|
||||
if (
|
||||
frame &&
|
||||
shouldApplyFrameClip(newElement, frame, appState, elementsMap)
|
||||
) {
|
||||
frameClip(frame, context, renderConfig, appState);
|
||||
}
|
||||
}
|
||||
|
||||
renderElement(
|
||||
newElement,
|
||||
elementsMap,
|
||||
@@ -46,6 +79,8 @@ const _renderNewElementScene = ({
|
||||
} else {
|
||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ const strokeGrid = (
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const frameClip = (
|
||||
export const frameClip = (
|
||||
frame: ExcalidrawFrameLikeElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderConfig: StaticCanvasRenderConfig,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getDraggedElementsBounds,
|
||||
getElementAbsoluteCoords,
|
||||
} from "@excalidraw/element";
|
||||
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
|
||||
import { isBoundToContainer } from "@excalidraw/element";
|
||||
|
||||
import { getMaximumGroups } from "@excalidraw/element";
|
||||
|
||||
@@ -169,8 +169,14 @@ export const isSnappingEnabled = ({
|
||||
selectedElements: NonDeletedExcalidrawElement[];
|
||||
}) => {
|
||||
if (event) {
|
||||
// Allow snapping for lasso tool when dragging selected elements
|
||||
// but not during lasso selection phase
|
||||
const isLassoDragging =
|
||||
app.state.activeTool.type === "lasso" &&
|
||||
app.state.selectedElementsAreBeingDragged;
|
||||
|
||||
return (
|
||||
app.state.activeTool.type !== "lasso" &&
|
||||
(app.state.activeTool.type !== "lasso" || isLassoDragging) &&
|
||||
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
|
||||
(!app.state.objectsSnapModeEnabled &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -311,20 +317,13 @@ const getReferenceElements = (
|
||||
selectedElements: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
elementsMap: ElementsMap,
|
||||
) => {
|
||||
const selectedFrames = selectedElements
|
||||
.filter((element) => isFrameLikeElement(element))
|
||||
.map((frame) => frame.id);
|
||||
|
||||
return getVisibleAndNonSelectedElements(
|
||||
) =>
|
||||
getVisibleAndNonSelectedElements(
|
||||
elements,
|
||||
selectedElements,
|
||||
appState,
|
||||
elementsMap,
|
||||
).filter(
|
||||
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getVisibleGaps = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
|
||||
@@ -908,7 +908,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1106,7 +1105,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1319,7 +1317,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1649,7 +1646,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1979,7 +1975,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2192,7 +2187,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2432,7 +2426,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2729,7 +2722,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3100,7 +3092,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3592,7 +3583,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3692,14 +3682,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1116226695,
|
||||
"seed": 400692809,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 4,
|
||||
"versionNonce": 23633383,
|
||||
"versionNonce": 81784553,
|
||||
"width": 20,
|
||||
"x": 20,
|
||||
"y": 30,
|
||||
@@ -3724,14 +3714,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"opacity": 100,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": 1278240551,
|
||||
"seed": 449462985,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 3,
|
||||
"versionNonce": 401146281,
|
||||
"versionNonce": 1150084233,
|
||||
"width": 20,
|
||||
"x": -10,
|
||||
"y": 0,
|
||||
@@ -3914,7 +3904,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -4236,7 +4225,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -5520,7 +5508,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -6736,7 +6723,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -7670,7 +7656,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8669,7 +8654,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9659,7 +9643,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
|
||||
@@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Click me
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Click me
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<a
|
||||
@@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Excalidraw blog
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Excalidraw blog
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div
|
||||
@@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Help
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Open
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Open
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Save to...
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Save to...
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Export image...
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Find on canvas
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Find on canvas
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Help
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
@@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Reset the canvas
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Reset the canvas
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
@@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
GitHub
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
GitHub
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
@@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Follow us
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Follow us
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
@@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Discord chat
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Discord chat
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
<span
|
||||
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
|
||||
>
|
||||
Dark mode
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -459,7 +458,6 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -874,7 +872,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": "id28",
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1439,7 +1436,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -1645,7 +1641,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2028,7 +2023,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2272,7 +2266,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2451,7 +2444,6 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -2775,7 +2767,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3029,7 +3020,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3269,7 +3259,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3504,7 +3493,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -3761,7 +3749,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -4074,7 +4061,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -4509,7 +4495,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -4791,7 +4776,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -5066,7 +5050,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -5273,7 +5256,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -5472,7 +5454,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": "id11",
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -5864,7 +5845,6 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -6160,7 +6140,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -6415,13 +6394,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id9": true,
|
||||
},
|
||||
"selectedLinearElementId": "id9",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id9",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id6": true,
|
||||
},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6489,13 +6471,19 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id12": true,
|
||||
},
|
||||
"selectedLinearElementId": "id12",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id12",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id9": true,
|
||||
},
|
||||
"selectedLinearElementId": "id9",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id9",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6561,13 +6549,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id15": true,
|
||||
},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id12": true,
|
||||
},
|
||||
"selectedLinearElementId": "id12",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id12",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6694,10 +6685,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": "id15",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id15",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6715,13 +6709,16 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id22": true,
|
||||
},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {
|
||||
"id15": true,
|
||||
},
|
||||
"selectedLinearElementId": "id15",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id15",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6846,10 +6843,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": "id22",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id22",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6884,10 +6884,13 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": "id22",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id22",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6991,7 +6994,6 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -7324,7 +7326,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -7602,7 +7603,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -7836,7 +7836,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8075,7 +8074,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8190,7 +8188,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -8203,7 +8201,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"version": 3,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -8254,7 +8252,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8369,7 +8366,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -8382,7 +8379,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"version": 3,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -8433,7 +8430,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8548,7 +8544,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -8561,7 +8557,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"version": 3,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -8612,7 +8608,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8676,6 +8671,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"endBindingElement": "keep",
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"isEditing": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
@@ -8735,11 +8731,14 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -8758,7 +8757,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@@ -8771,8 +8770,8 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -8786,7 +8785,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"version": 4,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -8837,7 +8836,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -8901,6 +8899,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"endBindingElement": "keep",
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"isEditing": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
@@ -8960,11 +8959,14 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -8982,7 +8984,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@@ -8995,8 +8997,8 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
@@ -9009,7 +9011,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"version": 4,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -9060,7 +9062,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9167,12 +9168,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": [
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -9183,12 +9184,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
],
|
||||
"pressures": [
|
||||
@@ -9204,7 +9205,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
|
||||
"strokeWidth": 2,
|
||||
"type": "freedraw",
|
||||
"version": 4,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -9255,7 +9256,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9319,6 +9319,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"endBindingElement": "keep",
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"isEditing": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
@@ -9378,11 +9379,14 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -9401,7 +9405,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@@ -9414,8 +9418,8 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -9429,7 +9433,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
|
||||
"strokeWidth": 2,
|
||||
"type": "arrow",
|
||||
"version": 4,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -9480,7 +9484,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9595,7 +9598,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -9608,7 +9611,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
|
||||
"strokeWidth": 2,
|
||||
"type": "diamond",
|
||||
"version": 3,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -9659,7 +9662,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9723,6 +9725,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"endBindingElement": "keep",
|
||||
"hoverPointIndex": -1,
|
||||
"isDragging": false,
|
||||
"isEditing": false,
|
||||
"lastUncommittedPoint": null,
|
||||
"pointerDownState": {
|
||||
"lastClickedIsEndPoint": false,
|
||||
@@ -9782,11 +9785,14 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
"selectedElementIds": {
|
||||
"id0": true,
|
||||
},
|
||||
"selectedLinearElementId": "id0",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id0",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
"inserted": {
|
||||
"selectedElementIds": {},
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -9804,7 +9810,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": null,
|
||||
@@ -9817,8 +9823,8 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
@@ -9831,7 +9837,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
"strokeWidth": 2,
|
||||
"type": "line",
|
||||
"version": 4,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -9882,7 +9888,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -9997,7 +10002,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -10010,7 +10015,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
|
||||
"strokeWidth": 2,
|
||||
"type": "ellipse",
|
||||
"version": 3,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -10061,7 +10066,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -10168,12 +10172,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lastCommittedPoint": [
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
"link": null,
|
||||
"locked": false,
|
||||
@@ -10184,12 +10188,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
|
||||
0,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
[
|
||||
10,
|
||||
10,
|
||||
30,
|
||||
30,
|
||||
],
|
||||
],
|
||||
"pressures": [
|
||||
@@ -10205,7 +10209,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
|
||||
"strokeWidth": 2,
|
||||
"type": "freedraw",
|
||||
"version": 4,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -10256,7 +10260,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -10371,7 +10374,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 10,
|
||||
"height": 30,
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"link": null,
|
||||
@@ -10384,7 +10387,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"version": 3,
|
||||
"width": 10,
|
||||
"width": 30,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
},
|
||||
@@ -10435,7 +10438,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -10965,7 +10967,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -11244,7 +11245,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -11366,7 +11366,6 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -11565,7 +11564,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -11883,7 +11881,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -12311,7 +12308,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -12950,7 +12946,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -13075,7 +13070,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": "id11",
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -13705,7 +13699,6 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -14043,7 +14036,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -14306,7 +14298,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -14428,7 +14419,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -14520,10 +14510,13 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
|
||||
"appState": AppStateDelta {
|
||||
"delta": Delta {
|
||||
"deleted": {
|
||||
"selectedLinearElementId": null,
|
||||
"selectedLinearElement": null,
|
||||
},
|
||||
"inserted": {
|
||||
"selectedLinearElementId": "id6",
|
||||
"selectedLinearElement": {
|
||||
"elementId": "id6",
|
||||
"isEditing": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -14816,7 +14809,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
@@ -14938,7 +14930,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
|
||||
@@ -60,7 +60,11 @@ describe("restoreElements", () => {
|
||||
const rectElement = API.createElement({ type: "rectangle" });
|
||||
mockSizeHelper.mockImplementation(() => true);
|
||||
|
||||
expect(restore.restoreElements([rectElement], null).length).toBe(0);
|
||||
expect(
|
||||
restore.restoreElements([rectElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
}),
|
||||
).toEqual([expect.objectContaining({ isDeleted: true })]);
|
||||
});
|
||||
|
||||
it("should restore text element correctly passing value for each attribute", () => {
|
||||
@@ -85,6 +89,23 @@ describe("restoreElements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not delete empty text element when opts.deleteInvisibleElements is not defined", () => {
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "",
|
||||
isDeleted: false,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([textElement], null);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: textElement.id,
|
||||
isDeleted: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should restore text element correctly with unknown font family, null text and undefined alignment", () => {
|
||||
const textElement: any = API.createElement({
|
||||
type: "text",
|
||||
@@ -97,10 +118,9 @@ describe("restoreElements", () => {
|
||||
textElement.font = "10 unknown";
|
||||
|
||||
expect(textElement.isDeleted).toBe(false);
|
||||
const restoredText = restore.restoreElements(
|
||||
[textElement],
|
||||
null,
|
||||
)[0] as ExcalidrawTextElement;
|
||||
const restoredText = restore.restoreElements([textElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
})[0] as ExcalidrawTextElement;
|
||||
expect(restoredText.isDeleted).toBe(true);
|
||||
expect(restoredText).toMatchSnapshot({
|
||||
seed: expect.any(Number),
|
||||
@@ -177,13 +197,16 @@ describe("restoreElements", () => {
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
const restoredElements = restore.restoreElements([arrowElement], null, {
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
|
||||
const restoredArrow = restoredElements[0] as
|
||||
| ExcalidrawArrowElement
|
||||
| undefined;
|
||||
|
||||
expect(restoredArrow).toBeUndefined();
|
||||
expect(restoredArrow).not.toBeUndefined();
|
||||
expect(restoredArrow?.isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||
@@ -848,6 +871,7 @@ describe("repairing bindings", () => {
|
||||
let restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
@@ -855,6 +879,11 @@ describe("repairing bindings", () => {
|
||||
id: container.id,
|
||||
boundElements: [obsoleteBinding, invisibleBinding, nonExistentBinding],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: invisibleBoundElement.id,
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
@@ -864,7 +893,7 @@ describe("repairing bindings", () => {
|
||||
restoredElements = restore.restoreElements(
|
||||
[container, invisibleBoundElement, boundElement],
|
||||
null,
|
||||
{ repairBindings: true },
|
||||
{ repairBindings: true, deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
@@ -872,6 +901,11 @@ describe("repairing bindings", () => {
|
||||
id: container.id,
|
||||
boundElements: [],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: invisibleBoundElement.id,
|
||||
containerId: container.id,
|
||||
isDeleted: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: boundElement.id,
|
||||
containerId: container.id,
|
||||
|
||||
@@ -315,7 +315,12 @@ describe("Test dragCreate", () => {
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "arrow",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("line", async () => {
|
||||
@@ -344,7 +349,12 @@ describe("Test dragCreate", () => {
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`5`);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(0);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "line",
|
||||
isDeleted: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { queryByText, queryByTestId } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { THEME } from "@excalidraw/common";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { Excalidraw, Footer, MainMenu } from "../index";
|
||||
import { Excalidraw, Footer } from "..";
|
||||
import MainMenu from "../components/main-menu/MainMenu";
|
||||
|
||||
import { fireEvent, GlobalTestState, toggleMenu, render } from "./test-utils";
|
||||
import {
|
||||
render,
|
||||
togglePopover,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
} from "./test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -15,7 +20,7 @@ describe("<Excalidraw/>", () => {
|
||||
afterEach(() => {
|
||||
const menu = document.querySelector(".dropdown-menu");
|
||||
if (menu) {
|
||||
toggleMenu(document.querySelector(".excalidraw")!);
|
||||
togglePopover("Main menu");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,7 +141,7 @@ describe("<Excalidraw/>", () => {
|
||||
<Excalidraw UIOptions={undefined} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -145,7 +150,7 @@ describe("<Excalidraw/>", () => {
|
||||
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -154,7 +159,7 @@ describe("<Excalidraw/>", () => {
|
||||
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "json-export-button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -163,7 +168,7 @@ describe("<Excalidraw/>", () => {
|
||||
<Excalidraw UIOptions={{ canvasActions: { saveAsImage: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "image-export-button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -182,7 +187,7 @@ describe("<Excalidraw/>", () => {
|
||||
/>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "save-as-button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -193,7 +198,7 @@ describe("<Excalidraw/>", () => {
|
||||
/>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "save-button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -204,7 +209,7 @@ describe("<Excalidraw/>", () => {
|
||||
/>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
||||
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
||||
});
|
||||
@@ -220,7 +225,7 @@ describe("<Excalidraw/>", () => {
|
||||
</Excalidraw>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "canvas-background-label")).toBeNull();
|
||||
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
|
||||
});
|
||||
@@ -230,7 +235,7 @@ describe("<Excalidraw/>", () => {
|
||||
<Excalidraw UIOptions={{ canvasActions: { toggleTheme: false } }} />,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -251,8 +256,8 @@ describe("<Excalidraw/>", () => {
|
||||
</Excalidraw>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
// load button shouldn't be rendered since `UIActions.canvasActions.loadScene` is `false`
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "load-button")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -263,7 +268,7 @@ describe("<Excalidraw/>", () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBeTruthy();
|
||||
});
|
||||
@@ -273,7 +278,7 @@ describe("<Excalidraw/>", () => {
|
||||
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "toggle-dark-mode")).toBe(null);
|
||||
});
|
||||
|
||||
@@ -286,7 +291,7 @@ describe("<Excalidraw/>", () => {
|
||||
);
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBeTruthy();
|
||||
});
|
||||
@@ -300,7 +305,7 @@ describe("<Excalidraw/>", () => {
|
||||
);
|
||||
expect(h.state.theme).toBe(THEME.DARK);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||
expect(darkModeToggle).toBe(null);
|
||||
});
|
||||
@@ -310,7 +315,7 @@ describe("<Excalidraw/>", () => {
|
||||
it("should allow editing name", async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
fireEvent.click(queryByTestId(container, "image-export-button")!);
|
||||
const textInput: HTMLInputElement | null = document.querySelector(
|
||||
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
||||
@@ -323,7 +328,7 @@ describe("<Excalidraw/>", () => {
|
||||
const name = "test";
|
||||
const { container } = await render(<Excalidraw name={name} />);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
await fireEvent.click(queryByTestId(container, "image-export-button")!);
|
||||
const textInput = document.querySelector(
|
||||
".ImageExportModal .ImageExportModal__preview__filename .TextInput",
|
||||
@@ -375,7 +380,7 @@ describe("<Excalidraw/>", () => {
|
||||
</Excalidraw>,
|
||||
);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
expect(queryByTestId(container, "dropdown-menu")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -394,7 +399,7 @@ describe("<Excalidraw/>", () => {
|
||||
|
||||
const { container } = await render(<CustomExcalidraw />);
|
||||
//open menu
|
||||
toggleMenu(container);
|
||||
togglePopover("Main menu");
|
||||
|
||||
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||
|
||||
|
||||
@@ -1073,7 +1073,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(6);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1090,7 +1090,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1114,7 +1114,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
expect(API.getRedoStack().length).toBe(2);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1131,7 +1131,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(3);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull(); // undo `open editor`
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1148,7 +1148,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(4);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1165,7 +1165,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(5);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1181,7 +1181,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(6);
|
||||
expect(API.getSelectedElements().length).toBe(0);
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1197,7 +1197,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(5);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1213,7 +1213,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(2);
|
||||
expect(API.getRedoStack().length).toBe(4);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1230,7 +1230,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(3);
|
||||
expect(API.getRedoStack().length).toBe(3);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull(); // undo `open editor`
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1247,7 +1247,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
expect(API.getRedoStack().length).toBe(2);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1264,7 +1264,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(5);
|
||||
expect(API.getRedoStack().length).toBe(1);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
|
||||
expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -1281,7 +1281,7 @@ describe("history", () => {
|
||||
expect(API.getUndoStack().length).toBe(6);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(assertSelectedElements(h.elements[0]));
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -3029,8 +3029,8 @@ describe("history", () => {
|
||||
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement).not.toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
|
||||
|
||||
// Simulate remote update
|
||||
API.updateScene({
|
||||
@@ -3043,16 +3043,15 @@ describe("history", () => {
|
||||
});
|
||||
|
||||
Keyboard.undo();
|
||||
expect(API.getUndoStack().length).toBe(1);
|
||||
expect(API.getRedoStack().length).toBe(3);
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(API.getUndoStack().length).toBe(0);
|
||||
expect(API.getRedoStack().length).toBe(4);
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
|
||||
Keyboard.redo();
|
||||
expect(API.getUndoStack().length).toBe(4);
|
||||
expect(API.getRedoStack().length).toBe(0);
|
||||
expect(h.state.editingLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement).toBeNull();
|
||||
expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
|
||||
});
|
||||
|
||||
it("should iterate through the history when z-index changes do not produce visible change and we synced changed indices", async () => {
|
||||
@@ -4056,7 +4055,7 @@ describe("history", () => {
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
boundElements: [{ id: remoteText.id, type: "text" }],
|
||||
isDeleted: false, // isDeleted got remotely updated to false
|
||||
isDeleted: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: text.id,
|
||||
@@ -4065,7 +4064,6 @@ describe("history", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: remoteText.id,
|
||||
// unbound
|
||||
containerId: container.id,
|
||||
isDeleted: false,
|
||||
}),
|
||||
@@ -4356,8 +4354,8 @@ describe("history", () => {
|
||||
expect.objectContaining({
|
||||
...textProps,
|
||||
// text element got redrawn!
|
||||
x: 205,
|
||||
y: 205,
|
||||
x: 241.295259647664,
|
||||
y: 247.59240920619527,
|
||||
angle: 90,
|
||||
id: text.id,
|
||||
containerId: container.id,
|
||||
@@ -4400,8 +4398,8 @@ describe("history", () => {
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...textProps,
|
||||
x: 205,
|
||||
y: 205,
|
||||
x: 241.295259647664,
|
||||
y: 247.59240920619527,
|
||||
angle: 90,
|
||||
id: text.id,
|
||||
containerId: container.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, queryByTestId } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { queryByTestId } from "@testing-library/react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
|
||||
@@ -13,9 +13,11 @@ import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { Excalidraw } from "../index";
|
||||
|
||||
import { fireEvent, render, togglePopover, waitFor } from "./test-utils";
|
||||
|
||||
import { API } from "./helpers/api";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
|
||||
import { getCloneByOrigId } from "./test-utils";
|
||||
|
||||
import type { LibraryItem, LibraryItems } from "../types";
|
||||
|
||||
@@ -215,12 +217,13 @@ describe("library menu", () => {
|
||||
const libraryButton = container.querySelector(".sidebar-trigger");
|
||||
|
||||
fireEvent.click(libraryButton!);
|
||||
fireEvent.click(
|
||||
queryByTestId(
|
||||
container.querySelector(".layer-ui__library")!,
|
||||
"dropdown-menu-button",
|
||||
)!,
|
||||
);
|
||||
togglePopover("Library menu");
|
||||
// fireEvent.click(
|
||||
// queryByTestId(
|
||||
// container.querySelector(".layer-ui__library")!,
|
||||
// "dropdown-menu-button",
|
||||
// )!,
|
||||
// );
|
||||
fireEvent.click(queryByTestId(container, "lib-dropdown--load")!);
|
||||
|
||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||
|
||||
@@ -2,26 +2,46 @@
|
||||
|
||||
exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu items if passed from host 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="radix-:r65:"
|
||||
aria-orientation="vertical"
|
||||
class="dropdown-menu main-menu-content"
|
||||
data-align="start"
|
||||
data-orientation="vertical"
|
||||
data-radix-menu-content=""
|
||||
data-side="bottom"
|
||||
data-state="open"
|
||||
data-testid="dropdown-menu"
|
||||
dir="ltr"
|
||||
id="radix-:r66:"
|
||||
role="menu"
|
||||
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
style="--padding: 1; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
type="button"
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
<button
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
type="button"
|
||||
>
|
||||
Click me
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
/>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Click me
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<a
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
href="https://plus.excalidraw.com/blog"
|
||||
@@ -46,301 +66,361 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
|
||||
custom menu item
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should render menu with default items when "UIOPtions" is "undefined" 1`] = `
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="radix-:rq:"
|
||||
aria-orientation="vertical"
|
||||
class="dropdown-menu main-menu-content"
|
||||
data-align="start"
|
||||
data-orientation="vertical"
|
||||
data-radix-menu-content=""
|
||||
data-side="bottom"
|
||||
data-state="open"
|
||||
data-testid="dropdown-menu"
|
||||
dir="ltr"
|
||||
id="radix-:rr:"
|
||||
role="menu"
|
||||
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); animation: none;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="Island dropdown-menu-container"
|
||||
style="--padding: 2; z-index: 2;"
|
||||
style="--padding: 1; z-index: 2;"
|
||||
>
|
||||
<button
|
||||
aria-label="Open"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="load-button"
|
||||
title="Open"
|
||||
type="button"
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
<button
|
||||
aria-label="Open"
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="load-button"
|
||||
title="Open"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<path
|
||||
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Save to..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="json-export-button"
|
||||
title="Save to..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Save to...
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Export image..."
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="image-export-button"
|
||||
title="Export image..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
<path
|
||||
d="M15 8h.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
|
||||
/>
|
||||
<path
|
||||
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
|
||||
/>
|
||||
<path
|
||||
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
|
||||
/>
|
||||
<path
|
||||
d="M19 16v6"
|
||||
/>
|
||||
<path
|
||||
d="M22 19l-3 3l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+O
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
aria-label="Save to..."
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="json-export-button"
|
||||
title="Save to..."
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
d="M3.333 14.167v1.666c0 .92.747 1.667 1.667 1.667h10c.92 0 1.667-.746 1.667-1.667v-1.666M5.833 9.167 10 13.333l4.167-4.166M10 3.333v10"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Reset the canvas"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="clear-canvas-button"
|
||||
title="Reset the canvas"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
<path
|
||||
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
Save to...
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
aria-label="Export image..."
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="image-export-button"
|
||||
title="Export image..."
|
||||
type="button"
|
||||
>
|
||||
Reset the canvas
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.25"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<path
|
||||
d="M15 8h.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 20h-5a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v5"
|
||||
/>
|
||||
<path
|
||||
d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l4 4"
|
||||
/>
|
||||
<path
|
||||
d="M14 14l1 -1c.617 -.593 1.328 -.793 2.009 -.598"
|
||||
/>
|
||||
<path
|
||||
d="M19 16v6"
|
||||
/>
|
||||
<path
|
||||
d="M22 19l-3 3l-3 -3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Export image...
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Ctrl+Shift+E
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
aria-label="Help"
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="help-menu-item"
|
||||
title="Help"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
stroke="none"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="17"
|
||||
y2="17.01"
|
||||
/>
|
||||
<path
|
||||
d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Help
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button
|
||||
aria-label="Reset the canvas"
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="clear-canvas-button"
|
||||
title="Reset the canvas"
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M3.333 5.833h13.334M8.333 9.167v5M11.667 9.167v5M4.167 5.833l.833 10c0 .92.746 1.667 1.667 1.667h6.666c.92 0 1.667-.746 1.667-1.667l.833-10M7.5 5.833v-2.5c0-.46.373-.833.833-.833h3.334c.46 0 .833.373.833.833v2.5"
|
||||
stroke-width="1.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Reset the canvas
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
@@ -473,45 +553,53 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
<div
|
||||
style="height: 1px; margin: .5rem 0px;"
|
||||
/>
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
class="dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="toggle-dark-mode"
|
||||
title="Dark mode"
|
||||
type="button"
|
||||
<div
|
||||
class="radix-menu-item"
|
||||
data-orientation="vertical"
|
||||
data-radix-collection-item=""
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
<button
|
||||
aria-label="Dark mode"
|
||||
class="excalidraw-button dropdown-menu-item dropdown-menu-item-base"
|
||||
data-testid="toggle-dark-mode"
|
||||
title="Dark mode"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
<div
|
||||
class="dropdown-menu-item__icon"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class=""
|
||||
fill="none"
|
||||
focusable="false"
|
||||
role="img"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Shift+Alt+D
|
||||
</div>
|
||||
</button>
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10 2.5h.328a6.25 6.25 0 0 0 6.6 10.372A7.5 7.5 0 1 1 10 2.493V2.5Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__text"
|
||||
>
|
||||
Dark mode
|
||||
</div>
|
||||
<div
|
||||
class="dropdown-menu-item__shortcut"
|
||||
>
|
||||
Shift+Alt+D
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style="margin-top: 0.5rem;"
|
||||
>
|
||||
@@ -522,6 +610,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
Canvas background
|
||||
</div>
|
||||
<div
|
||||
id="canvas-bg-color-picker-container"
|
||||
style="padding: 0px 0.625rem;"
|
||||
>
|
||||
<div>
|
||||
@@ -593,7 +682,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
style="width: 1px; height: 100%; margin: 0px auto;"
|
||||
/>
|
||||
<button
|
||||
aria-controls="radix-:r0:"
|
||||
aria-controls="radix-:r12:"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("regression tests", () => {
|
||||
expect(h.state.activeTool.type).toBe(shape);
|
||||
|
||||
mouse.down(10, 10);
|
||||
mouse.up(10, 10);
|
||||
mouse.up(30, 30);
|
||||
|
||||
if (shouldSelect) {
|
||||
expect(API.getSelectedElement().type).toBe(shape);
|
||||
|
||||
@@ -213,7 +213,6 @@ export type InteractiveCanvasAppState = Readonly<
|
||||
_CommonCanvasAppState & {
|
||||
// renderInteractiveScene
|
||||
activeEmbeddable: AppState["activeEmbeddable"];
|
||||
editingLinearElement: AppState["editingLinearElement"];
|
||||
selectionElement: AppState["selectionElement"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
selectedLinearElement: AppState["selectedLinearElement"];
|
||||
@@ -249,10 +248,10 @@ export type ObservedElementsAppState = {
|
||||
editingGroupId: AppState["editingGroupId"];
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
selectedGroupIds: AppState["selectedGroupIds"];
|
||||
// Avoiding storing whole instance, as it could lead into state incosistencies, empty undos/redos and etc.
|
||||
editingLinearElementId: LinearElementEditor["elementId"] | null;
|
||||
// Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId
|
||||
selectedLinearElementId: LinearElementEditor["elementId"] | null;
|
||||
selectedLinearElement: {
|
||||
elementId: LinearElementEditor["elementId"];
|
||||
isEditing: boolean;
|
||||
} | null;
|
||||
croppingElementId: AppState["croppingElementId"];
|
||||
lockedMultiSelections: AppState["lockedMultiSelections"];
|
||||
activeLockedId: AppState["activeLockedId"];
|
||||
@@ -307,7 +306,6 @@ export interface AppState {
|
||||
* set when a new text is created or when an existing text is being edited
|
||||
*/
|
||||
editingTextElement: NonDeletedExcalidrawElement | null;
|
||||
editingLinearElement: LinearElementEditor | null;
|
||||
activeTool: {
|
||||
/**
|
||||
* indicates a previous tool we should revert back to if we deselect the
|
||||
@@ -733,6 +731,8 @@ export type AppClassProperties = {
|
||||
|
||||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||||
updateEditorAtom: App["updateEditorAtom"];
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso";
|
||||
};
|
||||
|
||||
export type PointerDownState = Readonly<{
|
||||
@@ -782,6 +782,10 @@ export type PointerDownState = Readonly<{
|
||||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||||
// to current pointer position at time of duplication.
|
||||
origin: { x: number; y: number };
|
||||
// Whether to block drag after lasso selection
|
||||
// this is meant to be used to block dragging after lasso selection on PCs
|
||||
// until the next pointer down
|
||||
blockDragging: boolean;
|
||||
};
|
||||
// We need to have these in the state so that we can unsubscribe them
|
||||
eventListeners: {
|
||||
@@ -803,6 +807,7 @@ export type UnsubscribeCallback = () => void;
|
||||
|
||||
export interface ExcalidrawImperativeAPI {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||||
mutateElement: InstanceType<typeof App>["mutateElement"];
|
||||
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
||||
resetScene: InstanceType<typeof App>["resetScene"];
|
||||
|
||||
@@ -1198,7 +1198,11 @@ describe("textWysiwyg", () => {
|
||||
updateTextEditor(editor, " ");
|
||||
Keyboard.exitTextEditor(editor);
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
expect(h.elements[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
isDeleted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
|
||||
@@ -215,11 +215,12 @@ export const textWysiwyg = ({
|
||||
);
|
||||
app.scene.mutateElement(container, { height: targetContainerHeight });
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
const { x, y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
elementsMap,
|
||||
);
|
||||
coordX = x;
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const exportToCanvas = ({
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
const { exportBackground, viewBackgroundColor } = restoredAppState;
|
||||
return _exportToCanvas(
|
||||
@@ -179,6 +180,7 @@ export const exportToSvg = async ({
|
||||
{ elements, appState },
|
||||
null,
|
||||
null,
|
||||
{ deleteInvisibleElements: true },
|
||||
);
|
||||
|
||||
const exportAppState = {
|
||||
|
||||
@@ -34,7 +34,6 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"defaultSidebarDockedPreference": false,
|
||||
"editingFrame": null,
|
||||
"editingGroupId": null,
|
||||
"editingLinearElement": null,
|
||||
"editingTextElement": null,
|
||||
"elementsToHighlight": null,
|
||||
"errorMessage": null,
|
||||
|
||||
+2
-2
@@ -144,7 +144,7 @@ const askToCommit = (tag, nextVersion) => {
|
||||
});
|
||||
|
||||
rl.question(
|
||||
"Do you want to commit these changes to git? (Y/n): ",
|
||||
"Would you like to commit these changes to git? (Y/n): ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
|
||||
@@ -189,7 +189,7 @@ const askToPublish = (tag, version) => {
|
||||
});
|
||||
|
||||
rl.question(
|
||||
"Do you want to publish these changes to npm? (Y/n): ",
|
||||
"Would you like to publish these changes to npm? (Y/n): ",
|
||||
(answer) => {
|
||||
rl.close();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user