Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Di
118fd7bafa fix: lint 2025-07-03 16:50:57 +10:00
Soham Kulkarni
21f492fb13
Update App.tsx 2025-07-02 23:01:15 +05:30
112 changed files with 1957 additions and 6664 deletions

View File

@ -33,7 +33,6 @@ const ExcalidrawScope = {
initialData,
useI18n: ExcalidrawComp.useI18n,
convertToExcalidrawElements: ExcalidrawComp.convertToExcalidrawElements,
CaptureUpdateAction: ExcalidrawComp.CaptureUpdateAction,
};
export default ExcalidrawScope;

View File

@ -20,6 +20,7 @@ import {
APP_NAME,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
debounce,
getVersion,
@ -498,6 +499,11 @@ const ExcalidrawWrapper = () => {
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
@ -588,6 +594,7 @@ const ExcalidrawWrapper = () => {
visibilityChange,
false,
);
clearTimeout(titleTimeout);
};
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);

View File

@ -8,8 +8,7 @@ export const SYNC_BROWSER_TABS_TIMEOUT = 50;
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
// should be aligned with MAX_ALLOWED_FILE_BYTES
export const FILE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; // 4 MiB
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
// 1 year (https://stackoverflow.com/a/25201898/927631)
export const FILE_CACHE_MAX_AGE_SEC = 31536000;

View File

@ -9,7 +9,7 @@ import {
} from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types";
import { throttleRAF } from "@excalidraw/common";
import { useCallback } from "react";
import { useCallback, useImperativeHandle, useRef } from "react";
import {
isLineSegment,
@ -18,8 +18,6 @@ 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";
@ -115,6 +113,10 @@ const _debugRenderer = (
scale,
);
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({
canvas,
scale,
@ -312,29 +314,35 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
interface DebugCanvasProps {
appState: AppState;
scale: number;
ref?: React.Ref<HTMLCanvasElement>;
}
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => {
const { width, height } = appState;
const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
const { width, height } = appState;
return (
<canvas
style={{
width,
height,
position: "absolute",
zIndex: 2,
pointerEvents: "none",
}}
width={width * scale}
height={height * scale}
ref={ref}
>
Debug Canvas
</canvas>
);
},
);
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>
);
};
export default DebugCanvas;

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Excalidraw Whiteboard</title>
<title>Excalidraw | Hand-drawn look & feel • Collaborative • Secure</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="Free, collaborative whiteboard • Hand-drawn look & feel | Excalidraw"
content="Excalidraw — Collaborative whiteboarding made easy"
/>
<meta
name="description"

View File

@ -36,7 +36,7 @@ describe("Test MobileMenu", () => {
},
"isTouchScreen": false,
"viewport": {
"isLandscape": true,
"isLandscape": false,
"isMobile": true,
},
}

View File

@ -18,20 +18,13 @@ export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1;
export const isSafari =
!isChrome && navigator.userAgent.indexOf("Safari") !== -1;
export const isIOS =
/iPad|iPhone/i.test(navigator.platform) ||
/iPad|iPhone/.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;
@ -43,7 +36,6 @@ 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;
@ -129,7 +121,6 @@ export const CLASSES = {
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@ -260,17 +251,13 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif",
} as const;
export const STRING_MIME_TYPES = {
export const MIME_TYPES = {
text: "text/plain",
html: "text/html",
json: "application/json",
// excalidraw data
excalidraw: "application/vnd.excalidraw+json",
excalidrawlib: "application/vnd.excalidrawlib+json",
} as const;
export const MIME_TYPES = {
...STRING_MIME_TYPES,
// image-encoded excalidraw data
"excalidraw.svg": "image/svg+xml",
"excalidraw.png": "image/png",
@ -347,17 +334,10 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
// breakpoints
// -----------------------------------------------------------------------------
// mobile: up to 699px
export const MQ_MAX_MOBILE = 599;
// tablets
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
// desktop/laptop
export const MQ_MIN_WIDTH_DESKTOP = 1440;
// md screen
export const MQ_MAX_WIDTH_PORTRAIT = 730;
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
// sidebar
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
// -----------------------------------------------------------------------------
@ -534,5 +514,3 @@ export enum UserIdleState {
* the start and end points)
*/
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;

View File

@ -21,8 +21,6 @@ import {
FONT_FAMILY,
getFontFamilyFallbacks,
isDarwin,
isAndroid,
isIOS,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
@ -1280,59 +1278,3 @@ export const reduceToCommonValue = <T, R = T>(
return commonValue;
};
export const isMobileOrTablet = (): boolean => {
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const uaData = (navigator as any).userAgentData as
| { mobile?: boolean; platform?: string }
| undefined;
// --- 1) chromium: prefer ua client hints -------------------------------
if (uaData) {
const plat = (uaData.platform || "").toLowerCase();
const isDesktopOS =
plat === "windows" ||
plat === "macos" ||
plat === "linux" ||
plat === "chrome os";
if (uaData.mobile === true) {
return true;
}
if (uaData.mobile === false && plat === "android") {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
if (isDesktopOS) {
return false;
}
}
// --- 2) ios (includes ipad) --------------------------------------------
if (isIOS) {
return true;
}
// --- 3) android legacy ua fallback -------------------------------------
if (isAndroid) {
const isAndroidPhone = /Mobile/i.test(ua);
const isAndroidTablet = !isAndroidPhone;
if (isAndroidPhone || isAndroidTablet) {
const looksTouchTablet =
matchMedia?.("(hover: none)").matches &&
matchMedia?.("(pointer: coarse)").matches;
return looksTouchTablet;
}
}
// --- 4) last resort desktop exclusion ----------------------------------
const looksDesktopPlatform =
/Win|Linux|CrOS|Mac/.test(platform) ||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
if (looksDesktopPlatform) {
return false;
}
return false;
};

View File

@ -164,14 +164,9 @@ export class Scene {
return this.frames;
}
constructor(
elements: ElementsMapOrArray | null = null,
options?: {
skipValidation?: true;
},
) {
constructor(elements: ElementsMapOrArray | null = null) {
if (elements) {
this.replaceAllElements(elements, options);
this.replaceAllElements(elements);
}
}
@ -268,19 +263,12 @@ export class Scene {
return didChange;
}
replaceAllElements(
nextElements: ElementsMapOrArray,
options?: {
skipValidation?: true;
},
) {
replaceAllElements(nextElements: ElementsMapOrArray) {
// 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[] = [];
if (!options?.skipValidation) {
validateIndicesThrottled(_nextElements);
}
validateIndicesThrottled(_nextElements);
this.elements = syncInvalidIndices(_nextElements);
this.elementsMap.clear();

View File

@ -1,8 +1,6 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getSelectedElementsByGroup } from "./groups";
import { getMaximumGroups } from "./groups";
import type { Scene } from "./Scene";
@ -18,12 +16,11 @@ export const alignElements = (
selectedElements: ExcalidrawElement[],
alignment: Alignment,
scene: Scene,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
const elementsMap = scene.getNonDeletedElementsMap();
const groups: ExcalidrawElement[][] = getMaximumGroups(
selectedElements,
scene.getNonDeletedElementsMap(),
appState,
elementsMap,
);
const selectionBoundingBox = getCommonBoundingBox(selectedElements);

View File

@ -1126,9 +1126,7 @@ export interface BoundingBox {
}
export const getCommonBoundingBox = (
elements:
| readonly ExcalidrawElement[]
| readonly NonDeleted<ExcalidrawElement>[],
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
): BoundingBox => {
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
return {

View File

@ -55,10 +55,10 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import { StoreSnapshot } from "./store";
import { Scene } from "./Scene";
import { StoreSnapshot } from "./store";
import type { BindableProp, BindingProp } from "./binding";
import type { ElementUpdate } from "./mutateElement";
@ -150,27 +150,13 @@ export class Delta<T> {
);
}
/**
* Merges two deltas into a new one.
*/
public static merge<T>(
delta1: Delta<T>,
delta2: Delta<T>,
delta3: Delta<T> = Delta.empty(),
) {
return Delta.create(
{ ...delta1.deleted, ...delta2.deleted, ...delta3.deleted },
{ ...delta1.inserted, ...delta2.inserted, ...delta3.inserted },
);
}
/**
* Merges deleted and inserted object partials.
*/
public static mergeObjects<T extends { [key: string]: unknown }>(
prev: T,
added: T,
removed: T = {} as T,
removed: T,
) {
const cloned = { ...prev };
@ -510,11 +496,6 @@ 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.
*/
@ -522,11 +503,7 @@ export interface DeltaContainer<T> {
}
export class AppStateDelta implements DeltaContainer<AppState> {
private constructor(public delta: Delta<ObservedAppState>) {}
public static create(delta: Delta<ObservedAppState>): AppStateDelta {
return new AppStateDelta(delta);
}
private constructor(public readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends ObservedAppState>(
prevAppState: T,
@ -557,124 +534,53 @@ 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: deletedSelectedElementIds = {},
selectedGroupIds: deletedSelectedGroupIds = {},
lockedMultiSelections: deletedLockedMultiSelections = {},
selectedElementIds: removedSelectedElementIds = {},
selectedGroupIds: removedSelectedGroupIds = {},
} = this.delta.deleted;
const {
selectedElementIds: insertedSelectedElementIds = {},
selectedGroupIds: insertedSelectedGroupIds = {},
lockedMultiSelections: insertedLockedMultiSelections = {},
selectedLinearElement: insertedSelectedLinearElement,
selectedElementIds: addedSelectedElementIds = {},
selectedGroupIds: addedSelectedGroupIds = {},
selectedLinearElementId,
editingLinearElementId,
...directlyApplicablePartial
} = this.delta.inserted;
const mergedSelectedElementIds = Delta.mergeObjects(
appState.selectedElementIds,
insertedSelectedElementIds,
deletedSelectedElementIds,
addedSelectedElementIds,
removedSelectedElementIds,
);
const mergedSelectedGroupIds = Delta.mergeObjects(
appState.selectedGroupIds,
insertedSelectedGroupIds,
deletedSelectedGroupIds,
);
const mergedLockedMultiSelections = Delta.mergeObjects(
appState.lockedMultiSelections,
insertedLockedMultiSelections,
deletedLockedMultiSelections,
addedSelectedGroupIds,
removedSelectedGroupIds,
);
const selectedLinearElement =
insertedSelectedLinearElement &&
nextElements.has(insertedSelectedLinearElement.elementId)
selectedLinearElementId && nextElements.has(selectedLinearElementId)
? new LinearElementEditor(
nextElements.get(
insertedSelectedLinearElement.elementId,
selectedLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
)
: null;
const editingLinearElement =
editingLinearElementId && nextElements.has(editingLinearElementId)
? new LinearElementEditor(
nextElements.get(
editingLinearElementId,
) as NonDeleted<ExcalidrawLinearElement>,
nextElements,
insertedSelectedLinearElement.isEditing,
)
: null;
@ -683,11 +589,14 @@ export class AppStateDelta implements DeltaContainer<AppState> {
...directlyApplicablePartial,
selectedElementIds: mergedSelectedElementIds,
selectedGroupIds: mergedSelectedGroupIds,
lockedMultiSelections: mergedLockedMultiSelections,
selectedLinearElement:
typeof insertedSelectedLinearElement !== "undefined"
? selectedLinearElement
: appState.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
};
const constainsVisibleChanges = this.filterInvisibleChanges(
@ -816,53 +725,52 @@ export class AppStateDelta implements DeltaContainer<AppState> {
}
break;
case "selectedLinearElement":
const nextLinearElement = nextAppState[key];
case "selectedLinearElementId":
case "editingLinearElementId":
const appStateKey = AppStateDelta.convertToAppStateKey(key);
const linearElement = nextAppState[appStateKey];
if (!nextLinearElement) {
if (!linearElement) {
// previously there was a linear element (assuming visible), now there is none
visibleDifferenceFlag.value = true;
} else {
const element = nextElements.get(nextLinearElement.elementId);
const element = nextElements.get(linearElement.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[key] = null;
nextAppState[appStateKey] = 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,
);
}
}
}
}
@ -870,6 +778,20 @@ 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,
@ -934,7 +856,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
editingGroupId,
selectedGroupIds,
selectedElementIds,
selectedLinearElement,
editingLinearElementId,
selectedLinearElementId,
croppingElementId,
lockedMultiSelections,
activeLockedId,
@ -988,6 +911,12 @@ 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.`);
@ -1016,13 +945,12 @@ 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;
};
/**
@ -1111,27 +1039,18 @@ 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",
@ -1140,7 +1059,6 @@ 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(
@ -1177,7 +1095,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const nextElement = nextElements.get(prevElement.id);
if (!nextElement) {
const deleted = { ...prevElement } as ElementPartial;
const deleted = { ...prevElement, isDeleted: false } as ElementPartial;
const inserted = {
isDeleted: true,
@ -1191,11 +1109,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps,
);
if (!prevElement.isDeleted) {
removed[prevElement.id] = delta;
} else {
updated[prevElement.id] = delta;
}
removed[prevElement.id] = delta;
}
}
@ -1211,6 +1125,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inserted = {
...nextElement,
isDeleted: false,
} as ElementPartial;
const delta = Delta.create(
@ -1219,12 +1134,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
ElementsDelta.stripIrrelevantProps,
);
// ignore updates which would "delete" already deleted element
if (!nextElement.isDeleted) {
added[nextElement.id] = delta;
} else {
updated[nextElement.id] = delta;
}
added[nextElement.id] = delta;
continue;
}
@ -1253,7 +1163,10 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue;
}
updated[nextElement.id] = delta;
// making sure there are at least some changes
if (!Delta.isEmpty(delta)) {
updated[nextElement.id] = delta;
}
}
}
@ -1268,8 +1181,8 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const inverseInternal = (deltas: Record<string, Delta<ElementPartial>>) => {
const inversedDeltas: Record<string, Delta<ElementPartial>> = {};
for (const [id, { inserted, deleted }] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create({ ...inserted }, { ...deleted });
for (const [id, delta] of Object.entries(deltas)) {
inversedDeltas[id] = Delta.create(delta.inserted, delta.deleted);
}
return inversedDeltas;
@ -1388,7 +1301,9 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
public applyTo(
elements: SceneElementsMap,
snapshot: StoreSnapshot["elements"] = StoreSnapshot.empty().elements,
options?: ApplyToOptions,
options: ApplyToOptions = {
excludedProperties: new Set(),
},
): [SceneElementsMap, boolean] {
let nextElements = new Map(elements) as SceneElementsMap;
let changedElements: Map<string, OrderedExcalidrawElement>;
@ -1396,28 +1311,22 @@ 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,
flags,
options,
flags,
);
const addedElements = applyDeltas(this.added);
const removedElements = applyDeltas(this.removed);
const updatedElements = applyDeltas(this.updated);
const affectedElements = this.resolveConflicts(
elements,
nextElements,
flags.applyDirection,
);
const affectedElements = this.resolveConflicts(elements, nextElements);
// TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues
changedElements = new Map([
@ -1441,15 +1350,22 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
try {
// 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
// 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)
nextElements = ElementsDelta.reorderElements(
nextElements,
changedElements,
flags,
);
ElementsDelta.redrawElements(nextElements, changedElements);
// we don't have an up-to-date scene, as we can be just in the middle of applying history entry
// we also don't have a scene on the server
// so we are creating a temp scene just to query and mutate elements
const tempScene = new Scene(nextElements);
ElementsDelta.redrawTextBoundingBoxes(tempScene, changedElements);
// Need ordered nextElements to avoid z-index binding issues
ElementsDelta.redrawBoundArrows(tempScene, changedElements);
} catch (e) {
console.error(
`Couldn't mutate elements after applying elements change`,
@ -1464,113 +1380,12 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
}
}
public squash(delta: ElementsDelta): this {
if (delta.isEmpty()) {
return this;
}
const { added, removed, updated } = delta;
const mergeBoundElements = (
prevDelta: Delta<ElementPartial>,
nextDelta: Delta<ElementPartial>,
) => {
const mergedDeletedBoundElements =
Delta.mergeArrays(
prevDelta.deleted.boundElements ?? [],
nextDelta.deleted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
const mergedInsertedBoundElements =
Delta.mergeArrays(
prevDelta.inserted.boundElements ?? [],
nextDelta.inserted.boundElements ?? [],
undefined,
(x) => x.id,
) ?? [];
if (
!mergedDeletedBoundElements.length &&
!mergedInsertedBoundElements.length
) {
return;
}
return Delta.create(
{
boundElements: mergedDeletedBoundElements,
},
{
boundElements: mergedInsertedBoundElements,
},
);
};
for (const [id, nextDelta] of Object.entries(added)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.added[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.removed[id];
delete this.updated[id];
this.added[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(removed)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.removed[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
delete this.added[id];
delete this.updated[id];
this.removed[id] = Delta.merge(prevDelta, nextDelta, mergedDelta);
}
}
for (const [id, nextDelta] of Object.entries(updated)) {
const prevDelta = this.added[id] ?? this.removed[id] ?? this.updated[id];
if (!prevDelta) {
this.updated[id] = nextDelta;
} else {
const mergedDelta = mergeBoundElements(prevDelta, nextDelta);
const updatedDelta = Delta.merge(prevDelta, nextDelta, mergedDelta);
if (prevDelta === this.added[id]) {
this.added[id] = updatedDelta;
} else if (prevDelta === this.removed[id]) {
this.removed[id] = updatedDelta;
} else {
this.updated[id] = updatedDelta;
}
}
}
if (isTestEnv() || isDevEnv()) {
ElementsDelta.validate(this, "added", ElementsDelta.satisfiesAddition);
ElementsDelta.validate(this, "removed", ElementsDelta.satisfiesRemoval);
ElementsDelta.validate(this, "updated", ElementsDelta.satisfiesUpdate);
}
return this;
}
private static createApplier =
(
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
snapshot: StoreSnapshot["elements"],
options: ApplyToOptions,
flags: ApplyToFlags,
options?: ApplyToOptions,
) =>
(deltas: Record<string, Delta<ElementPartial>>) => {
const getElement = ElementsDelta.createGetter(
@ -1583,26 +1398,15 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
const element = getElement(id, delta.inserted);
if (element) {
const nextElement = ElementsDelta.applyDelta(
const newElement = ElementsDelta.applyDelta(
element,
delta,
flags,
options,
flags,
);
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";
}
}
nextElements.set(newElement.id, newElement);
acc.set(newElement.id, newElement);
}
return acc;
@ -1647,8 +1451,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> = {};
@ -1662,7 +1466,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
continue;
}
if (options?.excludedProperties?.has(key)) {
if (options.excludedProperties.has(key)) {
continue;
}
@ -1702,7 +1506,7 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
delta.deleted.index !== delta.inserted.index;
}
return newElementWith(element, directlyApplicablePartial, true);
return newElementWith(element, directlyApplicablePartial);
}
/**
@ -1742,7 +1546,6 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
private resolveConflicts(
prevElements: SceneElementsMap,
nextElements: SceneElementsMap,
applyDirection: "forward" | "backward" = "forward",
) {
const nextAffectedElements = new Map<string, OrderedExcalidrawElement>();
const updater = (
@ -1754,36 +1557,21 @@ 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 (prevElement === nextElement) {
if (prevElements.get(element.id) === 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,
{
...elementUpdates,
version: nextVersion,
},
true,
updates as ElementUpdate<OrderedExcalidrawElement>,
);
} else {
affectedElement = mutateElement(nextElement, nextElements, {
...elementUpdates,
// don't modify the version further, if it's already different
version:
prevElement?.version !== nextElement.version
? nextElement.version
: nextVersion,
});
affectedElement = mutateElement(
nextElement,
nextElements,
updates as ElementUpdate<OrderedExcalidrawElement>,
);
}
nextAffectedElements.set(affectedElement.id, affectedElement);
@ -1821,12 +1609,25 @@ export class ElementsDelta implements DeltaContainer<SceneElementsMap> {
Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)),
);
// 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),
// 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,
);
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;
}
@ -1888,31 +1689,6 @@ 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>,
@ -1967,7 +1743,6 @@ 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,
});

View File

@ -1,9 +1,7 @@
import type { AppState } from "@excalidraw/excalidraw/types";
import { getCommonBoundingBox } from "./bounds";
import { newElementWith } from "./mutateElement";
import { getSelectedElementsByGroup } from "./groups";
import { getMaximumGroups } from "./groups";
import type { ElementsMap, ExcalidrawElement } from "./types";
@ -16,7 +14,6 @@ export const distributeElements = (
selectedElements: ExcalidrawElement[],
elementsMap: ElementsMap,
distribution: Distribution,
appState: Readonly<AppState>,
): ExcalidrawElement[] => {
const [start, mid, end, extent] =
distribution.axis === "x"
@ -24,11 +21,7 @@ export const distributeElements = (
: (["minY", "midY", "maxY", "height"] as const);
const bounds = getCommonBoundingBox(selectedElements);
const groups = getSelectedElementsByGroup(
selectedElements,
elementsMap,
appState,
)
const groups = getMaximumGroups(selectedElements, elementsMap)
.map((group) => [group, getCommonBoundingBox(group)] as const)
.sort((a, b) => a[1][mid] - b[1][mid]);

View File

@ -359,12 +359,6 @@ 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
@ -712,7 +706,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) =>
@ -747,15 +741,8 @@ const handleEndpointDrag = (
// Calculate the moving second point connection and add the start point
{
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 secondPoint = globalUpdatedPoints[startIsSpecial ? 2 : 1];
const thirdPoint = globalUpdatedPoints[startIsSpecial ? 3 : 2];
const startIsHorizontal = headingIsHorizontal(startHeading);
const secondIsHorizontal = headingIsHorizontal(
vectorToHeading(vectorFromPoint(secondPoint, thirdPoint)),
@ -814,19 +801,10 @@ const handleEndpointDrag = (
// Calculate the moving second to last point connection
{
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 secondToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 3 : 2)];
const thirdToLastPoint =
globalUpdatedPoints[globalUpdatedPoints.length - (endIsSpecial ? 4 : 3)];
const endIsHorizontal = headingIsHorizontal(endHeading);
const secondIsHorizontal = headingForPointIsHorizontal(
thirdToLastPoint,
@ -2093,7 +2071,16 @@ const normalizeArrowElementUpdate = (
nextFixedSegments: readonly FixedSegment[] | null,
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"],
): ElementUpdate<ExcalidrawElbowArrowElement> => {
): {
points: LocalPoint[];
x: number;
y: number;
width: number;
height: number;
fixedSegments: readonly FixedSegment[] | null;
startIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
endIsSpecial?: ExcalidrawElbowArrowElement["startIsSpecial"];
} => {
const offsetX = global[0][0];
const offsetY = global[0][1];
let points = global.map((p) =>

View File

@ -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_-]+)/;
/^(?: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]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@ -56,35 +56,6 @@ 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",
@ -142,8 +113,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) {
const startTime = parseYouTubeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``;
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
const isPortrait = link.includes("shorts");
type = "video";
switch (ytLink[1]) {

View File

@ -7,8 +7,6 @@ import type { Mutable } from "@excalidraw/common/utility-types";
import { getBoundTextElement } from "./textElement";
import { isBoundToContainer } from "./typeChecks";
import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
import type {
@ -404,78 +402,3 @@ 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()));
};

View File

@ -97,7 +97,6 @@ export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
export * from "./positionElementsOnGrid";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";

View File

@ -149,12 +149,10 @@ 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";
@ -189,7 +187,6 @@ export class LinearElementEditor {
this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
this.customLineAngle = null;
this.isEditing = isEditing;
}
// ---------------------------------------------------------------------------
@ -197,7 +194,6 @@ 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)
@ -219,14 +215,11 @@ export class LinearElementEditor {
setState: React.Component<any, AppState>["setState"],
elementsMap: NonDeletedSceneElementsMap,
) {
if (
!appState.selectedLinearElement?.isEditing ||
!appState.selectionElement
) {
if (!appState.editingLinearElement || !appState.selectionElement) {
return false;
}
const { selectedLinearElement } = appState;
const { selectedPointsIndices, elementId } = selectedLinearElement;
const { editingLinearElement } = appState;
const { selectedPointsIndices, elementId } = editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
@ -267,8 +260,8 @@ export class LinearElementEditor {
});
setState({
selectedLinearElement: {
...selectedLinearElement,
editingLinearElement: {
...editingLinearElement,
selectedPointsIndices: nextSelectedPoints.length
? nextSelectedPoints
: null,
@ -486,6 +479,9 @@ export class LinearElementEditor {
return {
...app.state,
editingLinearElement: app.state.editingLinearElement
? newLinearElementEditor
: null,
selectedLinearElement: newLinearElementEditor,
suggestedBindings,
};
@ -622,7 +618,7 @@ export class LinearElementEditor {
// Since its not needed outside editor unless 2 pointer lines or bound text
if (
!isElbowArrow(element) &&
!appState.selectedLinearElement?.isEditing &&
!appState.editingLinearElement &&
element.points.length > 2 &&
!boundText
) {
@ -688,7 +684,7 @@ export class LinearElementEditor {
);
if (
points.length >= 3 &&
!appState.selectedLinearElement?.isEditing &&
!appState.editingLinearElement &&
!isElbowArrow(element)
) {
return null;
@ -885,7 +881,7 @@ export class LinearElementEditor {
segmentMidpoint,
elementsMap,
);
} else if (event.altKey && appState.selectedLinearElement?.isEditing) {
} else if (event.altKey && appState.editingLinearElement) {
if (linearElementEditor.lastUncommittedPoint == null) {
scene.mutateElement(element, {
points: [
@ -1027,14 +1023,14 @@ export class LinearElementEditor {
app: AppClassProperties,
): LinearElementEditor | null {
const appState = app.state;
if (!appState.selectedLinearElement?.isEditing) {
if (!appState.editingLinearElement) {
return null;
}
const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return appState.selectedLinearElement;
return appState.editingLinearElement;
}
const { points } = element;
@ -1044,12 +1040,10 @@ export class LinearElementEditor {
if (lastPoint === lastUncommittedPoint) {
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
}
return appState.selectedLinearElement?.lastUncommittedPoint
? {
...appState.selectedLinearElement,
lastUncommittedPoint: null,
}
: appState.selectedLinearElement;
return {
...appState.editingLinearElement,
lastUncommittedPoint: null,
};
}
let newPoint: LocalPoint;
@ -1073,8 +1067,8 @@ export class LinearElementEditor {
newPoint = LinearElementEditor.createPointAt(
element,
elementsMap,
scenePointerX - appState.selectedLinearElement.pointerOffset.x,
scenePointerY - appState.selectedLinearElement.pointerOffset.y,
scenePointerX - appState.editingLinearElement.pointerOffset.x,
scenePointerY - appState.editingLinearElement.pointerOffset.y,
event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
? null
: app.getEffectiveGridSize(),
@ -1098,7 +1092,7 @@ export class LinearElementEditor {
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
}
return {
...appState.selectedLinearElement,
...appState.editingLinearElement,
lastUncommittedPoint: element.points[element.points.length - 1],
};
}
@ -1257,12 +1251,12 @@ export class LinearElementEditor {
// ---------------------------------------------------------------------------
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
invariant(
appState.selectedLinearElement?.isEditing,
appState.editingLinearElement,
"Not currently editing a linear element",
);
const elementsMap = scene.getNonDeletedElementsMap();
const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
const { selectedPointsIndices, elementId } = appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
invariant(
@ -1324,8 +1318,8 @@ export class LinearElementEditor {
return {
...appState,
selectedLinearElement: {
...appState.selectedLinearElement,
editingLinearElement: {
...appState.editingLinearElement,
selectedPointsIndices: nextSelectedIndices,
},
};
@ -1337,9 +1331,8 @@ export class LinearElementEditor {
pointIndices: readonly number[],
) {
const isUncommittedPoint =
app.state.selectedLinearElement?.isEditing &&
app.state.selectedLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
app.state.editingLinearElement?.lastUncommittedPoint ===
element.points[element.points.length - 1];
const nextPoints = element.points.filter((_, idx) => {
return !pointIndices.includes(idx);
@ -1512,7 +1505,7 @@ export class LinearElementEditor {
pointFrom(pointerCoords.x, pointerCoords.y),
);
if (
!appState.selectedLinearElement?.isEditing &&
!appState.editingLinearElement &&
dist < DRAGGING_THRESHOLD / appState.zoom.value
) {
return false;

View File

@ -1,112 +0,0 @@
import { getCommonBounds } from "./bounds";
import { type ElementUpdate, newElementWith } from "./mutateElement";
import type { ExcalidrawElement } from "./types";
// TODO rewrite (mostly vibe-coded)
export const positionElementsOnGrid = <TElement extends ExcalidrawElement>(
elements: TElement[] | TElement[][],
centerX: number,
centerY: number,
padding = 50,
): TElement[] => {
// Ensure there are elements to position
if (!elements || elements.length === 0) {
return [];
}
const res: TElement[] = [];
// Normalize input to work with atomic units (groups of elements)
// If elements is a flat array, treat each element as its own atomic unit
const atomicUnits: TElement[][] = Array.isArray(elements[0])
? (elements as TElement[][])
: (elements as TElement[]).map((element) => [element]);
// Determine the number of columns for atomic units
// A common approach for a "grid-like" layout without specific column constraints
// is to aim for a roughly square arrangement.
const numUnits = atomicUnits.length;
const numColumns = Math.max(1, Math.ceil(Math.sqrt(numUnits)));
// Group atomic units into rows based on the calculated number of columns
const rows: TElement[][][] = [];
for (let i = 0; i < numUnits; i += numColumns) {
rows.push(atomicUnits.slice(i, i + numColumns));
}
// Calculate properties for each row (total width, max height)
// and the total actual height of all row content.
let totalGridActualHeight = 0; // Sum of max heights of rows, without inter-row padding
const rowProperties = rows.map((rowUnits) => {
let rowWidth = 0;
let maxUnitHeightInRow = 0;
const unitBounds = rowUnits.map((unit) => {
const [minX, minY, maxX, maxY] = getCommonBounds(unit);
return {
elements: unit,
bounds: [minX, minY, maxX, maxY] as const,
width: maxX - minX,
height: maxY - minY,
};
});
unitBounds.forEach((unitBound, index) => {
rowWidth += unitBound.width;
// Add padding between units in the same row, but not after the last one
if (index < unitBounds.length - 1) {
rowWidth += padding;
}
if (unitBound.height > maxUnitHeightInRow) {
maxUnitHeightInRow = unitBound.height;
}
});
totalGridActualHeight += maxUnitHeightInRow;
return {
unitBounds,
width: rowWidth,
maxHeight: maxUnitHeightInRow,
};
});
// Calculate the total height of the grid including padding between rows
const totalGridHeightWithPadding =
totalGridActualHeight + Math.max(0, rows.length - 1) * padding;
// Calculate the starting Y position to center the entire grid vertically around centerY
let currentY = centerY - totalGridHeightWithPadding / 2;
// Position atomic units row by row
rowProperties.forEach((rowProp) => {
const { unitBounds, width: rowWidth, maxHeight: rowMaxHeight } = rowProp;
// Calculate the starting X for the current row to center it horizontally around centerX
let currentX = centerX - rowWidth / 2;
unitBounds.forEach((unitBound) => {
// Calculate the offset needed to position this atomic unit
const [originalMinX, originalMinY] = unitBound.bounds;
const offsetX = currentX - originalMinX;
const offsetY = currentY - originalMinY;
// Apply the offset to all elements in this atomic unit
unitBound.elements.forEach((element) => {
res.push(
newElementWith(element, {
x: element.x + offsetX,
y: element.y + offsetY,
} as ElementUpdate<TElement>),
);
});
// Move X for the next unit in the row
currentX += unitBound.width + padding;
});
// Move Y to the starting position for the next row
// This accounts for the tallest unit in the current row and the inter-row padding
currentY += rowMaxHeight + padding;
});
return res;
};

View File

@ -1,14 +1,7 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import {
type GlobalPoint,
isRightAngleRads,
lineSegment,
pointFrom,
pointRotateRads,
type Radians,
} from "@excalidraw/math";
import { isRightAngleRads } from "@excalidraw/math";
import {
BOUND_TEXT_PADDING,
@ -21,7 +14,6 @@ import {
getFontString,
isRTL,
getVerticalOffset,
invariant,
} from "@excalidraw/common";
import type {
@ -40,7 +32,7 @@ import type {
InteractiveCanvasRenderConfig,
} from "@excalidraw/excalidraw/scene/types";
import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
import { getElementAbsoluteCoords } from "./bounds";
import { getUncroppedImageElement } from "./cropElement";
import { LinearElementEditor } from "./linearElementEditor";
import {
@ -114,11 +106,6 @@ 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;
}
@ -1047,66 +1034,6 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
);
invariant(points.length >= 2, "Freepath outline must have at least 2 points");
return points.slice(2).reduce(
(acc, curr) => {
acc.push(
lineSegment<GlobalPoint>(
acc[acc.length - 1][1],
pointRotateRads(
pointFrom<GlobalPoint>(curr[0] + element.x, curr[1] + element.y),
center,
element.angle,
),
),
);
return acc;
},
[
lineSegment<GlobalPoint>(
pointRotateRads(
pointFrom<GlobalPoint>(
points[0][0] + element.x,
points[0][1] + element.y,
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
points[1][0] + element.x,
points[1][1] + element.y,
),
center,
element.angle,
),
),
],
);
}
export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
// If input points are empty (should they ever be?) return a dot
const inputPoints = element.simulatePressure
? element.points
@ -1125,7 +1052,7 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return getStroke(inputPoints as number[][], options) as [number, number][];
return getSvgPathFromStroke(getStroke(inputPoints as number[][], options));
}
function med(A: number[], B: number[]) {

View File

@ -35,7 +35,6 @@ import {
getContainerElement,
handleBindTextResize,
getBoundTextMaxWidth,
computeBoundTextPosition,
} from "./textElement";
import {
getMinTextElementWidth,
@ -226,16 +225,7 @@ const rotateSingleElement = (
scene.getElement<ExcalidrawTextElementWithContainer>(boundTextElementId);
if (textElement && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
textElement,
scene.getNonDeletedElementsMap(),
);
scene.mutateElement(textElement, {
angle,
x,
y,
});
scene.mutateElement(textElement, { angle });
}
}
};
@ -426,15 +416,9 @@ const rotateMultipleElements = (
const boundText = getBoundTextElement(element, elementsMap);
if (boundText && !isArrowElement(element)) {
const { x, y } = computeBoundTextPosition(
element,
boundText,
elementsMap,
);
scene.mutateElement(boundText, {
x,
y,
x: boundText.x + (rotatedCX - cx),
y: boundText.y + (rotatedCY - cy),
angle: normalizeRadians((centerAngle + origAngle) as Radians),
});
}

View File

@ -27,8 +27,6 @@ import {
isImageElement,
} from "./index";
import type { ApplyToOptions } from "./delta";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
@ -76,9 +74,8 @@ type MicroActionsQueue = (() => void)[];
* Store which captures the observed changes and emits them as `StoreIncrement` events.
*/
export class Store {
// for internal use by history
// internally used by history
public readonly onDurableIncrementEmitter = new Emitter<[DurableIncrement]>();
// for public use as part of onIncrement API
public readonly onStoreIncrementEmitter = new Emitter<
[DurableIncrement | EphemeralIncrement]
>();
@ -240,6 +237,7 @@ export class Store {
if (!storeDelta.isEmpty()) {
const increment = new DurableIncrement(storeChange, storeDelta);
// Notify listeners with the increment
this.onDurableIncrementEmitter.trigger(increment);
this.onStoreIncrementEmitter.trigger(increment);
}
@ -552,26 +550,10 @@ 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, 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;
return new this(id, elements, AppStateDelta.empty());
}
/**
@ -588,13 +570,9 @@ export class StoreDelta {
delta: StoreDelta,
elements: SceneElementsMap,
appState: AppState,
options?: ApplyToOptions,
): [SceneElementsMap, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
elements,
StoreSnapshot.empty().elements,
options,
);
const [nextElements, elementsContainVisibleChange] =
delta.elements.applyTo(elements);
const [nextAppState, appStateContainsVisibleChange] =
delta.appState.applyTo(appState, nextElements);
@ -627,10 +605,6 @@ export class StoreDelta {
);
}
public static empty() {
return StoreDelta.create(ElementsDelta.empty(), AppStateDelta.empty());
}
public isEmpty() {
return this.elements.isEmpty() && this.appState.isEmpty();
}
@ -996,7 +970,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
viewBackgroundColor: COLOR_PALETTE.white,
selectedElementIds: {},
selectedGroupIds: {},
selectedLinearElement: null,
editingLinearElementId: null,
selectedLinearElementId: null,
croppingElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
@ -1015,12 +990,14 @@ export const getObservedAppState = (
croppingElementId: appState.croppingElementId,
activeLockedId: appState.activeLockedId,
lockedMultiSelections: appState.lockedMultiSelections,
selectedLinearElement: appState.selectedLinearElement
? {
elementId: appState.selectedLinearElement.elementId,
isEditing: !!appState.selectedLinearElement.isEditing,
}
: null,
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,
};
Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

View File

@ -10,12 +10,12 @@ import {
invariant,
} from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { ExtractSetType } from "@excalidraw/common/utility-types";
import type { Radians } from "@excalidraw/math";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
@ -254,26 +254,6 @@ 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 };
};

View File

@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
elements: readonly NonDeletedExcalidrawElement[],
appState: InteractiveCanvasAppState,
) => {
if (appState.selectedLinearElement?.isEditing) {
if (appState.editingLinearElement) {
return false;
}
if (elements.length > 1) {

View File

@ -589,424 +589,4 @@ 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);
});
});

View File

@ -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.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.editingLinearElement).not.toBe(null);
mouse.down(0, 0);
await new Promise((r) => setTimeout(r, 100));
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement).toBe(null);
expect(API.getSelectedElement().type).toBe("rectangle");
mouse.up();
expect(API.getSelectedElement().type).toBe("rectangle");

View File

@ -1,345 +1,13 @@
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, 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" },
]);
});
});
});
import { AppStateDelta } from "../src/delta";
describe("AppStateDelta", () => {
describe("ensure stable delta properties order", () => {
it("should maintain stable order for root properties", () => {
const name = "untitled scene";
const selectedLinearElement = {
elementId: "id1" as LinearElementEditor["elementId"],
isEditing: false,
};
const selectedLinearElementId = "id1" as LinearElementEditor["elementId"];
const commonAppState = {
viewBackgroundColor: "#ffffff",
@ -348,7 +16,6 @@ describe("AppStateDelta", () => {
editingGroupId: null,
croppingElementId: null,
editingLinearElementId: null,
selectedLinearElementIsEditing: null,
lockedMultiSelections: {},
activeLockedId: null,
};
@ -356,23 +23,23 @@ describe("AppStateDelta", () => {
const prevAppState1: ObservedAppState = {
...commonAppState,
name: "",
selectedLinearElement: null,
selectedLinearElementId: null,
};
const nextAppState1: ObservedAppState = {
...commonAppState,
name,
selectedLinearElement,
selectedLinearElementId,
};
const prevAppState2: ObservedAppState = {
selectedLinearElement: null,
selectedLinearElementId: null,
name: "",
...commonAppState,
};
const nextAppState2: ObservedAppState = {
selectedLinearElement,
selectedLinearElementId,
name,
...commonAppState,
};
@ -390,7 +57,8 @@ describe("AppStateDelta", () => {
selectedGroupIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElement: null,
selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@ -436,7 +104,8 @@ describe("AppStateDelta", () => {
selectedElementIds: {},
editingGroupId: null,
croppingElementId: null,
selectedLinearElement: null,
selectedLinearElementId: null,
editingLinearElementId: null,
activeLockedId: null,
lockedMultiSelections: {},
};
@ -477,97 +146,4 @@ 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 },
},
),
);
});
});
});

View File

@ -1,128 +0,0 @@
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);
});
});

View File

@ -1,153 +0,0 @@
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");
}
});
});

View File

@ -136,8 +136,7 @@ describe("Test Linear Elements", () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
};
const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@ -254,82 +253,75 @@ describe("Test Linear Elements", () => {
});
fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via enter (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.keyPress(KEYS.ENTER);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.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.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor via ctrl+enter (arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.clickAt(midpoint[0], midpoint[1]);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.ENTER);
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on ctrl+dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
Keyboard.withModifierKeys({ ctrl: true }, () => {
mouse.doubleClick();
});
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should enter line editor on dblclick (line)", () => {
createTwoPointerLinearElement("line");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
});
it("should not enter line editor on dblclick (arrow)", async () => {
createTwoPointerLinearElement("arrow");
expect(h.state.selectedLinearElement?.isEditing).toBe(false);
expect(h.state.editingLinearElement?.elementId).toBeUndefined();
mouse.doubleClick();
expect(h.state.selectedLinearElement).toBe(null);
expect(h.state.editingLinearElement).toEqual(null);
await getTextEditor();
});
@ -338,12 +330,10 @@ describe("Test Linear Elements", () => {
const arrow = h.elements[0] as ExcalidrawLinearElement;
enterLineEditingMode(arrow);
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
mouse.doubleClick();
expect(h.state.selectedLinearElement?.isEditing).toBe(true);
expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
expect(h.elements.length).toEqual(1);
expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
@ -377,7 +367,7 @@ describe("Test Linear Elements", () => {
// drag line from midpoint
drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@ -479,7 +469,7 @@ describe("Test Linear Elements", () => {
drag(startPoint, endPoint);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@ -547,7 +537,7 @@ describe("Test Linear Elements", () => {
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
`16`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@ -598,7 +588,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@ -639,7 +629,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
@ -687,7 +677,7 @@ describe("Test Linear Elements", () => {
deletePoint(points[2]);
expect(line.points.length).toEqual(3);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`17`,
`18`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
@ -745,7 +735,7 @@ describe("Test Linear Elements", () => {
),
);
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`14`,
`16`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
expect(line.points.length).toEqual(5);
@ -843,7 +833,7 @@ describe("Test Linear Elements", () => {
drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
`11`,
`12`,
);
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);

View File

@ -1,14 +1,13 @@
import { getLineHeight } from "@excalidraw/common";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { FONT_FAMILY, TEXT_ALIGN, VERTICAL_ALIGN } from "@excalidraw/common";
import { FONT_FAMILY } from "@excalidraw/common";
import {
computeContainerDimensionForBoundText,
getContainerCoords,
getBoundTextMaxWidth,
getBoundTextMaxHeight,
computeBoundTextPosition,
} from "../src/textElement";
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
@ -208,172 +207,3 @@ 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);
});
});
});

View File

@ -10,8 +10,6 @@ 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";
@ -40,11 +38,7 @@ export const alignActionsPredicate = (
) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 1 &&
selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
@ -58,12 +52,7 @@ const alignSelectedElements = (
) => {
const selectedElements = app.scene.getSelectedElements(appState);
const updatedElements = alignElements(
selectedElements,
alignment,
app.scene,
appState,
);
const updatedElements = alignElements(selectedElements, alignment, app.scene);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
PanelComponent: ({ elements, appState, updateData, appProps }) => {
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
return (
<ColorPicker
@ -83,7 +83,6 @@ export const actionChangeViewBackgroundColor = register({
elements={elements}
appState={appState}
updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/>
);
},
@ -122,7 +121,7 @@ export const actionClearCanvas = register({
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: app.defaultSelectionTool }
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -495,13 +494,13 @@ export const actionToggleEraserTool = register({
name: "toggleEraserTool",
label: "toolBar.eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState, _, app) => {
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
if (isEraserActive(appState)) {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.defaultSelectionTool,
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
@ -531,9 +530,6 @@ export const actionToggleLassoTool = register({
label: "toolBar.lasso",
icon: LassoIcon,
trackEvent: { category: "toolbar" },
predicate: (elements, appState, props, app) => {
return app.defaultSelectionTool !== "lasso";
},
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];

View File

@ -205,19 +205,16 @@ export const actionDeleteSelected = register({
icon: TrashIcon,
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => {
if (appState.selectedLinearElement?.isEditing) {
if (appState.editingLinearElement) {
const {
elementId,
selectedPointsIndices,
startBindingElement,
endBindingElement,
} = appState.selectedLinearElement;
} = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const linearElement = LinearElementEditor.getElement(
elementId,
elementsMap,
);
if (!linearElement) {
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (!element) {
return false;
}
// case: no point selected → do nothing, as deleting the whole element
@ -228,10 +225,10 @@ export const actionDeleteSelected = register({
return false;
}
// case: deleting all points
if (selectedPointsIndices.length >= linearElement.points.length) {
// case: deleting last remaining point
if (element.points.length < 2) {
const nextElements = elements.map((el) => {
if (el.id === linearElement.id) {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
@ -242,7 +239,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
selectedLinearElement: null,
editingLinearElement: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -255,24 +252,20 @@ export const actionDeleteSelected = register({
? null
: startBindingElement,
endBindingElement: selectedPointsIndices?.includes(
linearElement.points.length - 1,
element.points.length - 1,
)
? null
: endBindingElement,
};
LinearElementEditor.deletePoints(
linearElement,
app,
selectedPointsIndices,
);
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
return {
elements,
appState: {
...appState,
selectedLinearElement: {
...appState.selectedLinearElement,
editingLinearElement: {
...appState.editingLinearElement,
...binding,
selectedPointsIndices:
selectedPointsIndices?.[0] > 0
@ -298,9 +291,7 @@ export const actionDeleteSelected = register({
elements: nextElements,
appState: {
...nextAppState,
activeTool: updateActiveTool(appState, {
type: app.defaultSelectionTool,
}),
activeTool: updateActiveTool(appState, { type: "selection" }),
multiElement: null,
activeEmbeddable: null,
selectedLinearElement: null,

View File

@ -10,8 +10,6 @@ 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";
@ -33,11 +31,7 @@ import type { AppClassProperties, AppState } from "../types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
getSelectedElementsByGroup(
selectedElements,
app.scene.getNonDeletedElementsMap(),
appState as Readonly<AppState>,
).length > 2 &&
selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly
!selectedElements.some((el) => isFrameLikeElement(el))
);
@ -55,7 +49,6 @@ const distributeSelectedElements = (
selectedElements,
app.scene.getNonDeletedElementsMap(),
distribution,
appState,
);
const updatedElementsMap = arrayToMap(updatedElements);

View File

@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
}
// duplicate selected point(s) if editing a line
if (appState.selectedLinearElement?.isEditing) {
if (appState.editingLinearElement) {
// TODO: Invariants should be checked here instead of duplicateSelectedPoints()
try {
const newAppState = LinearElementEditor.duplicateSelectedPoints(

View File

@ -5,11 +5,7 @@ import {
bindOrUnbindLinearElement,
isBindingEnabled,
} from "@excalidraw/element/binding";
import {
isValidPolygon,
LinearElementEditor,
newElementWith,
} from "@excalidraw/element";
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
import {
isBindingElement,
@ -82,14 +78,7 @@ 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.map((el) => {
if (el.id === element.id) {
return newElementWith(el, {
isDeleted: true,
});
}
return el;
});
newElements = newElements.filter((el) => el.id !== element!.id);
}
return {
elements: newElements,
@ -105,9 +94,9 @@ export const actionFinalize = register({
}
}
if (appState.selectedLinearElement?.isEditing) {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.selectedLinearElement;
appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
if (element) {
@ -128,21 +117,12 @@ export const actionFinalize = register({
return {
elements:
element.points.length < 2 || isInvisiblySmallElement(element)
? elements.map((el) => {
if (el.id === element.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
})
? elements.filter((el) => el.id !== element.id)
: undefined,
appState: {
...appState,
cursorButton: "up",
selectedLinearElement: new LinearElementEditor(
element,
arrayToMap(elementsMap),
false, // exit editing mode
),
editingLinearElement: null,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -174,7 +154,11 @@ export const actionFinalize = register({
if (element) {
// pen and mouse have hover
if (appState.multiElement && element.type !== "freedraw") {
if (
appState.multiElement &&
element.type !== "freedraw" &&
appState.lastPointerDownWith !== "touch"
) {
const { points, lastCommittedPoint } = element;
if (
!lastCommittedPoint ||
@ -188,12 +172,7 @@ 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.map((el) => {
if (el.id === element?.id) {
return newElementWith(el, { isDeleted: true });
}
return el;
});
newElements = newElements.filter((el) => el.id !== element!.id);
}
if (isLinearElement(element) || isFreeDrawElement(element)) {
@ -261,13 +240,13 @@ export const actionFinalize = register({
if (appState.activeTool.type === "eraser") {
activeTool = updateActiveTool(appState, {
...(appState.activeTool.lastActiveTool || {
type: app.defaultSelectionTool,
type: "selection",
}),
lastActiveToolBeforeEraser: null,
});
} else {
activeTool = updateActiveTool(appState, {
type: app.defaultSelectionTool,
type: "selection",
});
}
@ -310,7 +289,7 @@ export const actionFinalize = register({
},
keyTest: (event, appState) =>
(event.key === KEYS.ESCAPE &&
(appState.selectedLinearElement?.isEditing ||
(appState.editingLinearElement !== null ||
(!appState.newElement && appState.multiElement === null))) ||
((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
appState.multiElement !== null),

View File

@ -1,9 +1,10 @@
import { LinearElementEditor } from "@excalidraw/element";
import {
isElbowArrow,
isLinearElement,
isLineElement,
} from "@excalidraw/element";
import { arrayToMap, invariant } from "@excalidraw/common";
import { arrayToMap } from "@excalidraw/common";
import {
toggleLinePolygonState,
@ -45,7 +46,7 @@ export const actionToggleLinearEditor = register({
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.selectedLinearElement?.isEditing &&
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
@ -60,25 +61,14 @@ export const actionToggleLinearEditor = register({
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
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,
};
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement, arrayToMap(elements));
return {
appState: {
...appState,
selectedLinearElement,
editingLinearElement,
},
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
@ -88,10 +78,6 @@ export const actionToggleLinearEditor = register({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement;
if (!selectedElement) {
return null;
}
const label = t(
selectedElement.type === "arrow"
? "labels.lineEditor.editArrow"

View File

@ -137,11 +137,6 @@ import {
isSomeElementSelected,
} from "../scene";
import {
withCaretPositionPreservation,
restoreCaretPosition,
} from "../hooks/useTextEditorFocus";
import { register } from "./register";
import type { AppClassProperties, AppState, Primitive } from "../types";
@ -326,11 +321,9 @@ export const actionChangeStrokeColor = register({
: CaptureUpdateAction.EVENTUALLY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<>
{appState.stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
)}
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
@ -348,7 +341,6 @@ export const actionChangeStrokeColor = register({
elements={elements}
appState={appState}
updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/>
</>
),
@ -406,11 +398,9 @@ export const actionChangeBackgroundColor = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<>
{appState.stylesPanelMode === "full" && (
<h3 aria-hidden="true">{t("labels.background")}</h3>
)}
<h3 aria-hidden="true">{t("labels.background")}</h3>
<ColorPicker
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
@ -428,7 +418,6 @@ export const actionChangeBackgroundColor = register({
elements={elements}
appState={appState}
updateData={updateData}
compactMode={appState.stylesPanelMode === "compact"}
/>
</>
),
@ -529,11 +518,9 @@ export const actionChangeStrokeWidth = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.strokeWidth")}</legend>
)}
<legend>{t("labels.strokeWidth")}</legend>
<div className="buttonList">
<RadioSelection
group="stroke-width"
@ -588,11 +575,9 @@ export const actionChangeSloppiness = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.sloppiness")}</legend>
)}
<legend>{t("labels.sloppiness")}</legend>
<div className="buttonList">
<RadioSelection
group="sloppiness"
@ -643,11 +628,9 @@ export const actionChangeStrokeStyle = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.strokeStyle")}</legend>
)}
<legend>{t("labels.strokeStyle")}</legend>
<div className="buttonList">
<RadioSelection
group="strokeStyle"
@ -714,7 +697,7 @@ export const actionChangeFontSize = register({
perform: (elements, appState, value, app) => {
return changeFontSize(elements, appState, app, () => value, value);
},
PanelComponent: ({ elements, appState, updateData, app, data }) => (
PanelComponent: ({ elements, appState, updateData, app }) => (
<fieldset>
<legend>{t("labels.fontSize")}</legend>
<div className="buttonList">
@ -773,14 +756,7 @@ export const actionChangeFontSize = register({
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
@ -1040,7 +1016,7 @@ export const actionChangeFontFamily = register({
return result;
},
PanelComponent: ({ elements, appState, app, updateData, data }) => {
PanelComponent: ({ elements, appState, app, updateData }) => {
const cachedElementsRef = useRef<ElementsMap>(new Map());
const prevSelectedFontFamilyRef = useRef<number | null>(null);
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
@ -1118,28 +1094,20 @@ export const actionChangeFontFamily = register({
return (
<fieldset>
{appState.stylesPanelMode === "full" && (
<legend>{t("labels.fontFamily")}</legend>
)}
<legend>{t("labels.fontFamily")}</legend>
<FontPicker
isOpened={appState.openPopup === "fontFamily"}
selectedFontFamily={selectedFontFamily}
hoveredFontFamily={appState.currentHoveredFontFamily}
compactMode={appState.stylesPanelMode === "compact"}
onSelect={(fontFamily) => {
withCaretPositionPreservation(
() => {
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
},
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
);
setBatchedData({
openPopup: null,
currentHoveredFontFamily: null,
currentItemFontFamily: fontFamily,
});
// defensive clear so immediate close won't abuse the cached elements
cachedElementsRef.current.clear();
}}
onHover={(fontFamily) => {
setBatchedData({
@ -1196,28 +1164,25 @@ export const actionChangeFontFamily = register({
}
setBatchedData({
...batchedData,
openPopup: "fontFamily",
});
} else {
const fontFamilyData = {
// close, use the cache and clear it afterwards
const data = {
openPopup: null,
currentHoveredFontFamily: null,
cachedElements: new Map(cachedElementsRef.current),
resetAll: true,
} as ChangeFontFamilyData;
setBatchedData({
...fontFamilyData,
});
cachedElementsRef.current.clear();
// Refocus text editor when font picker closes if we were editing text
if (
appState.stylesPanelMode === "compact" &&
appState.editingTextElement
) {
restoreCaretPosition(null); // Just refocus without saved position
if (isUnmounted.current) {
// in case the component was unmounted by the parent, trigger the update directly
updateData({ ...batchedData, ...data });
} else {
setBatchedData(data);
}
cachedElementsRef.current.clear();
}
}}
/>
@ -1260,9 +1225,8 @@ export const actionChangeTextAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
PanelComponent: ({ elements, appState, updateData, app }) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
return (
<fieldset>
<legend>{t("labels.textAlign")}</legend>
@ -1311,14 +1275,7 @@ export const actionChangeTextAlign = register({
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
@ -1360,7 +1317,7 @@ export const actionChangeVerticalAlign = register({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
};
},
PanelComponent: ({ elements, appState, updateData, app, data }) => {
PanelComponent: ({ elements, appState, updateData, app }) => {
return (
<fieldset>
<div className="buttonList">
@ -1410,14 +1367,7 @@ export const actionChangeVerticalAlign = register({
) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
onChange={(value) => {
withCaretPositionPreservation(
() => updateData(value),
appState.stylesPanelMode === "compact",
!!appState.editingTextElement,
data?.onPreventClose,
);
}}
onChange={(value) => updateData(value)}
/>
</div>
</fieldset>
@ -1666,25 +1616,6 @@ export const actionChangeArrowhead = register({
},
});
export const actionChangeArrowProperties = register({
name: "changeArrowProperties",
label: "Change arrow properties",
trackEvent: false,
perform: (elements, appState, value, app) => {
// This action doesn't perform any changes directly
// It's just a container for the arrow type and arrowhead actions
return false;
},
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
return (
<div className="selected-shape-actions">
{renderAction("changeArrowType")}
{renderAction("changeArrowhead")}
</div>
);
},
});
export const actionChangeArrowType = register({
name: "changeArrowType",
label: "Change arrow types",

View File

@ -21,7 +21,7 @@ export const actionSelectAll = register({
trackEvent: { category: "canvas" },
viewMode: false,
perform: (elements, appState, value, app) => {
if (appState.selectedLinearElement?.isEditing) {
if (appState.editingLinearElement) {
return false;
}

View File

@ -18,7 +18,6 @@ export {
actionChangeFontFamily,
actionChangeTextAlign,
actionChangeVerticalAlign,
actionChangeArrowProperties,
} from "./actionProperties";
export {

View File

@ -69,7 +69,6 @@ export type ActionName =
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeArrowProperties"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"

View File

@ -48,6 +48,7 @@ export const getDefaultAppState = (): Omit<
newElement: null,
editingTextElement: null,
editingGroupId: null,
editingLinearElement: null,
activeTool: {
type: "selection",
customType: null,
@ -123,7 +124,6 @@ export const getDefaultAppState = (): Omit<
searchMatches: null,
lockedMultiSelections: {},
activeLockedId: null,
stylesPanelMode: "full",
};
};
@ -175,6 +175,7 @@ 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 },
@ -248,7 +249,6 @@ const APP_STATE_STORAGE_CONF = (<
searchMatches: { browser: false, export: false, server: false },
lockedMultiSelections: { browser: true, export: true, server: true },
activeLockedId: { browser: false, export: false, server: false },
stylesPanelMode: { browser: true, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -1,7 +1,6 @@
import {
createPasteEvent,
parseClipboard,
parseDataTransferEvent,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
@ -14,9 +13,7 @@ describe("parseClipboard()", () => {
text = "123";
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
@ -24,9 +21,7 @@ describe("parseClipboard()", () => {
text = "[123]";
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
@ -34,9 +29,7 @@ describe("parseClipboard()", () => {
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({ types: { "text/plain": text } }),
),
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text);
});
@ -46,13 +39,11 @@ describe("parseClipboard()", () => {
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": json,
},
}),
),
createPasteEvent({
types: {
"text/plain": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
});
@ -65,25 +56,21 @@ describe("parseClipboard()", () => {
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": json,
},
}),
),
createPasteEvent({
types: {
"text/html": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
),
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
@ -93,13 +80,11 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
),
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
@ -109,13 +94,11 @@ describe("parseClipboard()", () => {
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
),
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
@ -131,13 +114,11 @@ describe("parseClipboard()", () => {
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
),
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
@ -160,16 +141,14 @@ describe("parseClipboard()", () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
),
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
@ -178,16 +157,14 @@ describe("parseClipboard()", () => {
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
),
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
@ -196,21 +173,19 @@ describe("parseClipboard()", () => {
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
await parseDataTransferEvent(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
),
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",

View File

@ -5,7 +5,6 @@ import {
arrayToMap,
isMemberOf,
isPromiseLike,
EVENT,
} from "@excalidraw/common";
import { mutateElement } from "@excalidraw/element";
@ -17,26 +16,15 @@ import {
import { getContainingFrame } from "@excalidraw/element";
import type { ValueOf } from "@excalidraw/common/utility-types";
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { ExcalidrawError } from "./errors";
import {
createFile,
getFileHandle,
isSupportedImageFileType,
normalizeFile,
} from "./data/blob";
import { createFile, isSupportedImageFileType } from "./data/blob";
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
import type { FileSystemHandle } from "./data/filesystem";
import type { Spreadsheet } from "./charts";
import type { BinaryFiles } from "./types";
@ -104,7 +92,7 @@ export const createPasteEvent = ({
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent(EVENT.PASTE, {
const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
@ -113,11 +101,10 @@ export const createPasteEvent = ({
if (typeof value !== "string") {
files = files || [];
files.push(value);
event.clipboardData?.items.add(value);
continue;
}
try {
event.clipboardData?.items.add(value, type);
event.clipboardData?.setData(type, value);
if (event.clipboardData?.getData(type) !== value) {
throw new Error(`Failed to set "${type}" as clipboardData item`);
}
@ -242,10 +229,14 @@ function parseHTMLTree(el: ChildNode) {
return result;
}
const maybeParseHTMLDataItem = (
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = dataItem.value;
const html = event.clipboardData?.getData(MIME_TYPES.html);
if (!html) {
return null;
}
try {
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
@ -341,21 +332,18 @@ export const readSystemClipboard = async () => {
* Parses "paste" ClipboardEvent.
*/
const parseClipboardEventTextData = async (
dataList: ParsedDataTranferList,
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ParsedClipboardEventTextData> => {
try {
const htmlItem = dataList.findByType(MIME_TYPES.html);
const mixedContent =
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
if (mixedContent) {
if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
dataList.getData(MIME_TYPES.text) ??
event.clipboardData?.getData(MIME_TYPES.text) ||
mixedContent.value
.map((item) => item.value)
.join("\n")
@ -366,155 +354,23 @@ const parseClipboardEventTextData = async (
return mixedContent;
}
return {
type: "text",
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
};
const text = event.clipboardData?.getData(MIME_TYPES.text);
return { type: "text", value: (text || "").trim() };
} catch {
return { type: "text", value: "" };
}
};
type AllowedParsedDataTransferItem =
| {
type: ValueOf<typeof IMAGE_MIME_TYPES>;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
type ParsedDataTransferItem =
| {
type: string;
kind: "file";
file: File;
fileHandle: FileSystemHandle | null;
}
| { type: string; kind: "string"; value: string };
type ParsedDataTransferItemType<
T extends AllowedParsedDataTransferItem["type"],
> = AllowedParsedDataTransferItem & { type: T };
export type ParsedDataTransferFile = Extract<
AllowedParsedDataTransferItem,
{ kind: "file" }
>;
type ParsedDataTranferList = ParsedDataTransferItem[] & {
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
findByType: typeof findDataTransferItemType;
/**
* Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files)
* unlike `string` data transfer items.
*/
getData: typeof getDataTransferItemData;
getFiles: typeof getDataTransferFiles;
};
const findDataTransferItemType = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
return (
this.find(
(item): item is ParsedDataTransferItemType<T> => item.type === type,
) || null
);
};
const getDataTransferItemData = function <
T extends ValueOf<typeof STRING_MIME_TYPES>,
>(
this: ParsedDataTranferList,
type: T,
):
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
| null {
const item = this.find(
(
item,
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
item.type === type,
);
return item?.value ?? null;
};
const getDataTransferFiles = function (
this: ParsedDataTranferList,
): ParsedDataTransferFile[] {
return this.filter(
(item): item is ParsedDataTransferFile => item.kind === "file",
);
};
export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
const dragEvent = event;
items = dragEvent.dataTransfer?.items;
}
const dataItems = (
await Promise.all(
Array.from(items || []).map(
async (item): Promise<ParsedDataTransferItem | null> => {
if (item.kind === "file") {
const file = item.getAsFile();
if (file) {
const fileHandle = await getFileHandle(item);
return {
type: file.type,
kind: "file",
file: await normalizeFile(file),
fileHandle,
};
}
} else if (item.kind === "string") {
const { type } = item;
let value: string;
if ("clipboardData" in event && event.clipboardData) {
value = event.clipboardData?.getData(type);
} else {
value = await new Promise<string>((resolve) => {
item.getAsString((str) => resolve(str));
});
}
return { type, kind: "string", value };
}
return null;
},
),
)
).filter((data): data is ParsedDataTransferItem => data != null);
return Object.assign(dataItems, {
findByType: findDataTransferItemType,
getData: getDataTransferItemData,
getFiles: getDataTransferFiles,
});
};
/**
* Attempts to parse clipboard event.
*/
export const parseClipboard = async (
dataList: ParsedDataTranferList,
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const parsedEventData = await parseClipboardEventTextData(
dataList,
event,
isPlainPaste,
);
@ -663,14 +519,3 @@ const copyTextViaExecCommand = (text: string | null) => {
return success;
};
export const isClipboardEvent = (
event: React.SyntheticEvent | Event,
): event is ClipboardEvent => {
/** not using instanceof ClipboardEvent due to tests (jsdom) */
return (
event.type === EVENT.PASTE ||
event.type === EVENT.COPY ||
event.type === EVENT.CUT
);
};

View File

@ -91,120 +91,3 @@
}
}
}
.compact-shape-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
padding: 0.5rem;
.compact-action-item {
position: relative;
display: flex;
justify-content: center;
align-items: center;
min-height: 2.5rem;
--default-button-size: 2rem;
.compact-action-button {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--border-radius-lg);
background: transparent;
color: var(--color-on-surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
svg {
width: 1rem;
height: 1rem;
flex: 0 0 auto;
}
&:hover {
background: var(--button-hover-bg, var(--island-bg-color));
border-color: var(
--button-hover-border,
var(--button-border, var(--default-border-color))
);
}
&:active {
background: var(--button-active-bg, var(--island-bg-color));
border-color: var(--button-active-border, var(--color-primary-darkest));
}
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
}
}
.compact-shape-actions-island {
width: fit-content;
overflow-x: hidden;
}
.compact-popover-content {
.popover-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
.popover-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
}
}
.shape-actions-theme-scope {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
:root.theme--dark .shape-actions-theme-scope {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}

View File

@ -1,6 +1,5 @@
import clsx from "clsx";
import { useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import {
CLASSES,
@ -20,7 +19,6 @@ import {
isImageElement,
isLinearElement,
isTextElement,
isArrowElement,
} from "@excalidraw/element";
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
@ -48,20 +46,15 @@ import {
hasStrokeWidth,
} from "../scene";
import { getFormValue } from "../actions/actionProperties";
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
import { getToolbarTools } from "./shapes";
import { SHAPES } from "./shapes";
import "./Actions.scss";
import { useDevice, useExcalidrawContainer } from "./App";
import { useDevice } from "./App";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { PropertiesPopover } from "./PropertiesPopover";
import {
EmbedIcon,
extraToolsIcon,
@ -70,29 +63,11 @@ import {
laserPointerToolIcon,
MagicIcon,
LassoIcon,
sharpArrowIcon,
roundArrowIcon,
elbowArrowIcon,
TextSizeIcon,
adjustmentsIcon,
DotsHorizontalIcon,
} from "./icons";
import type {
AppClassProperties,
AppProps,
UIAppState,
Zoom,
AppState,
} from "../types";
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import type { ActionManager } from "../actions/manager";
// Common CSS class combinations
const PROPERTIES_CLASSES = clsx([
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
"properties-content",
]);
export const canChangeStrokeColor = (
appState: UIAppState,
targetElements: ExcalidrawElement[],
@ -165,7 +140,7 @@ export const SelectedShapeActions = ({
targetElements.length === 1 || isSingleElementBoundContainer;
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
!appState.editingLinearElement &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
@ -305,437 +280,6 @@ export const SelectedShapeActions = ({
);
};
export const CompactShapeActions = ({
appState,
elementsMap,
renderAction,
app,
setAppState,
}: {
appState: UIAppState;
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
renderAction: ActionManager["renderAction"];
app: AppClassProperties;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const targetElements = getTargetElements(elementsMap, appState);
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
const { container } = useExcalidrawContainer();
const isEditingTextOrNewElement = Boolean(
appState.editingTextElement || appState.newElement,
);
const showFillIcons =
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
);
const showLinkIcon = targetElements.length === 1;
const showLineEditorAction =
!appState.selectedLinearElement?.isEditing &&
targetElements.length === 1 &&
isLinearElement(targetElements[0]) &&
!isElbowArrow(targetElements[0]);
const showCropEditorAction =
!appState.croppingElementId &&
targetElements.length === 1 &&
isImageElement(targetElements[0]);
const showAlignActions = alignActionsPredicate(appState, app);
let isSingleElementBoundContainer = false;
if (
targetElements.length === 2 &&
(hasBoundTextElement(targetElements[0]) ||
hasBoundTextElement(targetElements[1]))
) {
isSingleElementBoundContainer = true;
}
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
return (
<div className="compact-shape-actions">
{/* Stroke Color */}
{canChangeStrokeColor(appState, targetElements) && (
<div className={clsx("compact-action-item")}>
{renderAction("changeStrokeColor")}
</div>
)}
{/* Background Color */}
{canChangeBackgroundColor(appState, targetElements) && (
<div className="compact-action-item">
{renderAction("changeBackgroundColor")}
</div>
)}
{/* Combined Properties (Fill, Stroke, Opacity) */}
{(showFillIcons ||
hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeWidth(element.type)) ||
hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) => hasStrokeStyle(element.type)) ||
canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) => canChangeRoundness(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactStrokeStyles"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactStrokeStyles" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.stroke")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactStrokeStyles"
? null
: "compactStrokeStyles",
});
}}
>
{adjustmentsIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactStrokeStyles" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
<div className="selected-shape-actions">
{showFillIcons && renderAction("changeFillStyle")}
{(hasStrokeWidth(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
)) && (
<>
{renderAction("changeStrokeStyle")}
{renderAction("changeSloppiness")}
</>
)}
{(canChangeRoundness(appState.activeTool.type) ||
targetElements.some((element) =>
canChangeRoundness(element.type),
)) &&
renderAction("changeRoundness")}
{renderAction("changeOpacity")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Combined Arrow Properties */}
{(toolIsArrow(appState.activeTool.type) ||
targetElements.some((element) => toolIsArrow(element.type))) && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactArrowProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactArrowProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.arrowtypes")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactArrowProperties"
? null
: "compactArrowProperties",
});
}}
>
{(() => {
// Show an icon based on the current arrow type
const arrowType = getFormValue(
targetElements,
app,
(element) => {
if (isArrowElement(element)) {
return element.elbowed
? "elbow"
: element.roundness
? "round"
: "sharp";
}
return null;
},
(element) => isArrowElement(element),
(hasSelection) =>
hasSelection ? null : appState.currentItemArrowType,
);
if (arrowType === "elbow") {
return elbowArrowIcon;
}
if (arrowType === "round") {
return roundArrowIcon;
}
return sharpArrowIcon;
})()}
</button>
</Popover.Trigger>
{appState.openPopup === "compactArrowProperties" && (
<PropertiesPopover
container={container}
className="properties-content"
style={{ maxWidth: "13rem" }}
onClose={() => {}}
>
{renderAction("changeArrowProperties")}
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
{/* Linear Editor */}
{showLineEditorAction && (
<div className="compact-action-item">
{renderAction("toggleLinearEditor")}
</div>
)}
{/* Text Properties */}
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
<div className="compact-action-item">
{renderAction("changeFontFamily")}
</div>
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactTextProperties"}
onOpenChange={(open) => {
if (open) {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
} else {
setAppState({ openPopup: null });
if (appState.editingTextElement) {
restoreCaretPosition();
}
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.textAlign")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (appState.openPopup === "compactTextProperties") {
setAppState({ openPopup: null });
} else {
if (appState.editingTextElement) {
saveCaretPosition();
}
setAppState({ openPopup: "compactTextProperties" });
}
}}
>
{TextSizeIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactTextProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onClose={() => {
// Refocus text editor when popover closes with caret restoration
if (appState.editingTextElement) {
restoreCaretPosition();
}
}}
>
<div className="selected-shape-actions">
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) &&
renderAction("changeFontSize")}
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
renderAction("changeTextAlign")}
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
renderAction("changeVerticalAlign")}
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
</>
)}
{/* Dedicated Copy Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("duplicateSelection")}
</div>
)}
{/* Dedicated Delete Button */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
{renderAction("deleteSelectedElements")}
</div>
)}
{/* Combined Other Actions */}
{!isEditingTextOrNewElement && targetElements.length > 0 && (
<div className="compact-action-item">
<Popover.Root
open={appState.openPopup === "compactOtherProperties"}
onOpenChange={(open) => {
if (open) {
setAppState({ openPopup: "compactOtherProperties" });
} else {
setAppState({ openPopup: null });
}
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="compact-action-button properties-trigger"
title={t("labels.actions")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setAppState({
openPopup:
appState.openPopup === "compactOtherProperties"
? null
: "compactOtherProperties",
});
}}
>
{DotsHorizontalIcon}
</button>
</Popover.Trigger>
{appState.openPopup === "compactOtherProperties" && (
<PropertiesPopover
className={PROPERTIES_CLASSES}
container={container}
style={{
maxWidth: "12rem",
// center the popover content
justifyContent: "center",
alignItems: "center",
}}
onClose={() => {}}
>
<div className="selected-shape-actions">
<fieldset>
<legend>{t("labels.layers")}</legend>
<div className="buttonList">
{renderAction("sendToBack")}
{renderAction("sendBackward")}
{renderAction("bringForward")}
{renderAction("bringToFront")}
</div>
</fieldset>
{showAlignActions && !isSingleElementBoundContainer && (
<fieldset>
<legend>{t("labels.align")}</legend>
<div className="buttonList">
{isRTL ? (
<>
{renderAction("alignRight")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignLeft")}
</>
) : (
<>
{renderAction("alignLeft")}
{renderAction("alignHorizontallyCentered")}
{renderAction("alignRight")}
</>
)}
{targetElements.length > 2 &&
renderAction("distributeHorizontally")}
{/* breaks the row ˇˇ */}
<div style={{ flexBasis: "100%", height: 0 }} />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
marginTop: "-0.5rem",
}}
>
{renderAction("alignTop")}
{renderAction("alignVerticallyCentered")}
{renderAction("alignBottom")}
{targetElements.length > 2 &&
renderAction("distributeVertically")}
</div>
</div>
</fieldset>
)}
<fieldset>
<legend>{t("labels.actions")}</legend>
<div className="buttonList">
{renderAction("group")}
{renderAction("ungroup")}
{showLinkIcon && renderAction("hyperlink")}
{showCropEditorAction && renderAction("cropEditor")}
</div>
</fieldset>
</div>
</PropertiesPopover>
)}
</Popover.Root>
</div>
)}
</div>
);
};
export const ShapesSwitcher = ({
activeTool,
appState,
@ -751,8 +295,7 @@ export const ShapesSwitcher = ({
const frameToolSelected = activeTool.type === "frame";
const laserToolSelected = activeTool.type === "laser";
const lassoToolSelected =
activeTool.type === "lasso" && app.defaultSelectionTool !== "lasso";
const lassoToolSelected = activeTool.type === "lasso";
const embeddableToolSelected = activeTool.type === "embeddable";
@ -760,68 +303,63 @@ export const ShapesSwitcher = ({
return (
<>
{getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => {
if (
UIOptions.tools?.[
value as Extract<
typeof value,
keyof AppProps["UIOptions"]["tools"]
>
] === false
) {
return null;
}
{SHAPES.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);
}
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" });
}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
if (value === "selection") {
if (appState.activeTool.type === "selection") {
app.setActiveTool({ type: "lasso" });
} else {
app.setActiveTool({ type: value });
app.setActiveTool({ type: "selection" });
}
}}
/>
);
},
)}
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
<DropdownMenu open={isExtraToolsMenuOpen}>
@ -880,16 +418,14 @@ export const ShapesSwitcher = ({
>
{t("toolBar.laser")}
</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>
)}
<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>
@ -969,3 +505,15 @@ 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

View File

@ -22,12 +22,6 @@
@include isMobile {
max-width: 11rem;
}
&.color-picker-container--no-top-picks {
display: flex;
justify-content: center;
grid-template-columns: unset;
}
}
.color-picker__top-picks {
@ -86,16 +80,6 @@
}
}
.color-picker__button-background {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
}
}
&.active {
.color-picker__button-outline {
position: absolute;

View File

@ -1,6 +1,6 @@
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import { useRef, useEffect } from "react";
import { useRef } from "react";
import {
COLOR_OUTLINE_CONTRAST_THRESHOLD,
@ -18,12 +18,7 @@ import { useExcalidrawContainer } from "../App";
import { ButtonSeparator } from "../ButtonSeparator";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
import {
saveCaretPosition,
restoreCaretPosition,
temporarilyDisableTextEditorBlur,
} from "../../hooks/useTextEditorFocus";
import { slashIcon } from "../icons";
import { ColorInput } from "./ColorInput";
import { Picker } from "./Picker";
@ -72,7 +67,6 @@ interface ColorPickerProps {
palette?: ColorPaletteCustom | null;
topPicks?: ColorTuple;
updateData: (formData?: any) => void;
compactMode?: boolean;
}
const ColorPickerPopupContent = ({
@ -83,8 +77,6 @@ const ColorPickerPopupContent = ({
elements,
palette = COLOR_PALETTE,
updateData,
getOpenPopup,
appState,
}: Pick<
ColorPickerProps,
| "type"
@ -94,10 +86,7 @@ const ColorPickerPopupContent = ({
| "elements"
| "palette"
| "updateData"
| "appState"
> & {
getOpenPopup: () => AppState["openPopup"];
}) => {
>) => {
const { container } = useExcalidrawContainer();
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
@ -128,8 +117,6 @@ const ColorPickerPopupContent = ({
<PropertiesPopover
container={container}
style={{ maxWidth: "13rem" }}
// Improve focus handling for text editing scenarios
preventAutoFocusOnTouch={!!appState.editingTextElement}
onFocusOutside={(event) => {
// refocus due to eye dropper
focusPickerContent();
@ -144,23 +131,8 @@ const ColorPickerPopupContent = ({
}
}}
onClose={() => {
// only clear if we're still the active popup (avoid racing with switch)
if (getOpenPopup() === type) {
updateData({ openPopup: null });
}
updateData({ openPopup: null });
setActiveColorPickerSection(null);
// Refocus text editor when popover closes if we were editing text
if (appState.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
>
{palette ? (
@ -169,17 +141,7 @@ const ColorPickerPopupContent = ({
palette={palette}
color={color}
onChange={(changedColor) => {
// Save caret position before color change if editing text
const savedSelection = appState.editingTextElement
? saveCaretPosition()
: null;
onChange(changedColor);
// Restore caret position after color change if editing text
if (appState.editingTextElement && savedSelection) {
restoreCaretPosition(savedSelection);
}
}}
onEyeDropperToggle={(force) => {
setEyeDropperState((state) => {
@ -206,7 +168,6 @@ const ColorPickerPopupContent = ({
if (eyeDropperState) {
setEyeDropperState(null);
} else {
// close explicitly on Escape
updateData({ openPopup: null });
}
}}
@ -227,32 +188,11 @@ const ColorPickerTrigger = ({
label,
color,
type,
compactMode = false,
mode = "background",
onToggle,
editingTextElement,
}: {
color: string | null;
label: string;
type: ColorPickerType;
compactMode?: boolean;
mode?: "background" | "stroke";
onToggle: () => void;
editingTextElement?: boolean;
}) => {
const handleClick = (e: React.MouseEvent) => {
// use pointerdown so we run before outside-close logic
e.preventDefault();
e.stopPropagation();
// If editing text, temporarily disable the wysiwyg blur event
if (editingTextElement) {
temporarilyDisableTextEditorBlur();
}
onToggle();
};
return (
<Popover.Trigger
type="button"
@ -268,37 +208,8 @@ const ColorPickerTrigger = ({
? t("labels.showStroke")
: t("labels.showBackground")
}
data-openpopup={type}
onClick={handleClick}
>
<div className="color-picker__button-outline">{!color && slashIcon}</div>
{compactMode && color && (
<div className="color-picker__button-background">
{mode === "background" ? (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{backgroundIcon}
</span>
) : (
<span
style={{
color:
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
? "#fff"
: "#111",
}}
>
{strokeIcon}
</span>
)}
</div>
)}
</Popover.Trigger>
);
};
@ -313,59 +224,25 @@ export const ColorPicker = ({
topPicks,
updateData,
appState,
compactMode = false,
}: ColorPickerProps) => {
const openRef = useRef(appState.openPopup);
useEffect(() => {
openRef.current = appState.openPopup;
}, [appState.openPopup]);
return (
<div>
<div
role="dialog"
aria-modal="true"
className={clsx("color-picker-container", {
"color-picker-container--no-top-picks": compactMode,
})}
>
{!compactMode && (
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
)}
{!compactMode && <ButtonSeparator />}
<div role="dialog" aria-modal="true" className="color-picker-container">
<TopPicks
activeColor={color}
onChange={onChange}
type={type}
topPicks={topPicks}
/>
<ButtonSeparator />
<Popover.Root
open={appState.openPopup === type}
onOpenChange={(open) => {
if (open) {
updateData({ openPopup: type });
}
updateData({ openPopup: open ? type : null });
}}
>
{/* serves as an active color indicator as well */}
<ColorPickerTrigger
color={color}
label={label}
type={type}
compactMode={compactMode}
mode={type === "elementStroke" ? "stroke" : "background"}
editingTextElement={!!appState.editingTextElement}
onToggle={() => {
// atomic switch: if another popup is open, close it first, then open this one next tick
if (appState.openPopup === type) {
// toggle off on same trigger
updateData({ openPopup: null });
} else if (appState.openPopup) {
updateData({ openPopup: type });
} else {
// open this one
updateData({ openPopup: type });
}
}}
/>
<ColorPickerTrigger color={color} label={label} type={type} />
{/* popup content */}
{appState.openPopup === type && (
<ColorPickerPopupContent
@ -376,8 +253,6 @@ export const ColorPicker = ({
elements={elements}
palette={palette}
updateData={updateData}
getOpenPopup={() => openRef.current}
appState={appState}
/>
)}
</Popover.Root>

View File

@ -108,7 +108,6 @@ $verticalBreakpoint: 861px;
display: flex;
align-items: center;
gap: 0.25rem;
overflow: hidden;
}
}

View File

@ -59,8 +59,6 @@ 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";
@ -966,7 +964,7 @@ const CommandItem = ({
}
/>
)}
<Ellipsify>{command.label}</Ellipsify>
{command.label}
</div>
{showShortcut && command.shortcut && (
<CommandShortcutHint shortcut={command.shortcut} />

View File

@ -1,18 +0,0 @@
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>
);
};

View File

@ -11,10 +11,5 @@
2rem + 4 * var(--default-button-size)
); // 4 gaps + 4 buttons
}
&--compact {
display: block;
grid-template-columns: none;
}
}
}

View File

@ -1,5 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import React, { useCallback, useMemo } from "react";
import { FONT_FAMILY } from "@excalidraw/common";
@ -59,7 +58,6 @@ interface FontPickerProps {
onHover: (fontFamily: FontFamilyValues) => void;
onLeave: () => void;
onPopupChange: (open: boolean) => void;
compactMode?: boolean;
}
export const FontPicker = React.memo(
@ -71,7 +69,6 @@ export const FontPicker = React.memo(
onHover,
onLeave,
onPopupChange,
compactMode = false,
}: FontPickerProps) => {
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
const onSelectCallback = useCallback(
@ -84,29 +81,18 @@ export const FontPicker = React.memo(
);
return (
<div
role="dialog"
aria-modal="true"
className={clsx("FontPicker__container", {
"FontPicker__container--compact": compactMode,
})}
>
{!compactMode && (
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
)}
{!compactMode && <ButtonSeparator />}
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger
selectedFontFamily={selectedFontFamily}
isOpened={isOpened}
<div role="dialog" aria-modal="true" className="FontPicker__container">
<div className="buttonList">
<RadioSelection<FontFamilyValues | false>
type="button"
options={defaultFonts}
value={selectedFontFamily}
onClick={onSelectCallback}
/>
</div>
<ButtonSeparator />
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
{isOpened && (
<FontPickerList
selectedFontFamily={selectedFontFamily}

View File

@ -90,8 +90,7 @@ export const FontPickerList = React.memo(
onClose,
}: FontPickerListProps) => {
const { container } = useExcalidrawContainer();
const app = useApp();
const { fonts } = app;
const { fonts } = useApp();
const { showDeprecatedFonts } = useAppProps();
const [searchTerm, setSearchTerm] = useState("");
@ -188,42 +187,6 @@ export const FontPickerList = React.memo(
onLeave,
]);
// Create a wrapped onSelect function that preserves caret position
const wrappedOnSelect = useCallback(
(fontFamily: FontFamilyValues) => {
// Save caret position before font selection if editing text
let savedSelection: { start: number; end: number } | null = null;
if (app.state.editingTextElement) {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
savedSelection = {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
}
onSelect(fontFamily);
// Restore caret position after font selection if editing text
if (app.state.editingTextElement && savedSelection) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor && savedSelection) {
textEditor.focus();
textEditor.selectionStart = savedSelection.start;
textEditor.selectionEnd = savedSelection.end;
}
}, 0);
}
},
[onSelect, app.state.editingTextElement],
);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
const handled = fontPickerKeyHandler({
@ -231,7 +194,7 @@ export const FontPickerList = React.memo(
inputRef,
hoveredFont,
filteredFonts,
onSelect: wrappedOnSelect,
onSelect,
onHover,
onClose,
});
@ -241,7 +204,7 @@ export const FontPickerList = React.memo(
event.stopPropagation();
}
},
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
);
useEffect(() => {
@ -277,7 +240,7 @@ export const FontPickerList = React.memo(
// allow to tab between search and selected font
tabIndex={font.value === selectedFontFamily ? 0 : -1}
onClick={(e) => {
wrappedOnSelect(Number(e.currentTarget.value));
onSelect(Number(e.currentTarget.value));
}}
onMouseMove={() => {
if (hoveredFont?.value !== font.value) {
@ -319,24 +282,9 @@ export const FontPickerList = React.memo(
className="properties-content"
container={container}
style={{ width: "15rem" }}
onClose={() => {
onClose();
// Refocus text editor when font picker closes if we were editing text
if (app.state.editingTextElement) {
setTimeout(() => {
const textEditor = document.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
if (textEditor) {
textEditor.focus();
}
}, 0);
}
}}
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
preventAutoFocusOnTouch={!!app.state.editingTextElement}
>
<QuickSearch
ref={inputRef}

View File

@ -1,4 +1,5 @@
import * as Popover from "@radix-ui/react-popover";
import { useMemo } from "react";
import type { FontFamilyValues } from "@excalidraw/element/types";
@ -6,38 +7,33 @@ import { t } from "../../i18n";
import { ButtonIcon } from "../ButtonIcon";
import { TextIcon } from "../icons";
import { useExcalidrawSetAppState } from "../App";
import { isDefaultFont } from "./FontPicker";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
isOpened?: boolean;
}
export const FontPickerTrigger = ({
selectedFontFamily,
isOpened = false,
}: FontPickerTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const isTriggerActive = useMemo(
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
[selectedFontFamily],
);
return (
<Popover.Trigger asChild>
<div data-openpopup="fontFamily" className="properties-trigger">
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
<div>
<ButtonIcon
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isOpened}
onClick={() => {
setAppState((appState) => ({
openPopup:
appState.openPopup === "fontFamily" ? null : appState.openPopup,
}));
}}
style={{
border: "none",
}}
active={isTriggerActive}
// no-op
onClick={() => {}}
/>
</div>
</Popover.Trigger>

View File

@ -115,7 +115,7 @@ const getHints = ({
appState.selectionElement &&
!selectedElements.length &&
!appState.editingTextElement &&
!appState.selectedLinearElement?.isEditing
!appState.editingLinearElement
) {
return [t("hints.deepBoxSelect")];
}
@ -130,8 +130,8 @@ const getHints = ({
if (selectedElements.length === 1) {
if (isLinearElement(selectedElements[0])) {
if (appState.selectedLinearElement?.isEditing) {
return appState.selectedLinearElement.selectedPointsIndices
if (appState.editingLinearElement) {
return appState.editingLinearElement.selectedPointsIndices
? t("hints.lineEditor_pointSelected")
: t("hints.lineEditor_nothingSelected");
}

View File

@ -7,7 +7,6 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
flex: "0 0 auto",
}}
>
{icon}

View File

@ -24,10 +24,6 @@
gap: 0.75rem;
pointer-events: none !important;
&--compact {
gap: 0.5rem;
}
& > * {
pointer-events: var(--ui-pointerEvents);
}

View File

@ -4,7 +4,6 @@ import React from "react";
import {
CLASSES,
DEFAULT_SIDEBAR,
MQ_MIN_WIDTH_DESKTOP,
TOOL_TYPE,
arrayToMap,
capitalizeString,
@ -29,11 +28,7 @@ import { useAtom, useAtomValue } from "../editor-jotai";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import {
SelectedShapeActions,
ShapesSwitcher,
CompactShapeActions,
} from "./Actions";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { LoadingMessage } from "./LoadingMessage";
import { LockButton } from "./LockButton";
import { MobileMenu } from "./MobileMenu";
@ -162,25 +157,6 @@ const LayerUI = ({
const device = useDevice();
const tunnels = useInitializeTunnels();
const spacing =
appState.stylesPanelMode === "compact"
? {
menuTopGap: 4,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 0.5,
islandPadding: 1,
collabMarginLeft: 8,
}
: {
menuTopGap: 6,
toolbarColGap: 4,
toolbarRowGap: 1,
toolbarInnerRowGap: 1,
islandPadding: 1,
collabMarginLeft: 8,
};
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
@ -233,55 +209,31 @@ const LayerUI = ({
</div>
);
const renderSelectedShapeActions = () => {
const isCompactMode = appState.stylesPanelMode === "compact";
return (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
const renderSelectedShapeActions = () => (
<Section
heading="selectedShapeActions"
className={clsx("selected-shape-actions zen-mode-transition", {
"transition-left": appState.zenModeEnabled,
})}
>
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
{isCompactMode ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<CompactShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
setAppState={setAppState}
/>
</Island>
) : (
<Island
className={CLASSES.SHAPE_ACTIONS_MENU}
padding={2}
style={{
// we want to make sure this doesn't overflow so subtracting the
// approximate height of hamburgerMenu + footer
maxHeight: `${appState.height - 166}px`,
}}
>
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
)}
</Section>
);
};
<SelectedShapeActions
appState={appState}
elementsMap={app.scene.getNonDeletedElementsMap()}
renderAction={actionManager.renderAction}
app={app}
/>
</Island>
</Section>
);
const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -298,19 +250,9 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col
gap={spacing.menuTopGap}
className={clsx("App-menu_top__left")}
>
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
{renderCanvasActions()}
<div
className={clsx("selected-shape-actions-container", {
"selected-shape-actions-container--compact":
appState.stylesPanelMode === "compact",
})}
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
@ -320,19 +262,17 @@ const LayerUI = ({
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={spacing.toolbarColGap} align="start">
<Stack.Col gap={4} align="start">
<Stack.Row
gap={spacing.toolbarRowGap}
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Island
padding={spacing.islandPadding}
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
"App-toolbar--compact":
appState.stylesPanelMode === "compact",
})}
>
<HintViewer
@ -342,7 +282,7 @@ const LayerUI = ({
app={app}
/>
{heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}>
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
@ -376,7 +316,7 @@ const LayerUI = ({
{isCollaborating && (
<Island
style={{
marginLeft: spacing.collabMarginLeft,
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
@ -404,8 +344,6 @@ const LayerUI = ({
"layer-ui__wrapper__top-right zen-mode-transition",
{
"transition-right": appState.zenModeEnabled,
"layer-ui__wrapper__top-right--compact":
appState.stylesPanelMode === "compact",
},
)}
>
@ -480,9 +418,7 @@ const LayerUI = ({
}}
tab={DEFAULT_SIDEBAR.defaultTab}
>
{appState.stylesPanelMode === "full" &&
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
t("toolBar.library")}
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}

View File

@ -17,7 +17,6 @@ interface PropertiesPopoverProps {
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
preventAutoFocusOnTouch?: boolean;
}
export const PropertiesPopover = React.forwardRef<
@ -35,7 +34,6 @@ export const PropertiesPopover = React.forwardRef<
onFocusOutside,
onPointerLeave,
onPointerDownOutside,
preventAutoFocusOnTouch = false,
},
ref,
) => {
@ -66,12 +64,6 @@ export const PropertiesPopover = React.forwardRef<
onKeyDown={onKeyDown}
onFocusOutside={onFocusOutside}
onPointerDownOutside={onPointerDownOutside}
onOpenAutoFocus={(e) => {
// prevent auto-focus on touch devices to avoid keyboard popup
if (preventAutoFocusOnTouch && device.isTouchScreen) {
e.preventDefault();
}
}}
onCloseAutoFocus={(e) => {
e.stopPropagation();
// prevents focusing the trigger

View File

@ -10,16 +10,6 @@
}
}
&--compact {
.ToolIcon__keybinding {
display: none;
}
.App-toolbar__divider {
margin: 0;
}
}
&__divider {
width: 1px;
height: 1.5rem;

View File

@ -192,6 +192,7 @@ const getRelevantAppStateProps = (
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds,
frameToHighlight: appState.frameToHighlight,
offsetLeft: appState.offsetLeft,

View File

@ -34,13 +34,6 @@ 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) {
@ -56,6 +49,26 @@ 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,

View File

@ -19,8 +19,6 @@
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;
@ -102,7 +100,6 @@
align-items: center;
cursor: pointer;
border-radius: var(--border-radius-md);
flex: 1 0 auto;
@media screen and (min-width: 1921px) {
height: 2.25rem;

View File

@ -1,7 +1,5 @@
import { useDevice } from "../App";
import { Ellipsify } from "../Ellipsify";
import type { JSX } from "react";
const MenuItemContent = ({
@ -20,7 +18,7 @@ const MenuItemContent = ({
<>
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
<div style={textStyle} className="dropdown-menu-item__text">
<Ellipsify>{children}</Ellipsify>
{children}
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut">{shortcut}</div>

View File

@ -2,7 +2,13 @@ import clsx from "clsx";
import { actionShortcuts } from "../../actions";
import { useTunnels } from "../../context/tunnels";
import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
import {
ExitZenModeAction,
FinalizeAction,
UndoRedoActions,
ZoomActions,
} from "../Actions";
import { useDevice } from "../App";
import { HelpButton } from "../HelpButton";
import { Section } from "../Section";
import Stack from "../Stack";
@ -23,6 +29,10 @@ const Footer = ({
}) => {
const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
const device = useDevice();
const showFinalize =
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
return (
<footer
role="contentinfo"
@ -50,6 +60,15 @@ 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>

View File

@ -118,17 +118,6 @@ export const DotsIcon = createIcon(
tablerIconProps,
);
// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
export const DotsHorizontalIcon = createIcon(
<g strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</g>,
tablerIconProps,
);
// tabler-icons: pinned
export const PinIcon = createIcon(
<svg strokeWidth="1.5">
@ -407,19 +396,6 @@ export const TextIcon = createIcon(
tablerIconProps,
);
export const TextSizeIcon = createIcon(
<g stroke="currentColor" strokeWidth="1.5">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 7v-2h13v2" />
<path d="M10 5v14" />
<path d="M12 19h-4" />
<path d="M15 13v-1h6v1" />
<path d="M18 12v7" />
<path d="M17 19h2" />
</g>,
tablerIconProps,
);
// modified tabler-icons: photo
export const ImageIcon = createIcon(
<g strokeWidth="1.25">
@ -2293,48 +2269,3 @@ export const elementLinkIcon = createIcon(
</g>,
tablerIconProps,
);
export const resizeIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
<path d="M4 12h7a1 1 0 0 1 1 1v7" />
</g>,
tablerIconProps,
);
export const adjustmentsIcon = createIcon(
<g strokeWidth={1.5}>
<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 backgroundIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 10l4 -4" />
<path d="M6 14l8 -8" />
<path d="M6 18l12 -12" />
<path d="M10 18l8 -8" />
<path d="M14 18l4 -4" />
</g>,
tablerIconProps,
);
export const strokeIcon = createIcon(
<g strokeWidth={1}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<rect x="6" y="6" width="12" height="12" fill="none" />
</g>,
tablerIconProps,
);

View File

@ -1,7 +1,5 @@
import clsx from "clsx";
import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
import { t } from "../../i18n";
import { Button } from "../Button";
import { share } from "../icons";
@ -19,8 +17,7 @@ const LiveCollaborationTrigger = ({
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const appState = useUIAppState();
const showIconOnly =
isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
const showIconOnly = appState.width < 830;
return (
<Button

View File

@ -13,8 +13,6 @@ import {
EraserIcon,
} from "./icons";
import type { AppClassProperties } from "../types";
export const SHAPES = [
{
icon: SelectionIcon,
@ -88,23 +86,8 @@ export const SHAPES = [
},
] as const;
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) => {
export const findShapeByKey = (key: string) => {
const shape = SHAPES.find((shape, index) => {
return (
(shape.numericKey != null && key === shape.numericKey.toString()) ||
(shape.key &&

View File

@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
.App-menu_top {
grid-template-columns: 1fr 2fr 1fr;
grid-gap: 1rem;
grid-gap: 2rem;
align-items: flex-start;
cursor: default;
pointer-events: none !important;
@ -336,14 +336,6 @@ body.excalidraw-cursor-resize * {
justify-self: flex-start;
}
.selected-shape-actions-container {
width: fit-content;
&--compact {
min-width: 48px;
}
}
.App-menu_top > *:last-child {
justify-self: flex-end;
}

View File

@ -96,8 +96,6 @@ export const getMimeType = (blob: Blob | string): string => {
return MIME_TYPES.jpg;
} else if (/\.svg$/.test(name)) {
return MIME_TYPES.svg;
} else if (/\.excalidrawlib$/.test(name)) {
return MIME_TYPES.excalidrawlib;
}
return "";
};
@ -172,11 +170,7 @@ export const loadSceneOrLibraryFromBlob = async (
},
localAppState,
localElements,
{
repairBindings: true,
refreshDimensions: false,
deleteInvisibleElements: true,
},
{ repairBindings: true, refreshDimensions: false },
),
};
} else if (isValidLibrary(data)) {
@ -391,18 +385,23 @@ export const ImageURLToFile = async (
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
};
export const getFileFromEvent = async (
event: React.DragEvent<HTMLDivElement>,
) => {
const file = event.dataTransfer.files.item(0);
const fileHandle = await getFileHandle(event);
return { file: file ? await normalizeFile(file) : null, fileHandle };
};
export const getFileHandle = async (
event: DragEvent | React.DragEvent | DataTransferItem,
event: React.DragEvent<HTMLDivElement>,
): Promise<FileSystemHandle | null> => {
if (nativeFileSystemSupported) {
try {
const dataTransferItem =
event instanceof DataTransferItem
? event
: (event as DragEvent).dataTransfer?.items?.[0];
const item = event.dataTransfer.items[0];
const handle: FileSystemHandle | null =
(await (dataTransferItem as any).getAsFileSystemHandle()) || null;
(await (item as any).getAsFileSystemHandle()) || null;
return handle;
} catch (error: any) {

View File

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

View File

@ -241,9 +241,8 @@ const restoreElementWithProperties = <
return ret;
};
export const restoreElement = (
const restoreElement = (
element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
opts?: { deleteInvisibleElements?: boolean },
): typeof element | null => {
element = { ...element };
@ -291,8 +290,7 @@ export const restoreElement = (
// if empty text, mark as deleted. We keep in array
// for data integrity purposes (collab etc.)
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)
if (!text && !element.isDeleted) {
element = { ...element, originalText: text, isDeleted: true };
element = bumpVersion(element);
}
@ -387,10 +385,7 @@ export const restoreElement = (
elbowed: true,
startBinding: repairBinding(element, element.startBinding),
endBinding: repairBinding(element, element.endBinding),
fixedSegments:
element.fixedSegments?.length && base.points.length >= 4
? element.fixedSegments
: null,
fixedSegments: element.fixedSegments,
startIsSpecial: element.startIsSpecial,
endIsSpecial: element.endIsSpecial,
})
@ -528,13 +523,7 @@ export const restoreElements = (
elements: ImportedDataState["elements"],
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
opts?:
| {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
}
| undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): OrderedExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
@ -543,38 +532,24 @@ 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") {
return elements;
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);
}
}
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[]),
);
@ -815,11 +790,7 @@ export const restore = (
*/
localAppState: Partial<AppState> | null | undefined,
localElements: readonly ExcalidrawElement[] | null | undefined,
elementsConfig?: {
refreshDimensions?: boolean;
repairBindings?: boolean;
deleteInvisibleElements?: boolean;
},
elementsConfig?: { refreshDimensions?: boolean; repairBindings?: boolean },
): RestoredDataState => {
return {
elements: restoreElements(data?.elements, localElements, elementsConfig),

View File

@ -1,26 +1,11 @@
import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
import {
computeBoundTextPosition,
distanceToElement,
doBoundsIntersect,
getBoundTextElement,
getElementBounds,
getFreedrawOutlineAsSegments,
getFreedrawOutlinePoints,
intersectElementWithLineSegment,
isArrowElement,
isFreeDrawElement,
isLineElement,
isPointInElement,
} from "@excalidraw/element";
import {
lineSegment,
lineSegmentsDistance,
pointFrom,
polygon,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { lineSegment, pointFrom } from "@excalidraw/math";
import { getElementsInGroup } from "@excalidraw/element";
@ -28,8 +13,6 @@ import { shouldTestInside } from "@excalidraw/element";
import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
import { getBoundTextElementId } from "@excalidraw/element";
import type { Bounds } from "@excalidraw/element";
import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
@ -113,7 +96,6 @@ export class EraserTrail extends AnimatedTrail {
pathSegment,
element,
candidateElementsMap,
this.app.state.zoom.value,
);
if (intersects) {
@ -149,7 +131,6 @@ export class EraserTrail extends AnimatedTrail {
pathSegment,
element,
candidateElementsMap,
this.app.state.zoom.value,
);
if (intersects) {
@ -199,33 +180,8 @@ const eraserTest = (
pathSegment: LineSegment<GlobalPoint>,
element: ExcalidrawElement,
elementsMap: ElementsMap,
zoom: number,
): boolean => {
const lastPoint = pathSegment[1];
// PERF: Do a quick bounds intersection test first because it's cheap
const threshold = isFreeDrawElement(element) ? 15 : element.strokeWidth / 2;
const segmentBounds = [
Math.min(pathSegment[0][0], pathSegment[1][0]) - threshold,
Math.min(pathSegment[0][1], pathSegment[1][1]) - threshold,
Math.max(pathSegment[0][0], pathSegment[1][0]) + threshold,
Math.max(pathSegment[0][1], pathSegment[1][1]) + threshold,
] as Bounds;
const origElementBounds = getElementBounds(element, elementsMap);
const elementBounds: Bounds = [
origElementBounds[0] - threshold,
origElementBounds[1] - threshold,
origElementBounds[2] + threshold,
origElementBounds[3] + threshold,
];
if (!doBoundsIntersect(segmentBounds, elementBounds)) {
return false;
}
// There are shapes where the inner area should trigger erasing
// even though the eraser path segment doesn't intersect with or
// get close to the shape's stroke
if (
shouldTestInside(element) &&
isPointInElement(lastPoint, element, elementsMap)
@ -233,50 +189,6 @@ const eraserTest = (
return true;
}
// Freedraw elements are tested for erasure by measuring the distance
// of the eraser path and the freedraw shape outline lines to a tolerance
// which offers a good visual precision at various zoom levels
if (isFreeDrawElement(element)) {
const outlinePoints = getFreedrawOutlinePoints(element);
const strokeSegments = getFreedrawOutlineAsSegments(
element,
outlinePoints,
elementsMap,
);
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
for (const seg of strokeSegments) {
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
return true;
}
}
const poly = polygon(
...(outlinePoints.map(([x, y]) =>
pointFrom<GlobalPoint>(element.x + x, element.y + y),
) as GlobalPoint[]),
);
// PERF: Check only one point of the eraser segment. If the eraser segment
// start is inside the closed freedraw shape, the other point is either also
// inside or the eraser segment will intersect the shape outline anyway
if (polygonIncludesPointNonZero(pathSegment[0], poly)) {
return true;
}
return false;
} else if (
isArrowElement(element) ||
(isLineElement(element) && !element.polygon)
) {
const tolerance = Math.max(
element.strokeWidth,
(element.strokeWidth * 2) / zoom,
);
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
}
const boundTextElement = getBoundTextElement(element, elementsMap);
return (

View File

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

View File

@ -1,112 +0,0 @@
import { useState, useCallback } from "react";
// Utility type for caret position
export type CaretPosition = {
start: number;
end: number;
};
// Utility function to get text editor element
const getTextEditor = (): HTMLTextAreaElement | null => {
return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
};
// Utility functions for caret position management
export const saveCaretPosition = (): CaretPosition | null => {
const textEditor = getTextEditor();
if (textEditor) {
return {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
return null;
};
export const restoreCaretPosition = (position: CaretPosition | null): void => {
setTimeout(() => {
const textEditor = getTextEditor();
if (textEditor) {
textEditor.focus();
if (position) {
textEditor.selectionStart = position.start;
textEditor.selectionEnd = position.end;
}
}
}, 0);
};
export const withCaretPositionPreservation = (
callback: () => void,
isCompactMode: boolean,
isEditingText: boolean,
onPreventClose?: () => void,
): void => {
// Prevent popover from closing in compact mode
if (isCompactMode && onPreventClose) {
onPreventClose();
}
// Save caret position if editing text
const savedPosition =
isCompactMode && isEditingText ? saveCaretPosition() : null;
// Execute the callback
callback();
// Restore caret position if needed
if (isCompactMode && isEditingText) {
restoreCaretPosition(savedPosition);
}
};
// Hook for managing text editor caret position with state
export const useTextEditorFocus = () => {
const [savedCaretPosition, setSavedCaretPosition] =
useState<CaretPosition | null>(null);
const saveCaretPositionToState = useCallback(() => {
const position = saveCaretPosition();
setSavedCaretPosition(position);
}, []);
const restoreCaretPositionFromState = useCallback(() => {
setTimeout(() => {
const textEditor = getTextEditor();
if (textEditor) {
textEditor.focus();
if (savedCaretPosition) {
textEditor.selectionStart = savedCaretPosition.start;
textEditor.selectionEnd = savedCaretPosition.end;
setSavedCaretPosition(null);
}
}
}, 0);
}, [savedCaretPosition]);
const clearSavedPosition = useCallback(() => {
setSavedCaretPosition(null);
}, []);
return {
saveCaretPosition: saveCaretPositionToState,
restoreCaretPosition: restoreCaretPositionFromState,
clearSavedPosition,
hasSavedPosition: !!savedCaretPosition,
};
};
// Utility function to temporarily disable text editor blur
export const temporarilyDisableTextEditorBlur = (
duration: number = 100,
): void => {
const textEditor = getTextEditor();
if (textEditor) {
const originalOnBlur = textEditor.onblur;
textEditor.onblur = null;
setTimeout(() => {
textEditor.onblur = originalOnBlur;
}, duration);
}
};

View File

@ -229,7 +229,6 @@ export { defaultLang, useI18n, languages } from "./i18n";
export {
restore,
restoreAppState,
restoreElement,
restoreElements,
restoreLibraryItems,
} from "./data/restore";
@ -282,7 +281,6 @@ 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 };

View File

@ -83,7 +83,7 @@
"@excalidraw/element": "0.18.0",
"@excalidraw/math": "0.18.0",
"@excalidraw/laser-pointer": "1.3.1",
"@excalidraw/mermaid-to-excalidraw": "1.1.3",
"@excalidraw/mermaid-to-excalidraw": "1.1.2",
"@excalidraw/random-username": "1.1.0",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-tabs": "1.1.3",

View File

@ -118,8 +118,7 @@ const renderLinearElementPointHighlight = (
) => {
const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
if (
appState.selectedLinearElement?.isEditing &&
appState.selectedLinearElement?.selectedPointsIndices?.includes(
appState.editingLinearElement?.selectedPointsIndices?.includes(
hoverPointIndex,
)
) {
@ -181,7 +180,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
point[0],
point[1],
(isOverlappingPoint
? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
? radius * (appState.editingLinearElement ? 1.5 : 2)
: radius) / appState.zoom.value,
!isPhantomPoint,
!isOverlappingPoint || isSelected,
@ -449,7 +448,7 @@ const renderLinearPointHandles = (
);
const { POINT_HANDLE_SIZE } = LinearElementEditor;
const radius = appState.selectedLinearElement?.isEditing
const radius = appState.editingLinearElement
? POINT_HANDLE_SIZE
: POINT_HANDLE_SIZE / 2;
@ -471,8 +470,7 @@ const renderLinearPointHandles = (
);
let isSelected =
!!appState.selectedLinearElement?.isEditing &&
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(idx);
!!appState.editingLinearElement?.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
@ -481,8 +479,7 @@ const renderLinearPointHandles = (
element.polygon &&
!isSelected &&
idx === element.points.length - 1 &&
!!appState.selectedLinearElement?.isEditing &&
!!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
) {
isSelected = true;
}
@ -538,7 +535,7 @@ const renderLinearPointHandles = (
);
midPoints.forEach((segmentMidPoint) => {
if (appState.selectedLinearElement?.isEditing || points.length === 2) {
if (appState.editingLinearElement || points.length === 2) {
renderSingleLinearPoint(
context,
appState,
@ -763,10 +760,7 @@ 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.selectedLinearElement?.isEditing &&
appState.selectedLinearElement.elementId === element.id
) {
if (appState.editingLinearElement?.elementId === element.id) {
if (element) {
editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
}
@ -859,8 +853,7 @@ const _renderInteractiveScene = ({
// correct element from visible elements
if (
selectedElements.length === 1 &&
appState.selectedLinearElement?.isEditing &&
appState.selectedLinearElement.elementId === selectedElements[0].id
appState.editingLinearElement?.elementId === selectedElements[0].id
) {
renderLinearPointHandles(
context,
@ -891,7 +884,7 @@ const _renderInteractiveScene = ({
}
// Paint selected elements
if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
if (!appState.multiElement && !appState.editingLinearElement) {
const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
const isSingleLinearElementSelected =

View File

@ -1,16 +1,9 @@
import { throttleRAF } from "@excalidraw/common";
import {
getTargetFrame,
isInvisiblySmallElement,
renderElement,
shouldApplyFrameClip,
} from "@excalidraw/element";
import { renderElement } from "@excalidraw/element";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { frameClip } from "./staticScene";
import type { NewElementSceneRenderConfig } from "../scene/types";
const _renderNewElementScene = ({
@ -36,37 +29,11 @@ const _renderNewElementScene = ({
normalizedHeight,
});
context.save();
// Apply zoom
context.save();
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,
@ -79,8 +46,6 @@ const _renderNewElementScene = ({
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.restore();
}
};

View File

@ -113,7 +113,7 @@ const strokeGrid = (
context.restore();
};
export const frameClip = (
const frameClip = (
frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,

View File

@ -13,7 +13,7 @@ import {
getDraggedElementsBounds,
getElementAbsoluteCoords,
} from "@excalidraw/element";
import { isBoundToContainer } from "@excalidraw/element";
import { isBoundToContainer, isFrameLikeElement } from "@excalidraw/element";
import { getMaximumGroups } from "@excalidraw/element";
@ -169,14 +169,8 @@ 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" || isLassoDragging) &&
app.state.activeTool.type !== "lasso" &&
((app.state.objectsSnapModeEnabled && !event[KEYS.CTRL_OR_CMD]) ||
(!app.state.objectsSnapModeEnabled &&
event[KEYS.CTRL_OR_CMD] &&
@ -317,13 +311,20 @@ const getReferenceElements = (
selectedElements: NonDeletedExcalidrawElement[],
appState: AppState,
elementsMap: ElementsMap,
) =>
getVisibleAndNonSelectedElements(
) => {
const selectedFrames = selectedElements
.filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id);
return getVisibleAndNonSelectedElements(
elements,
selectedElements,
appState,
elementsMap,
).filter(
(element) => !(element.frameId && selectedFrames.includes(element.frameId)),
);
};
export const getVisibleGaps = (
elements: readonly NonDeletedExcalidrawElement[],

View File

@ -908,6 +908,7 @@ 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,
@ -981,7 +982,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -1106,6 +1106,7 @@ 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,
@ -1173,7 +1174,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": {
@ -1319,6 +1319,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@ -1386,7 +1387,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -1649,6 +1649,7 @@ 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,
@ -1716,7 +1717,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -1979,6 +1979,7 @@ 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,
@ -2046,7 +2047,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": {
@ -2192,6 +2192,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@ -2257,7 +2258,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -2432,6 +2432,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@ -2499,7 +2500,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -2729,6 +2729,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@ -2801,7 +2802,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -3100,6 +3100,7 @@ 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,
@ -3167,7 +3168,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": {
@ -3592,6 +3592,7 @@ 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,
@ -3659,7 +3660,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -3692,14 +3692,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 400692809,
"seed": 1116226695,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 4,
"versionNonce": 81784553,
"versionNonce": 23633383,
"width": 20,
"x": 20,
"y": 30,
@ -3724,14 +3724,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"opacity": 100,
"roughness": 1,
"roundness": null,
"seed": 449462985,
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 1150084233,
"versionNonce": 401146281,
"width": 20,
"x": -10,
"y": 0,
@ -3914,6 +3914,7 @@ 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,
@ -3981,7 +3982,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -4236,6 +4236,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"defaultSidebarDockedPreference": false,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"editingTextElement": null,
"elementsToHighlight": null,
"errorMessage": null,
@ -4306,7 +4307,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -5520,6 +5520,7 @@ 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,
@ -5590,7 +5591,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -6736,6 +6736,7 @@ 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,
@ -6808,7 +6809,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -7670,6 +7670,7 @@ 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,
@ -7738,7 +7739,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -8669,6 +8669,7 @@ 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,
@ -8736,7 +8737,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,
@ -9659,6 +9659,7 @@ 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,
@ -9729,7 +9730,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"open": false,
"panels": 3,
},
"stylesPanelMode": "full",
"suggestedBindings": [],
"theme": "light",
"toast": null,

View File

@ -15,11 +15,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Click me
</span>
Click me
</div>
</button>
<a
@ -31,11 +27,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Excalidraw blog
</span>
Excalidraw blog
</div>
</a>
<div
@ -96,11 +88,7 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
@ -150,11 +138,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Open
</span>
Open
</div>
<div
class="dropdown-menu-item__shortcut"
@ -191,11 +175,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Save to...
</span>
Save to...
</div>
</button>
<button
@ -251,11 +231,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Export image...
</span>
Export image...
</div>
<div
class="dropdown-menu-item__shortcut"
@ -304,11 +280,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Find on canvas
</span>
Find on canvas
</div>
<div
class="dropdown-menu-item__shortcut"
@ -365,11 +337,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Help
</span>
Help
</div>
<div
class="dropdown-menu-item__shortcut"
@ -406,11 +374,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Reset the canvas
</span>
Reset the canvas
</div>
</button>
<div
@ -455,11 +419,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
GitHub
</span>
GitHub
</div>
</a>
<a
@ -505,11 +465,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Follow us
</span>
Follow us
</div>
</a>
<a
@ -549,11 +505,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Discord chat
</span>
Discord chat
</div>
</a>
</div>
@ -590,11 +542,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
<div
class="dropdown-menu-item__text"
>
<span
style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
>
Dark mode
</span>
Dark mode
</div>
<div
class="dropdown-menu-item__shortcut"
@ -688,7 +636,6 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color properties-trigger has-outline"
data-openpopup="canvasBackground"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"

File diff suppressed because it is too large Load Diff

View File

@ -35,23 +35,20 @@ describe("appState", () => {
expect(h.state.viewBackgroundColor).toBe("#F00");
});
await API.drop([
{
kind: "file",
file: new Blob(
[
JSON.stringify({
type: EXPORT_DATA_TYPES.excalidraw,
appState: {
viewBackgroundColor: "#000",
},
elements: [API.createElement({ type: "rectangle", id: "A" })],
}),
],
{ type: MIME_TYPES.json },
),
},
]);
await API.drop(
new Blob(
[
JSON.stringify({
type: EXPORT_DATA_TYPES.excalidraw,
appState: {
viewBackgroundColor: "#000",
},
elements: [API.createElement({ type: "rectangle", id: "A" })],
}),
],
{ type: MIME_TYPES.json },
),
);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);

View File

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

View File

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

View File

@ -57,7 +57,7 @@ describe("export", () => {
blob: pngBlob,
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
});
await API.drop([{ kind: "file", file: pngBlobEmbedded }]);
await API.drop(pngBlobEmbedded);
await waitFor(() => {
expect(h.elements).toEqual([
@ -94,12 +94,7 @@ describe("export", () => {
});
it("import embedded png (legacy v1)", async () => {
await API.drop([
{
kind: "file",
file: await API.loadFile("./fixtures/test_embedded_v1.png"),
},
]);
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "test" }),
@ -108,12 +103,7 @@ describe("export", () => {
});
it("import embedded png (v2)", async () => {
await API.drop([
{
kind: "file",
file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
},
]);
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }),
@ -122,12 +112,7 @@ describe("export", () => {
});
it("import embedded svg (legacy v1)", async () => {
await API.drop([
{
kind: "file",
file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
},
]);
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "test" }),
@ -136,12 +121,7 @@ describe("export", () => {
});
it("import embedded svg (v2)", async () => {
await API.drop([
{
kind: "file",
file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
},
]);
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }),

View File

@ -1,9 +0,0 @@
export const DEER_IMAGE_DIMENSIONS = {
width: 318,
height: 335,
};
export const SMILEY_IMAGE_DIMENSIONS = {
width: 56,
height: 77,
};

View File

@ -25,7 +25,6 @@ import { Excalidraw } from "../index";
// Importing to spy on it and mock the implementation (mocking does not work with simple vi.mock for some reason)
import * as blobModule from "../data/blob";
import { SMILEY_IMAGE_DIMENSIONS } from "./fixtures/constants";
import { API } from "./helpers/api";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import {
@ -745,6 +744,11 @@ describe("freedraw", () => {
//image
//TODO: currently there is no test for pixel colors at flipped positions.
describe("image", () => {
const smileyImageDimensions = {
width: 56,
height: 77,
};
beforeEach(() => {
// it's necessary to specify the height in order to calculate natural dimensions of the image
h.state.height = 1000;
@ -752,8 +756,8 @@ describe("image", () => {
beforeAll(() => {
mockHTMLImageElement(
SMILEY_IMAGE_DIMENSIONS.width,
SMILEY_IMAGE_DIMENSIONS.height,
smileyImageDimensions.width,
smileyImageDimensions.height,
);
});

View File

@ -478,43 +478,33 @@ export class API {
});
};
static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
static drop = async (blob: Blob) => {
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
const text = await new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.readAsText(blob);
} catch (error: any) {
reject(error);
}
});
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
const files = [blob] as File[] & { item: (index: number) => File };
files.item = (index: number) => files[index];
Object.defineProperty(fileDropEvent, "dataTransfer", {
value: {
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
files,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
items: items.map((item, idx) => {
if (item.kind === "string") {
return {
kind: "string",
type: item.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
getAsString: (cb: (text: string) => any) => cb(item.value),
};
}
return {
kind: "file",
type: item.type || item.file.type,
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
getAsFile: () => item.file,
};
}),
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
getData: (type: string) => {
return items.find((item) => item.type === "string" && item.type === type) || "";
if (type === blob.type || type === "text") {
return text;
}
return "";
},
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
types: [blob.type],
},
});
Object.defineProperty(fileDropEvent, "clientX", {
@ -523,7 +513,7 @@ export class API {
Object.defineProperty(fileDropEvent, "clientY", {
value: 0,
});
await fireEvent(GlobalTestState.interactiveCanvas, fileDropEvent);
};

View File

@ -1,6 +0,0 @@
export const INITIALIZED_IMAGE_PROPS = {
type: "image",
fileId: expect.any(String),
x: expect.toBeNonNaNNumber(),
y: expect.toBeNonNaNNumber(),
};

View File

@ -58,35 +58,3 @@ export const mockHTMLImageElement = (
},
);
};
// Mocks for multiple HTMLImageElements (dimensions are assigned in the order of image initialization)
export const mockMultipleHTMLImageElements = (
sizes: (readonly [number, number])[],
) => {
const _sizes = [...sizes];
vi.stubGlobal(
"Image",
class extends Image {
constructor() {
super();
const size = _sizes.shift();
if (!size) {
throw new Error("Insufficient sizes");
}
Object.defineProperty(this, "naturalWidth", {
value: size[0],
});
Object.defineProperty(this, "naturalHeight", {
value: size[1],
});
queueMicrotask(() => {
this.onload?.({} as Event);
});
}
},
);
};

View File

@ -47,43 +47,42 @@ class DataTransferItem {
}
}
class DataTransferItemList extends Array<DataTransferItem> {
class DataTransferList {
items: DataTransferItem[] = [];
add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.push(new DataTransferItem("string", type, data));
this.items.push(new DataTransferItem("string", type, data));
} else if (data instanceof File) {
this.push(new DataTransferItem("file", type, data));
this.items.push(new DataTransferItem("file", type, data));
}
}
clear(): void {
this.clear();
this.items = [];
}
}
class DataTransfer {
public items: DataTransferItemList = new DataTransferItemList();
public items: DataTransferList = new DataTransferList();
private _types: Record<string, string> = {};
get files() {
return this.items
return this.items.items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile()!);
}
add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.items.add(data, type);
} else {
this.items.add(data);
}
this.items.add(data, type);
}
setData(type: string, value: string) {
this.items.add(value, type);
this._types[type] = value;
}
getData(type: string) {
return this.items.find((item) => item.type === type)?.data || "";
return this._types[type] || "";
}
}

Some files were not shown because too many files have changed in this diff Show More