Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd3a077bf | ||
|
|
069982606d | ||
|
|
b324a85ab1 | ||
|
|
a83ac48853 | ||
|
|
0cf56b19c7 | ||
|
|
61fe15a51d | ||
|
|
647a264a48 | ||
|
|
b6d80e4256 | ||
|
|
3372149277 |
+1
-10
@@ -22,7 +22,6 @@ import Trans from "@excalidraw/excalidraw/components/Trans";
|
|||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
EVENT,
|
EVENT,
|
||||||
THEME,
|
|
||||||
VERSION_TIMEOUT,
|
VERSION_TIMEOUT,
|
||||||
debounce,
|
debounce,
|
||||||
getVersion,
|
getVersion,
|
||||||
@@ -952,6 +951,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
handleKeyboardGlobally={true}
|
handleKeyboardGlobally={true}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
theme={editorTheme}
|
theme={editorTheme}
|
||||||
|
onThemeChange={setAppTheme}
|
||||||
renderTopRightUI={(isMobile) => {
|
renderTopRightUI={(isMobile) => {
|
||||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||||
return null;
|
return null;
|
||||||
@@ -988,7 +988,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
isCollaborating={isCollaborating}
|
isCollaborating={isCollaborating}
|
||||||
isCollabEnabled={!isCollabDisabled}
|
isCollabEnabled={!isCollabDisabled}
|
||||||
theme={appTheme}
|
theme={appTheme}
|
||||||
setTheme={(theme) => setAppTheme(theme)}
|
|
||||||
refresh={() => forceRefresh((prev) => !prev)}
|
refresh={() => forceRefresh((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
<AppWelcomeScreen
|
<AppWelcomeScreen
|
||||||
@@ -1229,14 +1228,6 @@ const ExcalidrawWrapper = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
...CommandPalette.defaultItems.toggleTheme,
|
|
||||||
perform: () => {
|
|
||||||
setAppTheme(
|
|
||||||
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t("labels.installPWA"),
|
label: t("labels.installPWA"),
|
||||||
category: DEFAULT_CATEGORIES.app,
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const AppMainMenu: React.FC<{
|
|||||||
isCollaborating: boolean;
|
isCollaborating: boolean;
|
||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
theme: Theme | "system";
|
theme: Theme | "system";
|
||||||
setTheme: (theme: Theme | "system") => void;
|
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
return (
|
return (
|
||||||
@@ -78,11 +77,7 @@ export const AppMainMenu: React.FC<{
|
|||||||
)}
|
)}
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.Preferences />
|
<MainMenu.DefaultItems.Preferences />
|
||||||
<MainMenu.DefaultItems.ToggleTheme
|
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme theme={props.theme} />
|
||||||
allowSystemTheme
|
|
||||||
theme={props.theme}
|
|
||||||
onSelect={props.setTheme}
|
|
||||||
/>
|
|
||||||
<MainMenu.ItemCustom>
|
<MainMenu.ItemCustom>
|
||||||
<LanguageList style={{ width: "100%" }} />
|
<LanguageList style={{ width: "100%" }} />
|
||||||
</MainMenu.ItemCustom>
|
</MainMenu.ItemCustom>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { THEME } from "@excalidraw/excalidraw";
|
import { THEME } from "@excalidraw/excalidraw";
|
||||||
import { EVENT, CODES, KEYS } from "@excalidraw/common";
|
|
||||||
import { useEffect, useLayoutEffect, useState } from "react";
|
import { useEffect, useLayoutEffect, useState } from "react";
|
||||||
|
|
||||||
import type { Theme } from "@excalidraw/element/types";
|
import type { Theme } from "@excalidraw/element/types";
|
||||||
@@ -31,28 +30,10 @@ export const useHandleAppTheme = () => {
|
|||||||
mediaQuery?.addEventListener("change", handleChange);
|
mediaQuery?.addEventListener("change", handleChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
!event[KEYS.CTRL_OR_CMD] &&
|
|
||||||
event.altKey &&
|
|
||||||
event.shiftKey &&
|
|
||||||
event.code === CODES.D
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery?.removeEventListener("change", handleChange);
|
mediaQuery?.removeEventListener("change", handleChange);
|
||||||
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}, [appTheme, editorTheme, setAppTheme]);
|
}, [appTheme]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ const cssInvert = (
|
|||||||
return { r: invertedR, g: invertedG, b: invertedB };
|
return { r: invertedR, g: invertedG, b: invertedB };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const applyDarkModeFilter = (color: string): string => {
|
export const applyDarkModeFilter = (color: string, enable = true): string => {
|
||||||
|
if (!enable) {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
const cached = DARK_MODE_COLORS_CACHE?.get(color);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
pointFrom,
|
|
||||||
pointFromPair,
|
pointFromPair,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
@@ -70,12 +69,12 @@ export const getGridPoint = (
|
|||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
gridSize: NullableGridSize,
|
gridSize: NullableGridSize,
|
||||||
): GlobalPoint => {
|
): [number, number] => {
|
||||||
if (gridSize) {
|
if (gridSize) {
|
||||||
return pointFrom<GlobalPoint>(
|
return [
|
||||||
Math.round(x / gridSize) * gridSize,
|
Math.round(x / gridSize) * gridSize,
|
||||||
Math.round(y / gridSize) * gridSize,
|
Math.round(y / gridSize) * gridSize,
|
||||||
);
|
];
|
||||||
}
|
}
|
||||||
return pointFrom<GlobalPoint>(x, y);
|
return [x, y];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { BinaryHeap } from "../src/binary-heap";
|
||||||
|
|
||||||
|
describe("BinaryHeap", () => {
|
||||||
|
const numberHeap = () => new BinaryHeap<number>((n) => n);
|
||||||
|
|
||||||
|
const drain = (heap: BinaryHeap<number>) => {
|
||||||
|
const out: number[] = [];
|
||||||
|
while (heap.size() > 0) {
|
||||||
|
out.push(heap.pop()!);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("empty heap", () => {
|
||||||
|
it("has size 0", () => {
|
||||||
|
expect(numberHeap().size()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pop() returns null", () => {
|
||||||
|
expect(numberHeap().pop()).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remove() is a no-op and does not throw", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
expect(() => heap.remove(1)).not.toThrow();
|
||||||
|
expect(heap.size()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("push / pop", () => {
|
||||||
|
it("tracks size as items are added and removed", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
[3, 1, 2].forEach((n) => heap.push(n));
|
||||||
|
expect(heap.size()).toBe(3);
|
||||||
|
|
||||||
|
heap.pop();
|
||||||
|
expect(heap.size()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops a single pushed element back out", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
heap.push(42);
|
||||||
|
expect(heap.pop()).toBe(42);
|
||||||
|
expect(heap.pop()).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always pops the smallest score first", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
[5, 3, 8, 1, 9, 2, 7].forEach((n) => heap.push(n));
|
||||||
|
expect(drain(heap)).toEqual([1, 2, 3, 5, 7, 8, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles duplicate scores", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
[4, 1, 4, 1, 2].forEach((n) => heap.push(n));
|
||||||
|
expect(drain(heap)).toEqual([1, 1, 2, 4, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains the heap invariant for a large adversarial (reverse-sorted) input", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
// pushing in descending order forces a sift-up on every insert
|
||||||
|
const input = Array.from({ length: 1000 }, (_, i) => 1000 - i);
|
||||||
|
input.forEach((n) => heap.push(n));
|
||||||
|
expect(drain(heap)).toEqual([...input].sort((a, b) => a - b));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("removes an interior element and keeps the rest ordered", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
|
||||||
|
|
||||||
|
heap.remove(8);
|
||||||
|
|
||||||
|
expect(heap.size()).toBe(4);
|
||||||
|
expect(drain(heap)).toEqual([1, 3, 5, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can remove the current minimum", () => {
|
||||||
|
const heap = numberHeap();
|
||||||
|
[5, 3, 8, 1, 9].forEach((n) => heap.push(n));
|
||||||
|
|
||||||
|
heap.remove(1);
|
||||||
|
|
||||||
|
expect(heap.size()).toBe(4);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rescoreElement", () => {
|
||||||
|
type Node = { id: string; f: number };
|
||||||
|
|
||||||
|
it("re-sorts a node after its score is lowered", () => {
|
||||||
|
const heap = new BinaryHeap<Node>((node) => node.f);
|
||||||
|
|
||||||
|
const a = { id: "a", f: 10 };
|
||||||
|
const b = { id: "b", f: 20 };
|
||||||
|
const c = { id: "c", f: 30 };
|
||||||
|
[a, b, c].forEach((node) => heap.push(node));
|
||||||
|
|
||||||
|
c.f = 5;
|
||||||
|
heap.rescoreElement(c);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(c);
|
||||||
|
expect(heap.pop()).toBe(a);
|
||||||
|
expect(heap.pop()).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
getFeatureFlag,
|
getFeatureFlag,
|
||||||
getGridPoint,
|
|
||||||
invariant,
|
invariant,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
@@ -23,7 +22,7 @@ import {
|
|||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
|
import type { LineSegment, LocalPoint, Radians } from "@excalidraw/math";
|
||||||
import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
|
||||||
import type { Bounds } from "@excalidraw/common";
|
import type { Bounds } from "@excalidraw/common";
|
||||||
|
|
||||||
@@ -155,7 +154,6 @@ export const bindOrUnbindBindingElement = (
|
|||||||
altKey?: boolean;
|
altKey?: boolean;
|
||||||
angleLocked?: boolean;
|
angleLocked?: boolean;
|
||||||
initialBinding?: boolean;
|
initialBinding?: boolean;
|
||||||
gridSize?: NullableGridSize;
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
|
const { start, end } = getBindingStrategyForDraggingBindingElementEndpoints(
|
||||||
@@ -172,16 +170,12 @@ export const bindOrUnbindBindingElement = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isMidpointSnappingEnabled =
|
|
||||||
appState.isMidpointSnappingEnabled && !appState.gridModeEnabled;
|
|
||||||
|
|
||||||
bindOrUnbindBindingElementEdge(
|
bindOrUnbindBindingElementEdge(
|
||||||
arrow,
|
arrow,
|
||||||
start,
|
start,
|
||||||
"start",
|
"start",
|
||||||
scene,
|
scene,
|
||||||
appState.isBindingEnabled,
|
appState.isBindingEnabled,
|
||||||
isMidpointSnappingEnabled,
|
|
||||||
);
|
);
|
||||||
bindOrUnbindBindingElementEdge(
|
bindOrUnbindBindingElementEdge(
|
||||||
arrow,
|
arrow,
|
||||||
@@ -189,7 +183,6 @@ export const bindOrUnbindBindingElement = (
|
|||||||
"end",
|
"end",
|
||||||
scene,
|
scene,
|
||||||
appState.isBindingEnabled,
|
appState.isBindingEnabled,
|
||||||
isMidpointSnappingEnabled,
|
|
||||||
);
|
);
|
||||||
if (start.focusPoint || end.focusPoint) {
|
if (start.focusPoint || end.focusPoint) {
|
||||||
// If the strategy dictates a focus point override, then
|
// If the strategy dictates a focus point override, then
|
||||||
@@ -234,7 +227,6 @@ const bindOrUnbindBindingElementEdge = (
|
|||||||
startOrEnd: "start" | "end",
|
startOrEnd: "start" | "end",
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
shouldSnapToOutline = true,
|
shouldSnapToOutline = true,
|
||||||
isMidpointSnappingEnabled = true,
|
|
||||||
): void => {
|
): void => {
|
||||||
if (mode === null) {
|
if (mode === null) {
|
||||||
// null means break the binding
|
// null means break the binding
|
||||||
@@ -248,7 +240,6 @@ const bindOrUnbindBindingElementEdge = (
|
|||||||
scene,
|
scene,
|
||||||
focusPoint,
|
focusPoint,
|
||||||
shouldSnapToOutline,
|
shouldSnapToOutline,
|
||||||
isMidpointSnappingEnabled,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -602,7 +593,6 @@ export const getBindingStrategyForDraggingBindingElementEndpoints = (
|
|||||||
finalize?: boolean;
|
finalize?: boolean;
|
||||||
initialBinding?: boolean;
|
initialBinding?: boolean;
|
||||||
zoom?: AppState["zoom"];
|
zoom?: AppState["zoom"];
|
||||||
gridSize?: NullableGridSize;
|
|
||||||
},
|
},
|
||||||
): { start: BindingStrategy; end: BindingStrategy } => {
|
): { start: BindingStrategy; end: BindingStrategy } => {
|
||||||
if (getFeatureFlag("COMPLEX_BINDINGS")) {
|
if (getFeatureFlag("COMPLEX_BINDINGS")) {
|
||||||
@@ -643,7 +633,6 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
|||||||
finalize?: boolean;
|
finalize?: boolean;
|
||||||
initialBinding?: boolean;
|
initialBinding?: boolean;
|
||||||
zoom?: AppState["zoom"];
|
zoom?: AppState["zoom"];
|
||||||
gridSize?: NullableGridSize;
|
|
||||||
},
|
},
|
||||||
): { start: BindingStrategy; end: BindingStrategy } => {
|
): { start: BindingStrategy; end: BindingStrategy } => {
|
||||||
const startIdx = 0;
|
const startIdx = 0;
|
||||||
@@ -706,9 +695,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
|||||||
elementsMap,
|
elementsMap,
|
||||||
);
|
);
|
||||||
const hit = getHoveredElementForBinding(
|
const hit = getHoveredElementForBinding(
|
||||||
opts?.angleLocked || appState.gridModeEnabled
|
globalPoint,
|
||||||
? pointFrom<GlobalPoint>(scenePointerX, scenePointerY)
|
|
||||||
: globalPoint,
|
|
||||||
elements,
|
elements,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
maxBindingDistance_simple(appState.zoom),
|
maxBindingDistance_simple(appState.zoom),
|
||||||
@@ -760,11 +747,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
|||||||
? globalPoint
|
? globalPoint
|
||||||
: // NOTE: Can only affect the start point because new arrows always drag the end point
|
: // NOTE: Can only affect the start point because new arrows always drag the end point
|
||||||
opts?.newArrow
|
opts?.newArrow
|
||||||
? getGridPoint(
|
? appState.selectedLinearElement!.initialState.origin!
|
||||||
appState.selectedLinearElement!.initialState.origin![0],
|
|
||||||
appState.selectedLinearElement!.initialState.origin![1],
|
|
||||||
opts.gridSize as NullableGridSize,
|
|
||||||
)
|
|
||||||
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
: LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||||
arrow,
|
arrow,
|
||||||
0,
|
0,
|
||||||
@@ -823,27 +806,12 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
|||||||
focusPoint:
|
focusPoint:
|
||||||
projectFixedPointOntoDiagonal(
|
projectFixedPointOntoDiagonal(
|
||||||
arrow,
|
arrow,
|
||||||
opts?.angleLocked || appState.gridModeEnabled
|
globalPoint,
|
||||||
? snapBoundPointToGrid(
|
|
||||||
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
|
|
||||||
hit,
|
|
||||||
elementsMap,
|
|
||||||
appState.gridSize as NullableGridSize,
|
|
||||||
arrow,
|
|
||||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
|
||||||
arrow,
|
|
||||||
startDragged ? 1 : -2,
|
|
||||||
elementsMap,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: globalPoint,
|
|
||||||
hit,
|
hit,
|
||||||
startDragged ? "start" : "end",
|
startDragged ? "start" : "end",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
appState.isMidpointSnappingEnabled &&
|
appState.isMidpointSnappingEnabled,
|
||||||
!opts?.angleLocked &&
|
|
||||||
!appState.gridModeEnabled,
|
|
||||||
) || globalPoint,
|
) || globalPoint,
|
||||||
}
|
}
|
||||||
: { mode: null };
|
: { mode: null };
|
||||||
@@ -888,7 +856,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
|
|||||||
startDragged ? "end" : "start",
|
startDragged ? "end" : "start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
appState.zoom,
|
appState.zoom,
|
||||||
false,
|
appState.isMidpointSnappingEnabled,
|
||||||
) || otherEndpoint,
|
) || otherEndpoint,
|
||||||
}
|
}
|
||||||
: { mode: undefined }
|
: { mode: undefined }
|
||||||
@@ -1053,7 +1021,6 @@ export const bindBindingElement = (
|
|||||||
scene: Scene,
|
scene: Scene,
|
||||||
focusPoint?: GlobalPoint,
|
focusPoint?: GlobalPoint,
|
||||||
shouldSnapToOutline = true,
|
shouldSnapToOutline = true,
|
||||||
isMidpointSnappingEnabled = true,
|
|
||||||
): void => {
|
): void => {
|
||||||
const elementsMap = scene.getNonDeletedElementsMap();
|
const elementsMap = scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
@@ -1069,7 +1036,6 @@ export const bindBindingElement = (
|
|||||||
startOrEnd,
|
startOrEnd,
|
||||||
elementsMap,
|
elementsMap,
|
||||||
shouldSnapToOutline,
|
shouldSnapToOutline,
|
||||||
isMidpointSnappingEnabled,
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -1774,92 +1740,6 @@ const extractBinding = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Snaps a bound arrow endpoint to the grid on the axis parallel to the
|
|
||||||
* bindable element's side, while preserving the binding gap distance on the
|
|
||||||
* perpendicular axis. In other words, the grid axis closest to the side's
|
|
||||||
* perpendicular (normal) is used as the snap axis and the other axis is kept at
|
|
||||||
* the binding gap distance.
|
|
||||||
*/
|
|
||||||
const snapBoundPointToGrid = (
|
|
||||||
outlinePoint: GlobalPoint,
|
|
||||||
bindableElement: ExcalidrawBindableElement,
|
|
||||||
elementsMap: ElementsMap,
|
|
||||||
gridSize: NullableGridSize,
|
|
||||||
arrowElement: ExcalidrawArrowElement,
|
|
||||||
adjacentPoint?: GlobalPoint,
|
|
||||||
): GlobalPoint => {
|
|
||||||
if (!gridSize) {
|
|
||||||
return outlinePoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aabb = aabbForElement(bindableElement, elementsMap);
|
|
||||||
// For ellipses and diamonds use the arrow's incoming direction instead of
|
|
||||||
// the position-based heading, which can give the wrong axis when the
|
|
||||||
// outline point is near a cardinal zone or an angled diamond face.
|
|
||||||
const heading =
|
|
||||||
adjacentPoint &&
|
|
||||||
(bindableElement.type === "ellipse" || bindableElement.type === "diamond")
|
|
||||||
? vectorToHeading(vectorFromPoint(adjacentPoint, outlinePoint))
|
|
||||||
: headingForPointFromElement(bindableElement, aabb, outlinePoint);
|
|
||||||
|
|
||||||
const normalLocal = pointFrom<GlobalPoint>(heading[0], heading[1]);
|
|
||||||
const normalGlobal = pointRotateRads(
|
|
||||||
normalLocal,
|
|
||||||
pointFrom<GlobalPoint>(0, 0),
|
|
||||||
bindableElement.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bindingGap = getBindingGap(bindableElement, arrowElement);
|
|
||||||
const extent =
|
|
||||||
Math.max(bindableElement.width, bindableElement.height) + bindingGap * 2;
|
|
||||||
const center = getCenterForBounds(aabb);
|
|
||||||
|
|
||||||
const absNX = Math.abs(normalGlobal[0]);
|
|
||||||
const absNY = Math.abs(normalGlobal[1]);
|
|
||||||
if (absNX >= absNY) {
|
|
||||||
// Global X is closest to the perpendicular so snap Y, intersect horizontal line
|
|
||||||
const [, snappedY] = getGridPoint(
|
|
||||||
outlinePoint[0],
|
|
||||||
outlinePoint[1],
|
|
||||||
gridSize,
|
|
||||||
);
|
|
||||||
const intersector = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(center[0] - extent, snappedY),
|
|
||||||
pointFrom<GlobalPoint>(center[0] + extent, snappedY),
|
|
||||||
);
|
|
||||||
const intersection = intersectElementWithLineSegment(
|
|
||||||
bindableElement,
|
|
||||||
elementsMap,
|
|
||||||
intersector,
|
|
||||||
bindingGap,
|
|
||||||
).sort(
|
|
||||||
(a, b) =>
|
|
||||||
pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return intersection ?? pointFrom<GlobalPoint>(outlinePoint[0], snappedY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global Y is closest to the perpendicular so snap X, intersect vertical line
|
|
||||||
const [snappedX] = getGridPoint(outlinePoint[0], outlinePoint[1], gridSize);
|
|
||||||
const intersector = lineSegment<GlobalPoint>(
|
|
||||||
pointFrom<GlobalPoint>(snappedX, center[1] - extent),
|
|
||||||
pointFrom<GlobalPoint>(snappedX, center[1] + extent),
|
|
||||||
);
|
|
||||||
const intersection = intersectElementWithLineSegment(
|
|
||||||
bindableElement,
|
|
||||||
elementsMap,
|
|
||||||
intersector,
|
|
||||||
bindingGap,
|
|
||||||
).sort(
|
|
||||||
(a, b) =>
|
|
||||||
pointDistanceSq(a, outlinePoint) - pointDistanceSq(b, outlinePoint),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
return intersection ?? pointFrom<GlobalPoint>(snappedX, outlinePoint[1]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const elementArea = (element: ExcalidrawBindableElement) =>
|
const elementArea = (element: ExcalidrawBindableElement) =>
|
||||||
element.width * element.height;
|
element.width * element.height;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
type Bounds,
|
type Bounds,
|
||||||
@@ -7,7 +6,6 @@ import {
|
|||||||
rescalePoints,
|
rescalePoints,
|
||||||
sizeOf,
|
sizeOf,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
degreesToRadians,
|
degreesToRadians,
|
||||||
lineSegment,
|
lineSegment,
|
||||||
@@ -16,9 +14,7 @@ import {
|
|||||||
pointFromArray,
|
pointFromArray,
|
||||||
pointRotateRads,
|
pointRotateRads,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
import { getCurvePathOps } from "@excalidraw/utils/shape";
|
||||||
|
|
||||||
import { pointsOnBezierCurves } from "points-on-curve";
|
import { pointsOnBezierCurves } from "points-on-curve";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -29,9 +25,7 @@ import type {
|
|||||||
LocalPoint,
|
LocalPoint,
|
||||||
Radians,
|
Radians,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import type { Mutable } from "@excalidraw/common/utility-types";
|
import type { Mutable } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
import { generateRoughOptions } from "./shape";
|
import { generateRoughOptions } from "./shape";
|
||||||
@@ -41,18 +35,20 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isLineElement,
|
isLineElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
|
isExcalidrawElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
|
|
||||||
import { getElementShape } from "./shape";
|
import { getElementShape } from "./shape";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deconstructDiamondElement,
|
deconstructDiamondElement,
|
||||||
deconstructRectanguloidElement,
|
deconstructRectanguloidElement,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { intersectElementWithLineSegment } from "./collision";
|
||||||
|
import { elementOverlapsWithFrame, getContainingFrame } from "./frame";
|
||||||
|
|
||||||
import type { Drawable, Op } from "roughjs/bin/core";
|
import type { Drawable, Op } from "roughjs/bin/core";
|
||||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
@@ -67,6 +63,7 @@ import type {
|
|||||||
ExcalidrawRectanguloidElement,
|
ExcalidrawRectanguloidElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export type RectangleBox = {
|
export type RectangleBox = {
|
||||||
@@ -1295,6 +1292,295 @@ export const boundsContainBounds = (outerBounds: Bounds, innerBounds: Bounds) =>
|
|||||||
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
pointFrom<GlobalPoint>(innerBounds[2], innerBounds[3]),
|
||||||
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
].every((point) => pointInsideBoundsInclusive(point, outerBounds));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High level helper to get elements overlapping a bounding box.
|
||||||
|
* It can be used to get elements overlapping a selection box, for example.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const elementsOverlappingBBox = ({
|
||||||
|
elements,
|
||||||
|
elementsMap,
|
||||||
|
bounds,
|
||||||
|
type,
|
||||||
|
excludeElementsInFrames,
|
||||||
|
shouldIgnoreElementFromSelection,
|
||||||
|
}: {
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
elementsMap?: ElementsMap;
|
||||||
|
bounds: Bounds | ExcalidrawElement;
|
||||||
|
/**
|
||||||
|
* - overlap: elements overlapping or inside bounds
|
||||||
|
* - contain: elements inside bounds
|
||||||
|
**/
|
||||||
|
type: "contain" | "overlap";
|
||||||
|
excludeElementsInFrames?: boolean;
|
||||||
|
shouldIgnoreElementFromSelection?: (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
) => boolean;
|
||||||
|
}) => {
|
||||||
|
if (!elementsMap) {
|
||||||
|
elementsMap = arrayToMap(elements) as ElementsMap;
|
||||||
|
}
|
||||||
|
const selectionBounds = isExcalidrawElement(bounds)
|
||||||
|
? getElementBounds(bounds, elementsMap)
|
||||||
|
: bounds;
|
||||||
|
const [selectionX1, selectionY1, selectionX2, selectionY2] = selectionBounds;
|
||||||
|
const selectionEdges = [
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX1, selectionY1),
|
||||||
|
pointFrom(selectionX2, selectionY1),
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX2, selectionY1),
|
||||||
|
pointFrom(selectionX2, selectionY2),
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX2, selectionY2),
|
||||||
|
pointFrom(selectionX1, selectionY2),
|
||||||
|
),
|
||||||
|
lineSegment<GlobalPoint>(
|
||||||
|
pointFrom(selectionX1, selectionY2),
|
||||||
|
pointFrom(selectionX1, selectionY1),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const framesInSelection = excludeElementsInFrames
|
||||||
|
? new Set<NonDeletedExcalidrawElement["id"]>()
|
||||||
|
: null;
|
||||||
|
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
|
||||||
|
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
if (shouldIgnoreElementFromSelection?.(element)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track only selectable top-level group members, so ignored elements such
|
||||||
|
// as bound text and locked elements don't affect group selection.
|
||||||
|
const groupId = element.groupIds.at(-1);
|
||||||
|
if (groupId) {
|
||||||
|
if (!groups[groupId]) {
|
||||||
|
groups[groupId] = [];
|
||||||
|
}
|
||||||
|
groups[groupId].push(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strokeWidth = element.strokeWidth;
|
||||||
|
let labelAABB: Bounds | null = null;
|
||||||
|
let elementAABB = getElementBounds(element, elementsMap);
|
||||||
|
|
||||||
|
elementAABB = [
|
||||||
|
elementAABB[0] - strokeWidth / 2,
|
||||||
|
elementAABB[1] - strokeWidth / 2,
|
||||||
|
elementAABB[2] + strokeWidth / 2,
|
||||||
|
elementAABB[3] + strokeWidth / 2,
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
// Whether the element bounds should include the bound text element bounds
|
||||||
|
const boundTextElement =
|
||||||
|
isArrowElement(element) && getBoundTextElement(element, elementsMap);
|
||||||
|
if (boundTextElement) {
|
||||||
|
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
||||||
|
element,
|
||||||
|
boundTextElement,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
labelAABB = [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
x + boundTextElement.width,
|
||||||
|
y + boundTextElement.height,
|
||||||
|
] as Bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clip element bounds by its containing frame (if any), since only the
|
||||||
|
// visible (frame-clipped) portion of the element is relevant for selection.
|
||||||
|
const associatedFrame = getContainingFrame(element, elementsMap);
|
||||||
|
if (
|
||||||
|
associatedFrame &&
|
||||||
|
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
|
||||||
|
) {
|
||||||
|
const frameAABB = getElementBounds(associatedFrame, elementsMap);
|
||||||
|
elementAABB = [
|
||||||
|
Math.max(elementAABB[0], frameAABB[0]),
|
||||||
|
Math.max(elementAABB[1], frameAABB[1]),
|
||||||
|
Math.min(elementAABB[2], frameAABB[2]),
|
||||||
|
Math.min(elementAABB[3], frameAABB[3]),
|
||||||
|
] as Bounds;
|
||||||
|
|
||||||
|
labelAABB = labelAABB
|
||||||
|
? ([
|
||||||
|
Math.max(labelAABB[0], frameAABB[0]),
|
||||||
|
Math.max(labelAABB[1], frameAABB[1]),
|
||||||
|
Math.min(labelAABB[2], frameAABB[2]),
|
||||||
|
Math.min(labelAABB[3], frameAABB[3]),
|
||||||
|
] as Bounds)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonAABB = labelAABB
|
||||||
|
? ([
|
||||||
|
Math.min(labelAABB[0], elementAABB[0]),
|
||||||
|
Math.min(labelAABB[1], elementAABB[1]),
|
||||||
|
Math.max(labelAABB[2], elementAABB[2]),
|
||||||
|
Math.max(labelAABB[3], elementAABB[3]),
|
||||||
|
] as Bounds)
|
||||||
|
: elementAABB;
|
||||||
|
|
||||||
|
// ============== Evaluation ==============
|
||||||
|
|
||||||
|
// 1. If the selection box WRAPs the element's AABB, then add it to the
|
||||||
|
// selection and move on, regardless of the selection mode.
|
||||||
|
//
|
||||||
|
// PERF: This trick only works with axis-aligned box selection and the
|
||||||
|
// current convex element shapes!
|
||||||
|
if (boundsContainBounds(selectionBounds, commonAABB)) {
|
||||||
|
if (framesInSelection && isFrameLikeElement(element)) {
|
||||||
|
framesInSelection.add(element.id);
|
||||||
|
}
|
||||||
|
elementsInSelection.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle the case where the label is overlapped by the selection box
|
||||||
|
if (
|
||||||
|
type === "overlap" &&
|
||||||
|
labelAABB &&
|
||||||
|
doBoundsIntersect(selectionBounds, labelAABB)
|
||||||
|
) {
|
||||||
|
elementsInSelection.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handle the case where the selection is not wrapping the element, but
|
||||||
|
// it does intersect the element's outline (non-AABB).
|
||||||
|
if (type === "overlap" && doBoundsIntersect(selectionBounds, elementAABB)) {
|
||||||
|
let hasIntersection = false;
|
||||||
|
|
||||||
|
// Preliminary check potential intersection imprecision
|
||||||
|
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
hasIntersection = element.points.some((point) => {
|
||||||
|
const rotatedPoint = pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
);
|
||||||
|
|
||||||
|
return pointInsideBounds(rotatedPoint, selectionBounds);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const nonRotatedElementBounds = getElementBounds(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const center = elementCenterPoint(element, elementsMap);
|
||||||
|
hasIntersection = [
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||||
|
nonRotatedElementBounds[1],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
nonRotatedElementBounds[2],
|
||||||
|
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
||||||
|
nonRotatedElementBounds[3],
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
pointRotateRads(
|
||||||
|
pointFrom<GlobalPoint>(
|
||||||
|
nonRotatedElementBounds[0],
|
||||||
|
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
||||||
|
),
|
||||||
|
center,
|
||||||
|
element.angle,
|
||||||
|
),
|
||||||
|
].some((point) => {
|
||||||
|
return pointInsideBounds(
|
||||||
|
pointRotateRads(point, center, element.angle),
|
||||||
|
selectionBounds,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasIntersection) {
|
||||||
|
hasIntersection = selectionEdges.some(
|
||||||
|
(selectionEdge) =>
|
||||||
|
intersectElementWithLineSegment(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
selectionEdge,
|
||||||
|
strokeWidth / 2,
|
||||||
|
true, // Stop at first hit for better performance
|
||||||
|
).length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIntersection) {
|
||||||
|
if (framesInSelection && isFrameLikeElement(element)) {
|
||||||
|
framesInSelection.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
elementsInSelection.add(element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. We don't need to handle when the selection is inside the element
|
||||||
|
// as it is separately handled in App.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (framesInSelection) {
|
||||||
|
elementsInSelection.forEach((element) => {
|
||||||
|
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||||
|
elementsInSelection.delete(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "overlap") {
|
||||||
|
Array.from(elementsInSelection).forEach((element) => {
|
||||||
|
const groupId = element.groupIds.at(-1);
|
||||||
|
const group = groupId ? groups[groupId] : null;
|
||||||
|
|
||||||
|
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
|
||||||
|
});
|
||||||
|
} else if (type === "contain") {
|
||||||
|
elementsInSelection.forEach((element) => {
|
||||||
|
// note: currently we only support top-level group handling since
|
||||||
|
// we don't support box selecting while editing the group/subgroup
|
||||||
|
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
|
||||||
|
const groupId = element.groupIds.at(-1);
|
||||||
|
|
||||||
|
const group = groupId ? groups[groupId] : null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
group &&
|
||||||
|
!group.every((groupElement) => elementsInSelection.has(groupElement))
|
||||||
|
) {
|
||||||
|
elementsInSelection.delete(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// to maintain original order elements (namely for group selection)
|
||||||
|
return elements.filter((element) => elementsInSelection.has(element));
|
||||||
|
};
|
||||||
|
|
||||||
export const elementCenterPoint = (
|
export const elementCenterPoint = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
import { isPointWithinBounds, pointFrom } from "@excalidraw/math";
|
import {
|
||||||
import { doLineSegmentsIntersect } from "@excalidraw/utils/bbox";
|
isPointWithinBounds,
|
||||||
|
pointFrom,
|
||||||
|
segmentsIntersectAt,
|
||||||
|
} from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppClassProperties,
|
AppClassProperties,
|
||||||
@@ -78,7 +81,7 @@ export function isElementIntersectingFrame(
|
|||||||
|
|
||||||
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
||||||
elementLineSegments.some((elementLineSegment) =>
|
elementLineSegments.some((elementLineSegment) =>
|
||||||
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
|
segmentsIntersectAt(frameLineSegment, elementLineSegment),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -566,10 +569,6 @@ export const addElementsToFrame = <T extends ElementsMapOrArray>(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.frameId && element.frameId !== frame.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
finalElementsToAdd.add(element);
|
finalElementsToAdd.add(element);
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||||
|
|||||||
@@ -359,7 +359,6 @@ export class LinearElementEditor {
|
|||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const angleLocked = shouldRotateWithDiscreteAngle(event);
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
app.scene,
|
app.scene,
|
||||||
@@ -371,10 +370,7 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
isBindingEnabled: app.state.isBindingEnabled,
|
isBindingEnabled: app.state.isBindingEnabled,
|
||||||
isMidpointSnappingEnabled:
|
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
|
||||||
app.state.isMidpointSnappingEnabled &&
|
|
||||||
!angleLocked &&
|
|
||||||
!app.state.gridModeEnabled,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Set the suggested binding from the updates if available
|
// Set the suggested binding from the updates if available
|
||||||
@@ -431,9 +427,7 @@ export class LinearElementEditor {
|
|||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
app.state.zoom,
|
app.state.zoom,
|
||||||
app.state.isMidpointSnappingEnabled &&
|
app.state.isMidpointSnappingEnabled,
|
||||||
!angleLocked &&
|
|
||||||
!app.state.gridModeEnabled,
|
|
||||||
)
|
)
|
||||||
: linearElementEditor.initialState.altFocusPoint,
|
: linearElementEditor.initialState.altFocusPoint,
|
||||||
},
|
},
|
||||||
@@ -560,8 +554,6 @@ export class LinearElementEditor {
|
|||||||
linearElementEditor,
|
linearElementEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
const angleLocked =
|
|
||||||
shouldRotateWithDiscreteAngle(event) && singlePointDragged;
|
|
||||||
LinearElementEditor.movePoints(
|
LinearElementEditor.movePoints(
|
||||||
element,
|
element,
|
||||||
app.scene,
|
app.scene,
|
||||||
@@ -573,10 +565,7 @@ export class LinearElementEditor {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
isBindingEnabled: app.state.isBindingEnabled,
|
isBindingEnabled: app.state.isBindingEnabled,
|
||||||
isMidpointSnappingEnabled:
|
isMidpointSnappingEnabled: app.state.isMidpointSnappingEnabled,
|
||||||
app.state.isMidpointSnappingEnabled &&
|
|
||||||
!angleLocked &&
|
|
||||||
!app.state.gridModeEnabled,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -672,9 +661,7 @@ export class LinearElementEditor {
|
|||||||
"start",
|
"start",
|
||||||
elementsMap,
|
elementsMap,
|
||||||
app.state.zoom,
|
app.state.zoom,
|
||||||
app.state.isMidpointSnappingEnabled &&
|
app.state.isMidpointSnappingEnabled,
|
||||||
!angleLocked &&
|
|
||||||
!app.state.gridModeEnabled,
|
|
||||||
)
|
)
|
||||||
: linearElementEditor.initialState.altFocusPoint,
|
: linearElementEditor.initialState.altFocusPoint,
|
||||||
},
|
},
|
||||||
@@ -2189,7 +2176,6 @@ const pointDraggingUpdates = (
|
|||||||
newArrow: !!app.state.newElement,
|
newArrow: !!app.state.newElement,
|
||||||
angleLocked,
|
angleLocked,
|
||||||
altKey,
|
altKey,
|
||||||
gridSize: app.getEffectiveGridSize(),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -422,10 +422,10 @@ const drawElementOnCanvas = (
|
|||||||
|
|
||||||
for (const shape of shapes) {
|
for (const shape of shapes) {
|
||||||
if (typeof shape === "string") {
|
if (typeof shape === "string") {
|
||||||
context.fillStyle =
|
context.fillStyle = applyDarkModeFilter(
|
||||||
renderConfig.theme === THEME.DARK
|
element.strokeColor,
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
renderConfig.theme === THEME.DARK,
|
||||||
: element.strokeColor;
|
);
|
||||||
context.fill(new Path2D(shape));
|
context.fill(new Path2D(shape));
|
||||||
} else {
|
} else {
|
||||||
rc.draw(shape);
|
rc.draw(shape);
|
||||||
@@ -555,10 +555,10 @@ const drawElementOnCanvas = (
|
|||||||
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
|
||||||
context.save();
|
context.save();
|
||||||
context.font = getFontString(element);
|
context.font = getFontString(element);
|
||||||
context.fillStyle =
|
context.fillStyle = applyDarkModeFilter(
|
||||||
renderConfig.theme === THEME.DARK
|
element.strokeColor,
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
renderConfig.theme === THEME.DARK,
|
||||||
: element.strokeColor;
|
);
|
||||||
context.textAlign = element.textAlign as CanvasTextAlign;
|
context.textAlign = element.textAlign as CanvasTextAlign;
|
||||||
|
|
||||||
// Canvas does not support multiline text by default
|
// Canvas does not support multiline text by default
|
||||||
@@ -811,10 +811,10 @@ export const renderElement = (
|
|||||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||||
|
|
||||||
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
|
||||||
context.strokeStyle =
|
context.strokeStyle = applyDarkModeFilter(
|
||||||
appState.theme === THEME.DARK
|
FRAME_STYLE.strokeColor,
|
||||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
appState.theme === THEME.DARK,
|
||||||
: FRAME_STYLE.strokeColor;
|
);
|
||||||
|
|
||||||
// TODO change later to only affect AI frames
|
// TODO change later to only affect AI frames
|
||||||
if (isMagicFrameElement(element)) {
|
if (isMagicFrameElement(element)) {
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
import { arrayToMap, isShallowEqual, type Bounds } from "@excalidraw/common";
|
||||||
import {
|
|
||||||
lineSegment,
|
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
|
||||||
type GlobalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -12,33 +6,18 @@ import type {
|
|||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
} from "@excalidraw/excalidraw/types";
|
} from "@excalidraw/excalidraw/types";
|
||||||
|
|
||||||
import {
|
import { elementsOverlappingBBox, getElementAbsoluteCoords } from "./bounds";
|
||||||
boundsContainBounds,
|
|
||||||
doBoundsIntersect,
|
|
||||||
elementCenterPoint,
|
|
||||||
getElementAbsoluteCoords,
|
|
||||||
getElementBounds,
|
|
||||||
pointInsideBounds,
|
|
||||||
} from "./bounds";
|
|
||||||
import { intersectElementWithLineSegment } from "./collision";
|
|
||||||
import { isElementInViewport } from "./sizeHelpers";
|
import { isElementInViewport } from "./sizeHelpers";
|
||||||
import {
|
import {
|
||||||
isArrowElement,
|
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isFreeDrawElement,
|
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
} from "./typeChecks";
|
} from "./typeChecks";
|
||||||
import {
|
import { getFrameChildren } from "./frame";
|
||||||
elementOverlapsWithFrame,
|
|
||||||
getContainingFrame,
|
|
||||||
getFrameChildren,
|
|
||||||
} from "./frame";
|
|
||||||
|
|
||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { selectGroupsForSelectedElements } from "./groups";
|
import { selectGroupsForSelectedElements } from "./groups";
|
||||||
import { getBoundTextElement } from "./textElement";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
@@ -107,263 +86,15 @@ export const getElementsWithinSelection = (
|
|||||||
selectionX2,
|
selectionX2,
|
||||||
selectionY2,
|
selectionY2,
|
||||||
] as Bounds;
|
] as Bounds;
|
||||||
const selectionEdges = [
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX1, selectionY1),
|
|
||||||
pointFrom(selectionX2, selectionY1),
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX2, selectionY1),
|
|
||||||
pointFrom(selectionX2, selectionY2),
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX2, selectionY2),
|
|
||||||
pointFrom(selectionX1, selectionY2),
|
|
||||||
),
|
|
||||||
lineSegment<GlobalPoint>(
|
|
||||||
pointFrom(selectionX1, selectionY2),
|
|
||||||
pointFrom(selectionX1, selectionY1),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const framesInSelection = excludeElementsInFrames
|
return elementsOverlappingBBox({
|
||||||
? new Set<NonDeletedExcalidrawElement["id"]>()
|
elements,
|
||||||
: null;
|
bounds: selectionBounds,
|
||||||
const groups: Record<string, NonDeletedExcalidrawElement[]> = {};
|
elementsMap,
|
||||||
const elementsInSelection: Set<NonDeletedExcalidrawElement> = new Set();
|
type: boxSelectionMode,
|
||||||
|
shouldIgnoreElementFromSelection,
|
||||||
for (const element of elements) {
|
excludeElementsInFrames,
|
||||||
if (shouldIgnoreElementFromSelection(element)) {
|
});
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track only selectable top-level group members, so ignored elements such
|
|
||||||
// as bound text and locked elements don't affect group selection.
|
|
||||||
const groupId = element.groupIds.at(-1);
|
|
||||||
if (groupId) {
|
|
||||||
if (!groups[groupId]) {
|
|
||||||
groups[groupId] = [];
|
|
||||||
}
|
|
||||||
groups[groupId].push(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
const strokeWidth = element.strokeWidth;
|
|
||||||
let labelAABB: Bounds | null = null;
|
|
||||||
let elementAABB = getElementBounds(element, elementsMap);
|
|
||||||
|
|
||||||
elementAABB = [
|
|
||||||
elementAABB[0] - strokeWidth / 2,
|
|
||||||
elementAABB[1] - strokeWidth / 2,
|
|
||||||
elementAABB[2] + strokeWidth / 2,
|
|
||||||
elementAABB[3] + strokeWidth / 2,
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
// Whether the element bounds should include the bound text element bounds
|
|
||||||
const boundTextElement =
|
|
||||||
isArrowElement(element) && getBoundTextElement(element, elementsMap);
|
|
||||||
if (boundTextElement) {
|
|
||||||
const { x, y } = LinearElementEditor.getBoundTextElementPosition(
|
|
||||||
element,
|
|
||||||
boundTextElement,
|
|
||||||
elementsMap,
|
|
||||||
);
|
|
||||||
labelAABB = [
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
x + boundTextElement.width,
|
|
||||||
y + boundTextElement.height,
|
|
||||||
] as Bounds;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clip element bounds by its containing frame (if any), since only the
|
|
||||||
// visible (frame-clipped) portion of the element is relevant for selection.
|
|
||||||
const associatedFrame = getContainingFrame(element, elementsMap);
|
|
||||||
if (
|
|
||||||
associatedFrame &&
|
|
||||||
elementOverlapsWithFrame(element, associatedFrame, elementsMap)
|
|
||||||
) {
|
|
||||||
const frameAABB = getElementBounds(associatedFrame, elementsMap);
|
|
||||||
elementAABB = [
|
|
||||||
Math.max(elementAABB[0], frameAABB[0]),
|
|
||||||
Math.max(elementAABB[1], frameAABB[1]),
|
|
||||||
Math.min(elementAABB[2], frameAABB[2]),
|
|
||||||
Math.min(elementAABB[3], frameAABB[3]),
|
|
||||||
] as Bounds;
|
|
||||||
|
|
||||||
labelAABB = labelAABB
|
|
||||||
? ([
|
|
||||||
Math.max(labelAABB[0], frameAABB[0]),
|
|
||||||
Math.max(labelAABB[1], frameAABB[1]),
|
|
||||||
Math.min(labelAABB[2], frameAABB[2]),
|
|
||||||
Math.min(labelAABB[3], frameAABB[3]),
|
|
||||||
] as Bounds)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonAABB = labelAABB
|
|
||||||
? ([
|
|
||||||
Math.min(labelAABB[0], elementAABB[0]),
|
|
||||||
Math.min(labelAABB[1], elementAABB[1]),
|
|
||||||
Math.max(labelAABB[2], elementAABB[2]),
|
|
||||||
Math.max(labelAABB[3], elementAABB[3]),
|
|
||||||
] as Bounds)
|
|
||||||
: elementAABB;
|
|
||||||
|
|
||||||
// ============== Evaluation ==============
|
|
||||||
|
|
||||||
// 1. If the selection box WRAPs the element's AABB, then add it to the
|
|
||||||
// selection and move on, regardless of the selection mode.
|
|
||||||
//
|
|
||||||
// PERF: This trick only works with axis-aligned box selection and the
|
|
||||||
// current convex element shapes!
|
|
||||||
if (boundsContainBounds(selectionBounds, commonAABB)) {
|
|
||||||
if (framesInSelection && isFrameLikeElement(element)) {
|
|
||||||
framesInSelection.add(element.id);
|
|
||||||
}
|
|
||||||
elementsInSelection.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Handle the case where the label is overlapped by the selection box
|
|
||||||
if (
|
|
||||||
boxSelectionMode === "overlap" &&
|
|
||||||
labelAABB &&
|
|
||||||
doBoundsIntersect(selectionBounds, labelAABB)
|
|
||||||
) {
|
|
||||||
elementsInSelection.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Handle the case where the selection is not wrapping the element, but
|
|
||||||
// it does intersect the element's outline (non-AABB).
|
|
||||||
if (
|
|
||||||
boxSelectionMode === "overlap" &&
|
|
||||||
doBoundsIntersect(selectionBounds, elementAABB)
|
|
||||||
) {
|
|
||||||
let hasIntersection = false;
|
|
||||||
|
|
||||||
// Preliminary check potential intersection imprecision
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
hasIntersection = element.points.some((point) => {
|
|
||||||
const rotatedPoint = pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
);
|
|
||||||
|
|
||||||
return pointInsideBounds(rotatedPoint, selectionBounds);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const nonRotatedElementBounds = getElementBounds(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
const center = elementCenterPoint(element, elementsMap);
|
|
||||||
hasIntersection = [
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
|
||||||
nonRotatedElementBounds[1],
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
nonRotatedElementBounds[2],
|
|
||||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
(nonRotatedElementBounds[0] + nonRotatedElementBounds[2]) / 2,
|
|
||||||
nonRotatedElementBounds[3],
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
pointRotateRads(
|
|
||||||
pointFrom<GlobalPoint>(
|
|
||||||
nonRotatedElementBounds[0],
|
|
||||||
(nonRotatedElementBounds[1] + nonRotatedElementBounds[3]) / 2,
|
|
||||||
),
|
|
||||||
center,
|
|
||||||
element.angle,
|
|
||||||
),
|
|
||||||
].some((point) => {
|
|
||||||
return pointInsideBounds(
|
|
||||||
pointRotateRads(point, center, element.angle),
|
|
||||||
selectionBounds,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasIntersection) {
|
|
||||||
hasIntersection = selectionEdges.some(
|
|
||||||
(selectionEdge) =>
|
|
||||||
intersectElementWithLineSegment(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
selectionEdge,
|
|
||||||
strokeWidth / 2,
|
|
||||||
true, // Stop at first hit for better performance
|
|
||||||
).length > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasIntersection) {
|
|
||||||
if (framesInSelection && isFrameLikeElement(element)) {
|
|
||||||
framesInSelection.add(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
elementsInSelection.add(element);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. We don't need to handle when the selection is inside the element
|
|
||||||
// as it is separately handled in App.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (framesInSelection) {
|
|
||||||
elementsInSelection.forEach((element) => {
|
|
||||||
if (element.frameId && framesInSelection.has(element.frameId)) {
|
|
||||||
elementsInSelection.delete(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boxSelectionMode === "overlap") {
|
|
||||||
Array.from(elementsInSelection).forEach((element) => {
|
|
||||||
const groupId = element.groupIds.at(-1);
|
|
||||||
const group = groupId ? groups[groupId] : null;
|
|
||||||
|
|
||||||
group?.forEach((groupElement) => elementsInSelection.add(groupElement));
|
|
||||||
});
|
|
||||||
} else if (boxSelectionMode === "contain") {
|
|
||||||
elementsInSelection.forEach((element) => {
|
|
||||||
// note: currently we only support top-level group handling since
|
|
||||||
// we don't support box selecting while editing the group/subgroup
|
|
||||||
// see https://github.com/excalidraw/excalidraw/pull/11234#issuecomment-4387654451
|
|
||||||
const groupId = element.groupIds.at(-1);
|
|
||||||
|
|
||||||
const group = groupId ? groups[groupId] : null;
|
|
||||||
|
|
||||||
if (
|
|
||||||
group &&
|
|
||||||
!group.every((groupElement) => elementsInSelection.has(groupElement))
|
|
||||||
) {
|
|
||||||
elementsInSelection.delete(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// to maintain original order elements (namely for group selection)
|
|
||||||
return elements.filter((element) => elementsInSelection.has(element));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVisibleAndNonSelectedElements = (
|
export const getVisibleAndNonSelectedElements = (
|
||||||
|
|||||||
@@ -218,9 +218,7 @@ export const generateRoughOptions = (
|
|||||||
fillWeight: element.strokeWidth / 2,
|
fillWeight: element.strokeWidth / 2,
|
||||||
hachureGap: element.strokeWidth * 4,
|
hachureGap: element.strokeWidth * 4,
|
||||||
roughness: adjustRoughness(element),
|
roughness: adjustRoughness(element),
|
||||||
stroke: isDarkMode
|
stroke: applyDarkModeFilter(element.strokeColor, isDarkMode),
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
|
||||||
: element.strokeColor,
|
|
||||||
preserveVertices:
|
preserveVertices:
|
||||||
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
continuousPath || element.roughness < ROUGHNESS.cartoonist,
|
||||||
};
|
};
|
||||||
@@ -234,9 +232,7 @@ export const generateRoughOptions = (
|
|||||||
options.fillStyle = element.fillStyle;
|
options.fillStyle = element.fillStyle;
|
||||||
options.fill = isTransparent(element.backgroundColor)
|
options.fill = isTransparent(element.backgroundColor)
|
||||||
? undefined
|
? undefined
|
||||||
: isDarkMode
|
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
|
||||||
? applyDarkModeFilter(element.backgroundColor)
|
|
||||||
: element.backgroundColor;
|
|
||||||
if (element.type === "ellipse") {
|
if (element.type === "ellipse") {
|
||||||
options.curveFitting = 1;
|
options.curveFitting = 1;
|
||||||
}
|
}
|
||||||
@@ -249,9 +245,7 @@ export const generateRoughOptions = (
|
|||||||
options.fill =
|
options.fill =
|
||||||
element.backgroundColor === "transparent"
|
element.backgroundColor === "transparent"
|
||||||
? undefined
|
? undefined
|
||||||
: isDarkMode
|
: applyDarkModeFilter(element.backgroundColor, isDarkMode);
|
||||||
? applyDarkModeFilter(element.backgroundColor)
|
|
||||||
: element.backgroundColor;
|
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
@@ -386,12 +380,11 @@ const getArrowheadShapes = (
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const strokeColor = isDarkMode
|
const strokeColor = applyDarkModeFilter(element.strokeColor, isDarkMode);
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
const backgroundFillColor = applyDarkModeFilter(
|
||||||
: element.strokeColor;
|
canvasBackgroundColor,
|
||||||
const backgroundFillColor = isDarkMode
|
isDarkMode,
|
||||||
? applyDarkModeFilter(canvasBackgroundColor)
|
);
|
||||||
: canvasBackgroundColor;
|
|
||||||
const cardinalityOneOrManyOffset = -0.25;
|
const cardinalityOneOrManyOffset = -0.25;
|
||||||
const cardinalityZeroCircleScale = 0.8;
|
const cardinalityZeroCircleScale = 0.8;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
pointFrom,
|
pointFrom,
|
||||||
type GlobalPoint,
|
type GlobalPoint,
|
||||||
type LocalPoint,
|
type LocalPoint,
|
||||||
|
type LineSegment,
|
||||||
} from "@excalidraw/math";
|
} from "@excalidraw/math";
|
||||||
import { type Bounds, isBounds } from "@excalidraw/common";
|
import { type Bounds, isBounds } from "@excalidraw/common";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
|
|
||||||
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
|
||||||
import type { Curve } from "@excalidraw/math";
|
import type { Curve } from "@excalidraw/math";
|
||||||
import type { LineSegment } from "@excalidraw/utils";
|
|
||||||
|
|
||||||
// The global data holder to collect the debug operations
|
// The global data holder to collect the debug operations
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
import { arrayToMap, findIndex, findLastIndex } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { isFiniteNumber } from "@excalidraw/math";
|
||||||
|
|
||||||
import type { AppState } from "@excalidraw/excalidraw/types";
|
import type { AppState } from "@excalidraw/excalidraw/types";
|
||||||
import type { GlobalPoint } from "@excalidraw/math";
|
import type { GlobalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
@@ -313,12 +315,46 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
|
|||||||
}, new Map<string, ExcalidrawElement>());
|
}, new Map<string, ExcalidrawElement>());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasSameElementIds = (
|
||||||
|
prevElements: readonly ExcalidrawElement[],
|
||||||
|
nextElements: readonly ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
if (prevElements.length !== nextElements.length) {
|
||||||
|
console.error(
|
||||||
|
"z-index reordering failed: resulting array have different lengths",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevElementIdCounts = new Map<ExcalidrawElement["id"], number>();
|
||||||
|
for (const element of prevElements) {
|
||||||
|
prevElementIdCounts.set(
|
||||||
|
element.id,
|
||||||
|
(prevElementIdCounts.get(element.id) || 0) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of nextElements) {
|
||||||
|
const count = prevElementIdCounts.get(element.id);
|
||||||
|
if (!count) {
|
||||||
|
console.error(
|
||||||
|
"z-index reordering failed: element id mismatch / duplicate ids",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
prevElementIdCounts.set(element.id, count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const shiftElementsByOne = (
|
const shiftElementsByOne = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
direction: "left" | "right",
|
direction: "left" | "right",
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
) => {
|
) => {
|
||||||
|
const originalElements = elements;
|
||||||
const indicesToMove = getIndicesToMove(elements, appState);
|
const indicesToMove = getIndicesToMove(elements, appState);
|
||||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||||
|
|
||||||
@@ -389,6 +425,10 @@ const shiftElementsByOne = (
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasSameElementIds(originalElements, elements)) {
|
||||||
|
return originalElements;
|
||||||
|
}
|
||||||
|
|
||||||
syncMovedIndices(elements, targetElementsMap);
|
syncMovedIndices(elements, targetElementsMap);
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
@@ -402,11 +442,20 @@ const shiftElementsToEnd = (
|
|||||||
elementsToBeMoved?: readonly ExcalidrawElement[],
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
|
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
|
||||||
|
|
||||||
|
// Nothing to move (e.g. `elementsToBeMoved` is empty because all selected
|
||||||
|
// elements were frame children handled in a prior pass). Bail out early —
|
||||||
|
// otherwise `leadingIndex`/`trailingIndex` below resolve to `undefined` and
|
||||||
|
// the resulting `slice()` calls overlap, duplicating elements.
|
||||||
|
if (indicesToMove.length === 0) {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||||
const displacedElements: ExcalidrawElement[] = [];
|
const displacedElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
let leadingIndex: number;
|
let leadingIndex: number | undefined;
|
||||||
let trailingIndex: number;
|
let trailingIndex: number | undefined;
|
||||||
if (direction === "left") {
|
if (direction === "left") {
|
||||||
if (containingFrame) {
|
if (containingFrame) {
|
||||||
leadingIndex = findIndex(elements, (el) =>
|
leadingIndex = findIndex(elements, (el) =>
|
||||||
@@ -451,6 +500,19 @@ const shiftElementsToEnd = (
|
|||||||
leadingIndex = 0;
|
leadingIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidIndex = (index: number | undefined): index is number => {
|
||||||
|
return isFiniteNumber(index) && index >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isValidIndex(leadingIndex) ||
|
||||||
|
!isValidIndex(trailingIndex) ||
|
||||||
|
leadingIndex > trailingIndex ||
|
||||||
|
indicesToMove.some((index) => index < leadingIndex || index > trailingIndex)
|
||||||
|
) {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
|
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
|
||||||
if (!indicesToMove.includes(index)) {
|
if (!indicesToMove.includes(index)) {
|
||||||
displacedElements.push(elements[index]);
|
displacedElements.push(elements[index]);
|
||||||
@@ -475,6 +537,10 @@ const shiftElementsToEnd = (
|
|||||||
...trailingElements,
|
...trailingElements,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!hasSameElementIds(elements, nextElements)) {
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
syncMovedIndices(nextElements, targetElementsMap);
|
syncMovedIndices(nextElements, targetElementsMap);
|
||||||
|
|
||||||
return nextElements;
|
return nextElements;
|
||||||
@@ -543,7 +609,7 @@ function shiftElementsAccountingForFrames(
|
|||||||
|
|
||||||
for (const [frameId, children] of frameChildrenSets) {
|
for (const [frameId, children] of frameChildrenSets) {
|
||||||
nextElements = shiftFunction(
|
nextElements = shiftFunction(
|
||||||
allElements,
|
nextElements,
|
||||||
appState,
|
appState,
|
||||||
direction,
|
direction,
|
||||||
frameId,
|
frameId,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
import { arrayToMap, type Bounds, ROUNDNESS } from "@excalidraw/common";
|
||||||
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
|
import {
|
||||||
|
elementsOverlappingBBox,
|
||||||
|
getElementAbsoluteCoords,
|
||||||
|
getElementBounds,
|
||||||
|
} from "../src/bounds";
|
||||||
|
|
||||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
||||||
|
|
||||||
@@ -141,3 +145,65 @@ describe("getElementBounds", () => {
|
|||||||
expect(y2).toEqual(319.8162855827246);
|
expect(y2).toEqual(319.8162855827246);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeElement = (x: number, y: number, width: number, height: number) =>
|
||||||
|
API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeBBox = (
|
||||||
|
minX: number,
|
||||||
|
minY: number,
|
||||||
|
maxX: number,
|
||||||
|
maxY: number,
|
||||||
|
): Bounds => [minX, minY, maxX, maxY];
|
||||||
|
|
||||||
|
describe("elementsOverlappingBBox()", () => {
|
||||||
|
it("should return elements that overlap bbox", () => {
|
||||||
|
const bbox = makeBBox(0, 0, 100, 100);
|
||||||
|
|
||||||
|
const rectOutside = makeElement(110, 110, 100, 100);
|
||||||
|
const rectInside = makeElement(10, 10, 85, 85);
|
||||||
|
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
||||||
|
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
elementsOverlappingBBox({
|
||||||
|
bounds: bbox,
|
||||||
|
type: "overlap",
|
||||||
|
elements: [
|
||||||
|
rectOutside,
|
||||||
|
rectInside,
|
||||||
|
rectContainingBBox,
|
||||||
|
rectOverlappingTopLeft,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([rectInside, rectOverlappingTopLeft]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return elements inside/containing bbox", () => {
|
||||||
|
const bbox = makeBBox(0, 0, 100, 100);
|
||||||
|
|
||||||
|
const rectOutside = makeElement(110, 110, 100, 100);
|
||||||
|
const rectInside = makeElement(10, 10, 85, 85);
|
||||||
|
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
||||||
|
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
elementsOverlappingBBox({
|
||||||
|
bounds: bbox,
|
||||||
|
type: "contain",
|
||||||
|
elements: [
|
||||||
|
rectOutside,
|
||||||
|
rectInside,
|
||||||
|
rectContainingBBox,
|
||||||
|
rectOverlappingTopLeft,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([rectInside]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Excalidraw,
|
Excalidraw,
|
||||||
} from "@excalidraw/excalidraw";
|
} from "@excalidraw/excalidraw";
|
||||||
import { arrayToMap } from "@excalidraw/common";
|
import { arrayToMap } from "@excalidraw/common";
|
||||||
|
import { degreesToRadians, pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||||
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
|
|
||||||
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
|
import { getSelectedElements } from "@excalidraw/excalidraw/scene";
|
||||||
|
|
||||||
|
import type { Degrees } from "@excalidraw/math";
|
||||||
|
|
||||||
import { elementOverlapsWithFrame } from "../src/frame";
|
import { elementOverlapsWithFrame } from "../src/frame";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -154,6 +157,108 @@ describe("adding elements to frames", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses precise geometry (not the bounding box) to detect frame overlap", () => {
|
||||||
|
// a 300x200 frame at the origin, with shapes positioned just outside each
|
||||||
|
// corner so that their bounding box clips the frame but their actual
|
||||||
|
// outline does not. The precise `isElementIntersectingFrame` must report
|
||||||
|
// no overlap.
|
||||||
|
const overlapFrame = API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
// clips the bottom-right corner; the rhombus tip stays out
|
||||||
|
const diamond = API.createElement({
|
||||||
|
type: "diamond",
|
||||||
|
x: 290,
|
||||||
|
y: 175,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
// clips the bottom-left corner; rotated (NOT 45°, which would degenerate
|
||||||
|
// into an axis-aligned square whose shape fills its AABB)
|
||||||
|
const diamondRotated = API.createElement({
|
||||||
|
type: "diamond",
|
||||||
|
x: -50,
|
||||||
|
y: 195,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
angle: degreesToRadians(45 as Degrees),
|
||||||
|
});
|
||||||
|
// clips the top-left corner; the curve doesn't reach the corner
|
||||||
|
const ellipse = API.createElement({
|
||||||
|
type: "ellipse",
|
||||||
|
x: -90,
|
||||||
|
y: -90,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
// clips the top edge; the polyline weaves outside the frame
|
||||||
|
const line = API.createElement({
|
||||||
|
type: "line",
|
||||||
|
x: 130,
|
||||||
|
y: -20,
|
||||||
|
width: 293,
|
||||||
|
height: 183,
|
||||||
|
points: [
|
||||||
|
pointFrom(0, 0),
|
||||||
|
pointFrom(-165, -96),
|
||||||
|
pointFrom(-293, -16),
|
||||||
|
pointFrom(-162, 87),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// clips the bottom-left corner; the stroke stays outside
|
||||||
|
const freedraw = API.createElement({
|
||||||
|
type: "freedraw",
|
||||||
|
x: -15,
|
||||||
|
y: 180,
|
||||||
|
width: 207,
|
||||||
|
height: 209,
|
||||||
|
points: [
|
||||||
|
pointFrom(0, 0),
|
||||||
|
pointFrom(-69, 99),
|
||||||
|
pointFrom(-33, 183),
|
||||||
|
pointFrom(48, 209),
|
||||||
|
pointFrom(124, 195),
|
||||||
|
pointFrom(138, 145),
|
||||||
|
pointFrom(123, 85),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// clips the top-right corner, but its rounded corner is cut away from
|
||||||
|
// exactly that corner — so its actual outline stays outside
|
||||||
|
const roundedRect = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
x: 295,
|
||||||
|
y: -100,
|
||||||
|
width: 160,
|
||||||
|
height: 110,
|
||||||
|
roundness: { type: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([
|
||||||
|
overlapFrame,
|
||||||
|
diamond,
|
||||||
|
diamondRotated,
|
||||||
|
ellipse,
|
||||||
|
line,
|
||||||
|
freedraw,
|
||||||
|
roundedRect,
|
||||||
|
]);
|
||||||
|
const elementsMap = arrayToMap(h.elements);
|
||||||
|
const f = overlapFrame as ExcalidrawFrameLikeElement;
|
||||||
|
|
||||||
|
expect(elementOverlapsWithFrame(diamond, f, elementsMap)).toBe(false);
|
||||||
|
expect(elementOverlapsWithFrame(diamondRotated, f, elementsMap)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(elementOverlapsWithFrame(ellipse, f, elementsMap)).toBe(false);
|
||||||
|
expect(elementOverlapsWithFrame(line, f, elementsMap)).toBe(false);
|
||||||
|
expect(elementOverlapsWithFrame(freedraw, f, elementsMap)).toBe(false);
|
||||||
|
expect(elementOverlapsWithFrame(roundedRect, f, elementsMap)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should not add a newly created element to a frame behind a non-frame element", () => {
|
it("should not add a newly created element to a frame behind a non-frame element", () => {
|
||||||
const cover = API.createElement({
|
const cover = API.createElement({
|
||||||
id: "cover",
|
id: "cover",
|
||||||
@@ -692,6 +797,34 @@ describe("adding elements to frames", () => {
|
|||||||
expect(rect2.frameId).toBe(frame.id);
|
expect(rect2.frameId).toBe(frame.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should move an element dragged from one frame into another", () => {
|
||||||
|
const otherFrame = API.createElement({
|
||||||
|
id: "otherFrame",
|
||||||
|
type: "frame",
|
||||||
|
x: 300,
|
||||||
|
y: 0,
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
});
|
||||||
|
const frameChild = API.createElement({
|
||||||
|
id: "frameChild",
|
||||||
|
type: "rectangle",
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([frame, frameChild, otherFrame]);
|
||||||
|
|
||||||
|
expect(frameChild.frameId).toBe(frame.id);
|
||||||
|
|
||||||
|
dragElementIntoFrame(otherFrame, frameChild);
|
||||||
|
|
||||||
|
expect(frameChild.frameId).toBe(otherFrame.id);
|
||||||
|
});
|
||||||
|
|
||||||
it("should layer a dragged element above the highest frame child", () => {
|
it("should layer a dragged element above the highest frame child", () => {
|
||||||
const frameChild = API.createElement({
|
const frameChild = API.createElement({
|
||||||
id: "frameChild",
|
id: "frameChild",
|
||||||
|
|||||||
@@ -1509,4 +1509,190 @@ describe("z-indexing with frames", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bringing to front / sending to back children of MULTIPLE frames at once moves all of them", () => {
|
||||||
|
assertZindex({
|
||||||
|
elements: [
|
||||||
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
||||||
|
{ id: "F1_2", frameId: "F1" },
|
||||||
|
{ id: "F1", type: "frame" },
|
||||||
|
{ id: "F2_1", frameId: "F2", isSelected: true },
|
||||||
|
{ id: "F2_2", frameId: "F2" },
|
||||||
|
{ id: "F2", type: "frame" },
|
||||||
|
],
|
||||||
|
operations: [
|
||||||
|
// +∞: each selected child moves to the front of its own frame
|
||||||
|
[actionBringToFront, ["F1_2", "F1", "F1_1", "F2_2", "F2", "F2_1"]],
|
||||||
|
// -∞: each selected child moves to the back of its own frame
|
||||||
|
[actionSendToBack, ["F1_1", "F1_2", "F1", "F2_1", "F2_2", "F2"]],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("send to back / bring to front of a grouped frame child (in group-editing mode) must not duplicate elements", () => {
|
||||||
|
assertZindex({
|
||||||
|
elements: [
|
||||||
|
{ id: "F1_1", frameId: "F1", groupIds: ["g1"] },
|
||||||
|
{ id: "F1_2", frameId: "F1", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "F1", type: "frame" },
|
||||||
|
{ id: "F2_1", frameId: "F2", groupIds: ["g2"] },
|
||||||
|
{ id: "F2_2", frameId: "F2", groupIds: ["g2"] },
|
||||||
|
{ id: "F2", type: "frame" },
|
||||||
|
],
|
||||||
|
appState: { editingGroupId: "g1" },
|
||||||
|
operations: [
|
||||||
|
// -∞ (send to back, within the frame)
|
||||||
|
[actionSendToBack, ["F1_2", "F1_1", "F1", "F2_1", "F2_2", "F2"]],
|
||||||
|
// +∞ (bring to front, within the frame)
|
||||||
|
[actionBringToFront, ["F1_1", "F1", "F1_2", "F2_1", "F2_2", "F2"]],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inputs in this block intentionally VIOLATE the (soft) invariant that a
|
||||||
|
* frame's children — and a group's members — are contiguous in the elements
|
||||||
|
* array. Such states shouldn't occur in normal use, but they CAN arise from
|
||||||
|
* bugs or broken input, because nothing re-defragments element order during
|
||||||
|
* a reorder (`normalizeElementOrder` only runs on duplication). We keep these
|
||||||
|
* tests so the reordering ops stay exercised against malformed order.
|
||||||
|
*
|
||||||
|
* HARD CONTRACT (a failure here is a real bug): a reorder must never throw,
|
||||||
|
* duplicate, or drop elements. `assertReorderPreservesElements` checks this.
|
||||||
|
*
|
||||||
|
* SOFT SNAPSHOT (read before "fixing"): the exact resulting ORDER is NOT a
|
||||||
|
* contract for invalid input — it's whatever the slice math happens to
|
||||||
|
* produce. If a future change alters an `expected` order below, that is NOT
|
||||||
|
* necessarily a functional regression. First confirm from the diff that the
|
||||||
|
* hard contract still holds (nothing duplicated/lost), then update the
|
||||||
|
* expected order to match, provided it's deemed an improvement over the
|
||||||
|
* previous order, or it's an acceptable change given the underlying logic
|
||||||
|
* change.
|
||||||
|
*/
|
||||||
|
describe("z-index reordering with broken contiguity (invariant-violating input)", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const assertReorderPreservesElements = (
|
||||||
|
elements: Parameters<typeof populateElements>[0],
|
||||||
|
appState: Parameters<typeof populateElements>[1],
|
||||||
|
// each op is applied to a freshly-populated (broken) state
|
||||||
|
cases: [Actions, string[]][],
|
||||||
|
) => {
|
||||||
|
for (const [action, expected] of cases) {
|
||||||
|
populateElements(elements, appState);
|
||||||
|
const before = h.elements.map((el) => el.id);
|
||||||
|
|
||||||
|
expect(() => API.executeAction(action)).not.toThrow();
|
||||||
|
|
||||||
|
const after = h.elements.map((el) => el.id);
|
||||||
|
// hard contract:
|
||||||
|
expect(after.length).toBe(before.length); // no loss
|
||||||
|
expect(new Set(after).size).toBe(after.length); // no duplication
|
||||||
|
// soft snapshot (see block comment before changing):
|
||||||
|
expect(after).toEqual(expected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it("discontiguous frame children (foreign frame's child interleaved in span)", () => {
|
||||||
|
// F2_1 (a child of frame F2) sits INSIDE frame F1's z-span. Reordering F1's
|
||||||
|
// child sweeps F2_1 along (span-based frame handling) — wrong ordering, but
|
||||||
|
// never a duplication/loss, and the op does not throw.
|
||||||
|
const elements: Parameters<typeof populateElements>[0] = [
|
||||||
|
{ id: "F1_1", frameId: "F1", isSelected: true },
|
||||||
|
{ id: "F2_1", frameId: "F2" },
|
||||||
|
{ id: "F1_2", frameId: "F1" },
|
||||||
|
{ id: "F1", type: "frame" },
|
||||||
|
{ id: "F2", type: "frame" },
|
||||||
|
];
|
||||||
|
assertReorderPreservesElements(elements, undefined, [
|
||||||
|
[actionBringForward, ["F2_1", "F1_2", "F1_1", "F1", "F2"]],
|
||||||
|
[actionSendBackward, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
|
||||||
|
[actionBringToFront, ["F2_1", "F1_2", "F1", "F1_1", "F2"]],
|
||||||
|
[actionSendToBack, ["F1_1", "F2_1", "F1_2", "F1", "F2"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discontiguous group, whole group selected", () => {
|
||||||
|
// g1 = {A, C}, scattered by the loose elements B and D.
|
||||||
|
const elements: Parameters<typeof populateElements>[0] = [
|
||||||
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "D" },
|
||||||
|
];
|
||||||
|
assertReorderPreservesElements(elements, undefined, [
|
||||||
|
// move-by-one leaves the group scattered (each run moves independently)
|
||||||
|
[actionBringForward, ["B", "A", "D", "C"]],
|
||||||
|
[actionSendBackward, ["A", "C", "B", "D"]],
|
||||||
|
// to-front / to-back gather the scattered members back into one block
|
||||||
|
[actionBringToFront, ["B", "D", "A", "C"]],
|
||||||
|
[actionSendToBack, ["A", "C", "B", "D"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discontiguous group, single member selected in group-editing mode", () => {
|
||||||
|
const elements: Parameters<typeof populateElements>[0] = [
|
||||||
|
{ id: "A", groupIds: ["g1"] },
|
||||||
|
{ id: "B" },
|
||||||
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "D" },
|
||||||
|
];
|
||||||
|
assertReorderPreservesElements(elements, { editingGroupId: "g1" }, [
|
||||||
|
[actionBringForward, ["A", "B", "C", "D"]],
|
||||||
|
[actionSendBackward, ["C", "A", "B", "D"]],
|
||||||
|
[actionBringToFront, ["A", "B", "C", "D"]],
|
||||||
|
[actionSendToBack, ["C", "A", "B", "D"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two interleaved groups, both fully selected", () => {
|
||||||
|
const elements: Parameters<typeof populateElements>[0] = [
|
||||||
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "X", groupIds: ["g2"], isSelected: true },
|
||||||
|
{ id: "C", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "Y", groupIds: ["g2"], isSelected: true },
|
||||||
|
{ id: "Z" },
|
||||||
|
];
|
||||||
|
assertReorderPreservesElements(elements, undefined, [
|
||||||
|
[actionBringForward, ["Z", "A", "X", "C", "Y"]],
|
||||||
|
[actionSendBackward, ["A", "X", "C", "Y", "Z"]],
|
||||||
|
[actionBringToFront, ["Z", "A", "X", "C", "Y"]],
|
||||||
|
[actionSendToBack, ["A", "X", "C", "Y", "Z"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("z-index reordering with inconsistent group-editing state", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate or drop elements when selected elements fall outside the edited group scope", () => {
|
||||||
|
assertZindex({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", groupIds: ["g1"], isSelected: true },
|
||||||
|
{ id: "C", groupIds: ["g1"] },
|
||||||
|
{ id: "X", groupIds: ["g2"] },
|
||||||
|
{ id: "Y", groupIds: ["g2"] },
|
||||||
|
{ id: "R" },
|
||||||
|
],
|
||||||
|
appState: { editingGroupId: "g2" },
|
||||||
|
operations: [[actionSendToBack, ["A", "C", "X", "Y", "R"]]],
|
||||||
|
});
|
||||||
|
|
||||||
|
assertZindex({
|
||||||
|
elements: [
|
||||||
|
{ id: "A", groupIds: ["g1"] },
|
||||||
|
{ id: "C", groupIds: ["g1"] },
|
||||||
|
{ id: "X", groupIds: ["g2"], isSelected: true },
|
||||||
|
{ id: "Y", groupIds: ["g2"] },
|
||||||
|
{ id: "R" },
|
||||||
|
],
|
||||||
|
appState: { editingGroupId: "g1" },
|
||||||
|
operations: [[actionBringToFront, ["A", "C", "X", "Y", "R"]]],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Breaking changes
|
### Breaking changes
|
||||||
|
|
||||||
|
- Theme changes initiated by the default UI are now delegated to `<Excalidraw onThemeChange={(theme) => ...} />` when supplied. If `onThemeChange` is not supplied, light/dark theme toggling still falls back to updating the internal editor state.
|
||||||
|
|
||||||
|
- `MainMenu.DefaultItems.ToggleTheme` no longer accepts the item-level `onSelect` callback. Host apps that need to control light/dark/system theme should pass `onThemeChange` to `<Excalidraw />` instead.
|
||||||
|
- `MainMenu.DefaultItems.ToggleTheme` with system theme support now uses `allowSystemTheme` together with `theme={Theme | "system"}` only to render the selected value. For the regular light/dark item, pass `allowSystemTheme={false}`.
|
||||||
|
- `CommandPalette.defaultItems.toggleTheme` was removed. The default theme command is now rendered by the command palette itself when `UIOptions.canvasActions.toggleTheme` enables the action (see below).
|
||||||
|
- `UIOptions.canvasActions.toggleTheme` still controls default theme UI availability. When it is `null`, it defaults to `true` if `props.theme` is omitted or `props.onThemeChange` is supplied, and otherwise defaults to disabled.
|
||||||
|
|
||||||
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
- Renamed the `excalidrawAPI` prop to `onExcalidrawAPI`.
|
||||||
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
|
- `onExcalidrawAPI` is now called on mount (instead of during constructor), and later on unmount (with `null` value). The API may be removed altogether in the future (you can use `onMount` & `onUmount` to manage the `ExcalidrawAPI` object (e.g. to cache it to a global state), already).
|
||||||
|
|
||||||
|
|||||||
@@ -477,17 +477,28 @@ export const actionToggleTheme = register<AppState["theme"]>({
|
|||||||
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
|
appState.theme === THEME.LIGHT ? MoonIcon : SunIcon,
|
||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (_, appState, value) => {
|
perform: (_, appState, value, app) => {
|
||||||
|
const nextTheme =
|
||||||
|
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
|
||||||
|
|
||||||
|
if (app.props.onThemeChange) {
|
||||||
|
app.props.onThemeChange(nextTheme);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
theme:
|
theme: nextTheme,
|
||||||
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
|
|
||||||
},
|
},
|
||||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
keyTest: (event) =>
|
||||||
|
!event[KEYS.CTRL_OR_CMD] &&
|
||||||
|
event.altKey &&
|
||||||
|
event.shiftKey &&
|
||||||
|
event.code === CODES.D,
|
||||||
predicate: (elements, appState, props, app) => {
|
predicate: (elements, appState, props, app) => {
|
||||||
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
import type { GlobalPoint, LocalPoint } from "@excalidraw/math";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
@@ -93,40 +93,32 @@ export const actionFinalize = register<FormData>({
|
|||||||
? [element.points.length - 1] // New arrow creation
|
? [element.points.length - 1] // New arrow creation
|
||||||
: appState.selectedLinearElement.selectedPointsIndices;
|
: appState.selectedLinearElement.selectedPointsIndices;
|
||||||
|
|
||||||
const angleLocked = shouldRotateWithDiscreteAngle(event);
|
|
||||||
const effectiveGridSize = event[KEYS.CTRL_OR_CMD]
|
|
||||||
? null
|
|
||||||
: app.getEffectiveGridSize();
|
|
||||||
|
|
||||||
const draggedPoints: PointsPositionUpdates =
|
const draggedPoints: PointsPositionUpdates =
|
||||||
selectedPointsIndices.reduce((map, index) => {
|
selectedPointsIndices.reduce((map, index) => {
|
||||||
map.set(index, {
|
map.set(index, {
|
||||||
point: angleLocked
|
point: LinearElementEditor.pointFromAbsoluteCoords(
|
||||||
? element.points[index]
|
element,
|
||||||
: LinearElementEditor.createPointAt(
|
pointFrom<GlobalPoint>(
|
||||||
element,
|
sceneCoords.x - linearElementEditor.pointerOffset.x,
|
||||||
elementsMap,
|
sceneCoords.y - linearElementEditor.pointerOffset.y,
|
||||||
sceneCoords.x - linearElementEditor.pointerOffset.x,
|
),
|
||||||
sceneCoords.y - linearElementEditor.pointerOffset.y,
|
elementsMap,
|
||||||
effectiveGridSize,
|
),
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}, new Map()) ?? new Map();
|
}, new Map()) ?? new Map();
|
||||||
|
|
||||||
bindOrUnbindBindingElement(
|
bindOrUnbindBindingElement(
|
||||||
element,
|
element,
|
||||||
draggedPoints,
|
draggedPoints,
|
||||||
sceneCoords.x,
|
sceneCoords.x - linearElementEditor.pointerOffset.x,
|
||||||
sceneCoords.y,
|
sceneCoords.y - linearElementEditor.pointerOffset.y,
|
||||||
scene,
|
scene,
|
||||||
appState,
|
appState,
|
||||||
{
|
{
|
||||||
newArrow,
|
newArrow,
|
||||||
altKey: event.altKey,
|
altKey: event.altKey,
|
||||||
angleLocked,
|
angleLocked: shouldRotateWithDiscreteAngle(event),
|
||||||
gridSize: app.getEffectiveGridSize(),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (isLineElement(element)) {
|
} else if (isLineElement(element)) {
|
||||||
|
|||||||
@@ -1995,9 +1995,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: isDarkTheme
|
background: applyDarkModeFilter(
|
||||||
? applyDarkModeFilter(this.state.viewBackgroundColor)
|
this.state.viewBackgroundColor,
|
||||||
: this.state.viewBackgroundColor,
|
isDarkTheme,
|
||||||
|
),
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
border: "none",
|
border: "none",
|
||||||
display: "block",
|
display: "block",
|
||||||
@@ -7263,16 +7264,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set suggested binding if we're hovering with an arrow tool
|
if (this.state.activeTool.type === "arrow") {
|
||||||
// and not dragging out a new element
|
|
||||||
if (this.state.activeTool.type === "arrow" && !this.state.newElement) {
|
|
||||||
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
|
|
||||||
const hit = getHoveredElementForBinding(
|
const hit = getHoveredElementForBinding(
|
||||||
scenePointer,
|
pointFrom<GlobalPoint>(scenePointerX, scenePointerY),
|
||||||
this.scene.getNonDeletedElements(),
|
this.scene.getNonDeletedElements(),
|
||||||
this.scene.getNonDeletedElementsMap(),
|
this.scene.getNonDeletedElementsMap(),
|
||||||
maxBindingDistance_simple(this.state.zoom),
|
maxBindingDistance_simple(this.state.zoom),
|
||||||
);
|
);
|
||||||
|
const scenePointer = pointFrom<GlobalPoint>(scenePointerX, scenePointerY);
|
||||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||||
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
|
if (hit && !isPointInElement(scenePointer, hit, elementsMap)) {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
actionClearCanvas,
|
actionClearCanvas,
|
||||||
actionLink,
|
actionLink,
|
||||||
actionToggleSearchMenu,
|
actionToggleSearchMenu,
|
||||||
|
actionToggleTheme,
|
||||||
} from "../../actions";
|
} from "../../actions";
|
||||||
import {
|
import {
|
||||||
actionCopyElementLink,
|
actionCopyElementLink,
|
||||||
@@ -424,6 +425,7 @@ function CommandPaletteInner({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const additionalCommands: CommandPaletteItem[] = [
|
const additionalCommands: CommandPaletteItem[] = [
|
||||||
|
actionToCommand(actionToggleTheme, DEFAULT_CATEGORIES.app),
|
||||||
{
|
{
|
||||||
label: t("toolBar.library"),
|
label: t("toolBar.library"),
|
||||||
category: DEFAULT_CATEGORIES.app,
|
category: DEFAULT_CATEGORIES.app,
|
||||||
|
|||||||
@@ -1,12 +1 @@
|
|||||||
import { actionToggleTheme } from "../../actions";
|
export {};
|
||||||
|
|
||||||
import type { CommandPaletteItem } from "./types";
|
|
||||||
|
|
||||||
export const toggleTheme: CommandPaletteItem = {
|
|
||||||
...actionToggleTheme,
|
|
||||||
category: "App",
|
|
||||||
label: "Toggle theme",
|
|
||||||
perform: ({ actionManager }) => {
|
|
||||||
actionManager.executeAction(actionToggleTheme, "commandPalette");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -831,14 +831,13 @@ const convertElementType = <
|
|||||||
newElement({
|
newElement({
|
||||||
...element,
|
...element,
|
||||||
type: targetType,
|
type: targetType,
|
||||||
roundness:
|
roundness: element.roundness
|
||||||
targetType === "diamond" && element.roundness
|
? {
|
||||||
? {
|
type: isUsingAdaptiveRadius(targetType)
|
||||||
type: isUsingAdaptiveRadius(targetType)
|
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
}
|
||||||
}
|
: element.roundness,
|
||||||
: element.roundness,
|
|
||||||
}),
|
}),
|
||||||
) as typeof element;
|
) as typeof element;
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
|
|||||||
|
|
||||||
import { KEYS } from "@excalidraw/common";
|
import { KEYS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { actionToggleTheme } from "../actions";
|
||||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getShortcutKey } from "../shortcut";
|
import { getShortcutKey } from "../shortcut";
|
||||||
|
|
||||||
|
import { useExcalidrawActionManager } from "./App";
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ const ShortcutKey = (props: { children: React.ReactNode }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||||
|
const actionManager = useExcalidrawActionManager();
|
||||||
const handleClose = React.useCallback(() => {
|
const handleClose = React.useCallback(() => {
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -302,10 +305,12 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("labels.viewMode")}
|
label={t("labels.viewMode")}
|
||||||
shortcuts={[getShortcutKey("Alt+R")]}
|
shortcuts={[getShortcutKey("Alt+R")]}
|
||||||
/>
|
/>
|
||||||
<Shortcut
|
{actionManager.isActionEnabled(actionToggleTheme) && (
|
||||||
label={t("labels.toggleTheme")}
|
<Shortcut
|
||||||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
label={t("labels.toggleTheme")}
|
||||||
/>
|
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("stats.fullTitle")}
|
label={t("stats.fullTitle")}
|
||||||
shortcuts={[getShortcutKey("Alt+/")]}
|
shortcuts={[getShortcutKey("Alt+/")]}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
border-radius: var(--border-radius-lg);
|
border-radius: var(--border-radius-lg);
|
||||||
padding: calc(var(--padding) * var(--space-factor));
|
padding: calc(var(--padding) * var(--space-factor));
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: box-shadow 0.5s ease-in-out;
|
|
||||||
|
|
||||||
&.zen-mode {
|
&.zen-mode {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const DefaultMainMenu: React.FC<{
|
|||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
</MainMenu.Group>
|
</MainMenu.Group>
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
<MainMenu.DefaultItems.ToggleTheme />
|
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
|
||||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||||
</MainMenu>
|
</MainMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
||||||
&__choice {
|
&__choice {
|
||||||
|
box-sizing: content-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -50,13 +51,11 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
|
|
||||||
transition: all 75ms ease-out;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--RadioGroup-choice-color-off-hover);
|
color: var(--RadioGroup-choice-color-off-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:not(.active):active {
|
||||||
background: var(--RadioGroup-choice-background-off-active);
|
background: var(--RadioGroup-choice-background-off-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,6 @@ const getRelevantAppStateProps = (
|
|||||||
newElement: appState.newElement,
|
newElement: appState.newElement,
|
||||||
isBindingEnabled: appState.isBindingEnabled,
|
isBindingEnabled: appState.isBindingEnabled,
|
||||||
isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled,
|
isMidpointSnappingEnabled: appState.isMidpointSnappingEnabled,
|
||||||
gridModeEnabled: appState.gridModeEnabled,
|
|
||||||
suggestedBinding: appState.suggestedBinding,
|
suggestedBinding: appState.suggestedBinding,
|
||||||
isRotating: appState.isRotating,
|
isRotating: appState.isRotating,
|
||||||
elementsToHighlight: appState.elementsToHighlight,
|
elementsToHighlight: appState.elementsToHighlight,
|
||||||
|
|||||||
@@ -232,18 +232,22 @@ export const ToggleTheme = (
|
|||||||
props:
|
props:
|
||||||
| {
|
| {
|
||||||
allowSystemTheme: true;
|
allowSystemTheme: true;
|
||||||
|
/**
|
||||||
|
* Controls the theme of this UI component only.
|
||||||
|
* You should subscribe to `props.onThemeChange` and control the theme
|
||||||
|
* upstream.
|
||||||
|
*/
|
||||||
theme: Theme | "system";
|
theme: Theme | "system";
|
||||||
onSelect: (theme: Theme | "system") => void;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
allowSystemTheme?: false;
|
allowSystemTheme: false;
|
||||||
onSelect?: (theme: Theme) => void;
|
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
const shortcut = getShortcutFromShortcutName("toggleTheme");
|
const shortcut = getShortcutFromShortcutName("toggleTheme");
|
||||||
|
const appProps = useAppProps();
|
||||||
|
|
||||||
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
if (!actionManager.isActionEnabled(actionToggleTheme)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -254,7 +258,16 @@ export const ToggleTheme = (
|
|||||||
<DropdownMenuItemContentRadio
|
<DropdownMenuItemContentRadio
|
||||||
name="theme"
|
name="theme"
|
||||||
value={props.theme}
|
value={props.theme}
|
||||||
onChange={(value: Theme | "system") => props.onSelect(value)}
|
onChange={(value: Theme | "system") => {
|
||||||
|
if (appProps.onThemeChange) {
|
||||||
|
appProps.onThemeChange(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
"MainMenu.DefaultItems.ToggleTheme: `<Excalidraw/> props.onThemeChange` must be defined to use system theme selection.",
|
||||||
|
);
|
||||||
|
}}
|
||||||
choices={[
|
choices={[
|
||||||
{
|
{
|
||||||
value: THEME.LIGHT,
|
value: THEME.LIGHT,
|
||||||
@@ -284,13 +297,7 @@ export const ToggleTheme = (
|
|||||||
// do not close the menu when changing theme
|
// do not close the menu when changing theme
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (props?.onSelect) {
|
actionManager.executeAction(actionToggleTheme);
|
||||||
props.onSelect(
|
|
||||||
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return actionManager.executeAction(actionToggleTheme);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
|
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
|
||||||
data-testid="toggle-dark-mode"
|
data-testid="toggle-dark-mode"
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ import { getNormalizedDimensions } from "@excalidraw/element";
|
|||||||
|
|
||||||
import { isInvisiblySmallElement } from "@excalidraw/element";
|
import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import {
|
||||||
|
elementOverlapsWithFrame,
|
||||||
|
isFrameLikeElement,
|
||||||
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import type { LocalPoint, Radians } from "@excalidraw/math";
|
import type { LocalPoint, Radians } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -744,20 +749,78 @@ const repairBoundElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an element's frameId if its containing frame is non-existent
|
* Remove an element's frameId if:
|
||||||
|
* - its containing frame is non-existent (or isn't actually a frame), or
|
||||||
|
* - the element no longer (precisely) overlaps its containing frame.
|
||||||
|
*
|
||||||
|
* Frame membership is treated atomically per group: a grouped element keeps its
|
||||||
|
* frameId as long as ANY element of its (outermost) group still overlaps the
|
||||||
|
* frame, so we never split a group across frame membership. Bound text inherits
|
||||||
|
* its container's geometry/membership.
|
||||||
*
|
*
|
||||||
* NOTE mutates elements.
|
* NOTE mutates elements.
|
||||||
*/
|
*/
|
||||||
const repairFrameMembership = (
|
const repairFrameMembership = (
|
||||||
element: Mutable<ExcalidrawElement>,
|
element: Mutable<ExcalidrawElement>,
|
||||||
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
|
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
|
||||||
|
/** elements bucketed by outermost groupId (see `restoreElements`) */
|
||||||
|
elementsByGroup: Map<string, Mutable<ExcalidrawElement>[]>,
|
||||||
|
/** memoizes the overlap decision per `${frameId}:${groupId}` */
|
||||||
|
checkedFrameGroups: Map<string, boolean>,
|
||||||
) => {
|
) => {
|
||||||
if (element.frameId) {
|
if (!element.frameId) {
|
||||||
const containingFrame = elementsMap.get(element.frameId);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!containingFrame) {
|
const containingFrame = elementsMap.get(element.frameId);
|
||||||
element.frameId = null;
|
|
||||||
|
// frame no longer exists (or the frameId doesn't point to a frame)
|
||||||
|
if (!containingFrame || !isFrameLikeElement(containingFrame)) {
|
||||||
|
element.frameId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't recompute geometry for deleted elements
|
||||||
|
if (element.isDeleted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a bound text follows its container's geometry / membership
|
||||||
|
const geometryElement =
|
||||||
|
isTextElement(element) && element.containerId
|
||||||
|
? elementsMap.get(element.containerId) ?? element
|
||||||
|
: element;
|
||||||
|
|
||||||
|
const outerGroupId =
|
||||||
|
geometryElement.groupIds[geometryElement.groupIds.length - 1];
|
||||||
|
|
||||||
|
let overlapsFrame: boolean;
|
||||||
|
if (outerGroupId) {
|
||||||
|
const cacheKey = `${containingFrame.id}:${outerGroupId}`;
|
||||||
|
const cached = checkedFrameGroups.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
overlapsFrame = cached;
|
||||||
|
} else {
|
||||||
|
// group membership is atomic — keep the element as long as ANY element
|
||||||
|
// of its group still overlaps the frame, so we never split a group.
|
||||||
|
const groupElements = elementsByGroup.get(outerGroupId) ?? [
|
||||||
|
geometryElement,
|
||||||
|
];
|
||||||
|
overlapsFrame = groupElements.some((groupElement) =>
|
||||||
|
elementOverlapsWithFrame(groupElement, containingFrame, elementsMap),
|
||||||
|
);
|
||||||
|
checkedFrameGroups.set(cacheKey, overlapsFrame);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
overlapsFrame = elementOverlapsWithFrame(
|
||||||
|
geometryElement,
|
||||||
|
containingFrame,
|
||||||
|
elementsMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overlapsFrame) {
|
||||||
|
element.frameId = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -836,9 +899,32 @@ export const restoreElements = <T extends ExcalidrawElement>(
|
|||||||
|
|
||||||
// repair binding. Mutates elements.
|
// repair binding. Mutates elements.
|
||||||
const restoredElementsMap = arrayToMap(restoredElements);
|
const restoredElementsMap = arrayToMap(restoredElements);
|
||||||
|
|
||||||
|
// index elements by their outermost groupId once, so frame-membership repair
|
||||||
|
// can resolve a group's members in O(1) instead of scanning all elements per
|
||||||
|
// group (getElementsInGroup) — a big win on large, heavily-grouped scenes.
|
||||||
|
const elementsByGroup = new Map<string, Mutable<ExcalidrawElement>[]>();
|
||||||
|
for (const element of restoredElements) {
|
||||||
|
const outerGroupId = element.groupIds[element.groupIds.length - 1];
|
||||||
|
if (outerGroupId) {
|
||||||
|
const members = elementsByGroup.get(outerGroupId);
|
||||||
|
if (members) {
|
||||||
|
members.push(element);
|
||||||
|
} else {
|
||||||
|
elementsByGroup.set(outerGroupId, [element]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedFrameGroups = new Map<string, boolean>();
|
||||||
for (const element of restoredElements) {
|
for (const element of restoredElements) {
|
||||||
if (element.frameId) {
|
if (element.frameId) {
|
||||||
repairFrameMembership(element, restoredElementsMap);
|
repairFrameMembership(
|
||||||
|
element,
|
||||||
|
restoredElementsMap,
|
||||||
|
elementsByGroup,
|
||||||
|
checkedFrameGroups,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTextElement(element) && element.containerId) {
|
if (isTextElement(element) && element.containerId) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyDarkModeFilter,
|
||||||
DEFAULT_IMAGE_OPTIONS,
|
DEFAULT_IMAGE_OPTIONS,
|
||||||
DEFAULT_UI_OPTIONS,
|
DEFAULT_UI_OPTIONS,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
@@ -66,6 +67,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
const {
|
const {
|
||||||
onExport,
|
onExport,
|
||||||
onChange,
|
onChange,
|
||||||
|
onThemeChange,
|
||||||
onIncrement,
|
onIncrement,
|
||||||
initialData,
|
initialData,
|
||||||
onExcalidrawAPI,
|
onExcalidrawAPI,
|
||||||
@@ -128,7 +130,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
UIOptions.canvasActions.toggleTheme === null &&
|
UIOptions.canvasActions.toggleTheme === null &&
|
||||||
typeof theme === "undefined"
|
(theme == null || onThemeChange)
|
||||||
) {
|
) {
|
||||||
UIOptions.canvasActions.toggleTheme = true;
|
UIOptions.canvasActions.toggleTheme = true;
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
<App
|
<App
|
||||||
onExport={onExport}
|
onExport={onExport}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onThemeChange={onThemeChange}
|
||||||
onIncrement={onIncrement}
|
onIncrement={onIncrement}
|
||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
onExcalidrawAPI={handleExcalidrawAPI}
|
onExcalidrawAPI={handleExcalidrawAPI}
|
||||||
@@ -397,11 +400,7 @@ export {
|
|||||||
convertToExcalidrawElements,
|
convertToExcalidrawElements,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
export {
|
export { elementsOverlappingBBox } from "@excalidraw/element";
|
||||||
elementsOverlappingBBox,
|
|
||||||
isElementInsideBBox,
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox,
|
|
||||||
} from "@excalidraw/utils/withinBounds";
|
|
||||||
|
|
||||||
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
|
||||||
export { getDataURL } from "./data/blob";
|
export { getDataURL } from "./data/blob";
|
||||||
@@ -450,3 +449,5 @@ export function useExcalidrawStateValue(
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export { _useOnAppStateChange as useOnExcalidrawStateChange };
|
export { _useOnAppStateChange as useOnExcalidrawStateChange };
|
||||||
|
|
||||||
|
export { applyDarkModeFilter };
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export const bootstrapCanvas = ({
|
|||||||
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
}
|
}
|
||||||
context.save();
|
context.save();
|
||||||
context.fillStyle =
|
context.fillStyle = applyDarkModeFilter(
|
||||||
theme === THEME.DARK
|
viewBackgroundColor,
|
||||||
? applyDarkModeFilter(viewBackgroundColor)
|
theme === THEME.DARK,
|
||||||
: viewBackgroundColor;
|
);
|
||||||
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
|
||||||
context.restore();
|
context.restore();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
getFeatureFlag,
|
getFeatureFlag,
|
||||||
invariant,
|
invariant,
|
||||||
shouldRotateWithDiscreteAngle,
|
|
||||||
THEME,
|
THEME,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
@@ -230,7 +229,6 @@ const renderBindingHighlightForBindableElement_simple = (
|
|||||||
elementsMap: ElementsMap,
|
elementsMap: ElementsMap,
|
||||||
appState: InteractiveCanvasAppState,
|
appState: InteractiveCanvasAppState,
|
||||||
pointerCoords: GlobalPoint | null,
|
pointerCoords: GlobalPoint | null,
|
||||||
angleLocked = false,
|
|
||||||
) => {
|
) => {
|
||||||
const enclosingFrame =
|
const enclosingFrame =
|
||||||
suggestedBinding.element.frameId &&
|
suggestedBinding.element.frameId &&
|
||||||
@@ -417,8 +415,6 @@ const renderBindingHighlightForBindableElement_simple = (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
appState.isMidpointSnappingEnabled &&
|
appState.isMidpointSnappingEnabled &&
|
||||||
!appState.gridModeEnabled &&
|
|
||||||
!angleLocked &&
|
|
||||||
(isFrameLikeElement(suggestedBinding.element) ||
|
(isFrameLikeElement(suggestedBinding.element) ||
|
||||||
isBindableElement(suggestedBinding.element))
|
isBindableElement(suggestedBinding.element))
|
||||||
) {
|
) {
|
||||||
@@ -811,12 +807,7 @@ const renderBindingHighlightForBindableElement_complex = (
|
|||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
if (
|
if (appState.isMidpointSnappingEnabled) {
|
||||||
appState.isMidpointSnappingEnabled &&
|
|
||||||
!appState.gridModeEnabled &&
|
|
||||||
(!app.lastPointerMoveEvent ||
|
|
||||||
!shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent))
|
|
||||||
) {
|
|
||||||
// Draw midpoint indicators
|
// Draw midpoint indicators
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(
|
context.translate(
|
||||||
@@ -929,16 +920,12 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
app.lastPointerMoveCoords.y,
|
app.lastPointerMoveCoords.y,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const angleLocked =
|
|
||||||
!!app.lastPointerMoveEvent &&
|
|
||||||
shouldRotateWithDiscreteAngle(app.lastPointerMoveEvent);
|
|
||||||
renderBindingHighlightForBindableElement_simple(
|
renderBindingHighlightForBindableElement_simple(
|
||||||
context,
|
context,
|
||||||
suggestedBinding,
|
suggestedBinding,
|
||||||
allElementsMap,
|
allElementsMap,
|
||||||
appState,
|
appState,
|
||||||
pointerCoords,
|
pointerCoords,
|
||||||
angleLocked,
|
|
||||||
);
|
);
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -291,6 +291,14 @@ const renderElementToSvg = (
|
|||||||
);
|
);
|
||||||
offsetX = offsetX || 0;
|
offsetX = offsetX || 0;
|
||||||
offsetY = offsetY || 0;
|
offsetY = offsetY || 0;
|
||||||
|
// Pin the mask to user space; the default maskUnits="objectBoundingBox"
|
||||||
|
// collapses to zero area for axis-aligned arrows (zero-size bbox),
|
||||||
|
// hiding the whole line from SVG exports (#11439).
|
||||||
|
maskPath.setAttribute("maskUnits", "userSpaceOnUse");
|
||||||
|
maskPath.setAttribute("x", "0");
|
||||||
|
maskPath.setAttribute("y", "0");
|
||||||
|
maskPath.setAttribute("width", `${element.width + 100 + offsetX}`);
|
||||||
|
maskPath.setAttribute("height", `${element.height + 100 + offsetY}`);
|
||||||
maskRectVisible.setAttribute("x", "0");
|
maskRectVisible.setAttribute("x", "0");
|
||||||
maskRectVisible.setAttribute("y", "0");
|
maskRectVisible.setAttribute("y", "0");
|
||||||
maskRectVisible.setAttribute("fill", "#fff");
|
maskRectVisible.setAttribute("fill", "#fff");
|
||||||
@@ -386,9 +394,10 @@ const renderElementToSvg = (
|
|||||||
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
|
const path = svgRoot.ownerDocument.createElementNS(SVG_NS, "path");
|
||||||
path.setAttribute(
|
path.setAttribute(
|
||||||
"fill",
|
"fill",
|
||||||
renderConfig.theme === THEME.DARK
|
applyDarkModeFilter(
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
element.strokeColor,
|
||||||
: element.strokeColor,
|
renderConfig.theme === THEME.DARK,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
path.setAttribute("d", shape);
|
path.setAttribute("d", shape);
|
||||||
wrapper.appendChild(path);
|
wrapper.appendChild(path);
|
||||||
@@ -621,9 +630,10 @@ const renderElementToSvg = (
|
|||||||
rect.setAttribute("fill", "none");
|
rect.setAttribute("fill", "none");
|
||||||
rect.setAttribute(
|
rect.setAttribute(
|
||||||
"stroke",
|
"stroke",
|
||||||
renderConfig.theme === THEME.DARK
|
applyDarkModeFilter(
|
||||||
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
|
FRAME_STYLE.strokeColor,
|
||||||
: FRAME_STYLE.strokeColor,
|
renderConfig.theme === THEME.DARK,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
|
||||||
|
|
||||||
@@ -677,9 +687,10 @@ const renderElementToSvg = (
|
|||||||
text.setAttribute("font-size", `${element.fontSize}px`);
|
text.setAttribute("font-size", `${element.fontSize}px`);
|
||||||
text.setAttribute(
|
text.setAttribute(
|
||||||
"fill",
|
"fill",
|
||||||
renderConfig.theme === THEME.DARK
|
applyDarkModeFilter(
|
||||||
? applyDarkModeFilter(element.strokeColor)
|
element.strokeColor,
|
||||||
: element.strokeColor,
|
renderConfig.theme === THEME.DARK,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
text.setAttribute("text-anchor", textAnchor);
|
text.setAttribute("text-anchor", textAnchor);
|
||||||
text.setAttribute("style", "white-space: pre;");
|
text.setAttribute("style", "white-space: pre;");
|
||||||
|
|||||||
@@ -459,9 +459,7 @@ export const exportToSvg = async (
|
|||||||
rect.setAttribute("height", `${height}`);
|
rect.setAttribute("height", `${height}`);
|
||||||
rect.setAttribute(
|
rect.setAttribute(
|
||||||
"fill",
|
"fill",
|
||||||
exportWithDarkMode
|
applyDarkModeFilter(viewBackgroundColor, exportWithDarkMode),
|
||||||
? applyDarkModeFilter(viewBackgroundColor)
|
|
||||||
: viewBackgroundColor,
|
|
||||||
);
|
);
|
||||||
svgRoot.appendChild(rect);
|
svgRoot.appendChild(rect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ROUNDNESS } from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { convertElementTypes } from "../components/ConvertElementTypePopup";
|
||||||
|
import { Excalidraw } from "../index";
|
||||||
|
|
||||||
|
import { API } from "./helpers/api";
|
||||||
|
import { act, render } from "./test-utils";
|
||||||
|
|
||||||
|
const { h } = window;
|
||||||
|
|
||||||
|
describe("convert element type", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #9662
|
||||||
|
it("recalculates roundness type when switching between generic shapes", () => {
|
||||||
|
const rectangle = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
roundness: { type: ROUNDNESS.ADAPTIVE_RADIUS }, // Dooesn't matter as long as it is set
|
||||||
|
});
|
||||||
|
|
||||||
|
API.setElements([rectangle]);
|
||||||
|
API.setSelectedElements([rectangle]);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
convertElementTypes(h.app, {
|
||||||
|
conversionType: "generic",
|
||||||
|
nextType: "diamond",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.elements[0].type).toBe("diamond");
|
||||||
|
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.PROPORTIONAL_RADIUS);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
convertElementTypes(h.app, {
|
||||||
|
conversionType: "generic",
|
||||||
|
nextType: "rectangle",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(h.elements[0].type).toBe("rectangle");
|
||||||
|
expect(h.elements[0].roundness?.type).toBe(ROUNDNESS.ADAPTIVE_RADIUS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -154,7 +154,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||||||
"opacity": 10,
|
"opacity": 10,
|
||||||
"roughness": 2,
|
"roughness": 2,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"strokeColor": "red",
|
"strokeColor": "red",
|
||||||
@@ -192,7 +192,7 @@ exports[`restoreElements > should restore correctly with rectangle, ellipse and
|
|||||||
"opacity": 10,
|
"opacity": 10,
|
||||||
"roughness": 2,
|
"roughness": 2,
|
||||||
"roundness": {
|
"roundness": {
|
||||||
"type": 3,
|
"type": 2,
|
||||||
},
|
},
|
||||||
"seed": Any<Number>,
|
"seed": Any<Number>,
|
||||||
"strokeColor": "red",
|
"strokeColor": "red",
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { pointFrom } from "@excalidraw/math";
|
import { pointFrom } from "@excalidraw/math";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
|
import {
|
||||||
|
arrayToMap,
|
||||||
|
DEFAULT_SIDEBAR,
|
||||||
|
FONT_FAMILY,
|
||||||
|
ROUNDNESS,
|
||||||
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import { newElementWith } from "@excalidraw/element";
|
import { newElementWith } from "@excalidraw/element";
|
||||||
import * as sizeHelpers from "@excalidraw/element";
|
import * as sizeHelpers from "@excalidraw/element";
|
||||||
@@ -1140,3 +1145,201 @@ describe("repairing bindings", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("repairing frame membership", () => {
|
||||||
|
const makeFrame = () =>
|
||||||
|
API.createElement({
|
||||||
|
type: "frame",
|
||||||
|
id: "frame",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps frameId for an element fully inside the frame", () => {
|
||||||
|
const frame = makeFrame();
|
||||||
|
const inside = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "inside",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frame, inside], null, { repairBindings: true }),
|
||||||
|
);
|
||||||
|
expect(restoredElementsMap.get("inside")?.frameId).toBe(frame.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps frameId for an element intersecting the frame border", () => {
|
||||||
|
const frame = makeFrame();
|
||||||
|
// spans x:[90,130] — crosses the frame's right edge (x=100)
|
||||||
|
const straddling = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "straddling",
|
||||||
|
x: 90,
|
||||||
|
y: 10,
|
||||||
|
width: 40,
|
||||||
|
height: 20,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frame, straddling], null, {
|
||||||
|
repairBindings: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(restoredElementsMap.get("straddling")?.frameId).toBe(frame.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes frameId for an element fully outside the frame", () => {
|
||||||
|
const frame = makeFrame();
|
||||||
|
const outside = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "outside",
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frame, outside], null, { repairBindings: true }),
|
||||||
|
);
|
||||||
|
expect(restoredElementsMap.get("outside")?.frameId).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes frameId when the containing frame no longer exists", () => {
|
||||||
|
const orphan = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "orphan",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
frameId: "non-existent-frame",
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([orphan], null, { repairBindings: true }),
|
||||||
|
);
|
||||||
|
expect(restoredElementsMap.get("orphan")?.frameId).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT touch frameId when repairBindings is off", () => {
|
||||||
|
const frame = makeFrame();
|
||||||
|
const outside = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "outside",
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frame, outside], null, {
|
||||||
|
repairBindings: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(restoredElementsMap.get("outside")?.frameId).toBe(frame.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a group atomic: members stay if ANY group member overlaps", () => {
|
||||||
|
const frame = makeFrame();
|
||||||
|
const insideMember = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "insideMember",
|
||||||
|
x: 10,
|
||||||
|
y: 10,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
groupIds: ["g1"],
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const outsideMember = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "outsideMember",
|
||||||
|
x: 300,
|
||||||
|
y: 300,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
groupIds: ["g1"],
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frame, insideMember, outsideMember], null, {
|
||||||
|
repairBindings: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// the whole group overlaps (via insideMember), so neither is evicted
|
||||||
|
expect(restoredElementsMap.get("insideMember")?.frameId).toBe(frame.id);
|
||||||
|
expect(restoredElementsMap.get("outsideMember")?.frameId).toBe(frame.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes frameId for a whole group when no member overlaps", () => {
|
||||||
|
const frame = makeFrame();
|
||||||
|
const a = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "a",
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
groupIds: ["g1"],
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
const b = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "b",
|
||||||
|
x: 300,
|
||||||
|
y: 300,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
groupIds: ["g1"],
|
||||||
|
frameId: frame.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frame, a, b], null, { repairBindings: true }),
|
||||||
|
);
|
||||||
|
expect(restoredElementsMap.get("a")?.frameId).toBe(null);
|
||||||
|
expect(restoredElementsMap.get("b")?.frameId).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bound text follows its container's frame membership", () => {
|
||||||
|
const frameOutside = makeFrame();
|
||||||
|
const container = API.createElement({
|
||||||
|
type: "rectangle",
|
||||||
|
id: "container",
|
||||||
|
x: 300,
|
||||||
|
y: 300,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
frameId: frameOutside.id,
|
||||||
|
boundElements: [{ type: "text", id: "label" }],
|
||||||
|
});
|
||||||
|
const label = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
id: "label",
|
||||||
|
x: 305,
|
||||||
|
y: 305,
|
||||||
|
containerId: container.id,
|
||||||
|
frameId: frameOutside.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const restoredElementsMap = arrayToMap(
|
||||||
|
restore.restoreElements([frameOutside, container, label], null, {
|
||||||
|
repairBindings: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// container is fully outside -> both it and its bound text leave the frame
|
||||||
|
expect(restoredElementsMap.get("container")?.frameId).toBe(null);
|
||||||
|
expect(restoredElementsMap.get("label")?.frameId).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { queryByText, queryByTestId } from "@testing-library/react";
|
import { queryByText, queryByTestId } from "@testing-library/react";
|
||||||
import React from "react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { THEME } from "@excalidraw/common";
|
import { THEME } from "@excalidraw/common";
|
||||||
@@ -433,7 +432,7 @@ describe("<Excalidraw/>", () => {
|
|||||||
const customMenu = useMemo(() => {
|
const customMenu = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<MainMenu>
|
<MainMenu>
|
||||||
<MainMenu.DefaultItems.ToggleTheme />
|
<MainMenu.DefaultItems.ToggleTheme allowSystemTheme={false} />
|
||||||
</MainMenu>
|
</MainMenu>
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -457,5 +456,32 @@ describe("<Excalidraw/>", () => {
|
|||||||
queryByTestId(container, "toggle-dark-mode")?.textContent,
|
queryByTestId(container, "toggle-dark-mode")?.textContent,
|
||||||
).toContain(t("buttons.lightMode"));
|
).toContain(t("buttons.lightMode"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should show theme toggle when the theme prop and onThemeChange are defined", async () => {
|
||||||
|
const onThemeChange = vi.fn();
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw theme={THEME.DARK} onThemeChange={onThemeChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(h.state.theme).toBe(THEME.DARK);
|
||||||
|
//open menu
|
||||||
|
toggleMenu(container);
|
||||||
|
const darkModeToggle = queryByTestId(container, "toggle-dark-mode");
|
||||||
|
expect(darkModeToggle).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onThemeChange instead of mutating theme when defined", async () => {
|
||||||
|
const onThemeChange = vi.fn();
|
||||||
|
const { container } = await render(
|
||||||
|
<Excalidraw theme={THEME.LIGHT} onThemeChange={onThemeChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
//open menu
|
||||||
|
toggleMenu(container);
|
||||||
|
fireEvent.click(queryByTestId(container, "toggle-dark-mode")!);
|
||||||
|
|
||||||
|
expect(onThemeChange).toHaveBeenCalledWith(THEME.DARK);
|
||||||
|
expect(h.state.theme).toBe(THEME.LIGHT);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
newTextElement,
|
newTextElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { isLinearElementType } from "@excalidraw/element";
|
import { isUsingAdaptiveRadius, getSelectedElements } from "@excalidraw/element";
|
||||||
import { getSelectedElements } from "@excalidraw/element";
|
|
||||||
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
import { selectGroupsForSelectedElements } from "@excalidraw/element";
|
||||||
|
|
||||||
import { FONT_SIZES } from "@excalidraw/common";
|
import { FONT_SIZES } from "@excalidraw/common";
|
||||||
@@ -267,9 +266,9 @@ export class API {
|
|||||||
: rest.roundness
|
: rest.roundness
|
||||||
)
|
)
|
||||||
? {
|
? {
|
||||||
type: isLinearElementType(type)
|
type: isUsingAdaptiveRadius(type)
|
||||||
? ROUNDNESS.PROPORTIONAL_RADIUS
|
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||||
: ROUNDNESS.ADAPTIVE_RADIUS,
|
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
roughness: rest.roughness ?? appState.currentItemRoughness,
|
roughness: rest.roughness ?? appState.currentItemRoughness,
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type {
|
||||||
|
ExcalidrawElement,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
|
import { exportToSvg } from "../../scene/export";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only helper: render the supplied elements to an SVG string for visual
|
||||||
|
* debugging (e.g. to eyeball frame-membership / overlap geometry).
|
||||||
|
*
|
||||||
|
* Frames are drawn with their outline but are NOT clipped, and every element's
|
||||||
|
* `frameId` is ignored, so elements that fall outside a frame stay visible and
|
||||||
|
* within the exported viewBox instead of being clipped or cropped away.
|
||||||
|
*
|
||||||
|
* Pass `outFile` to also write the SVG to disk — open it in a browser to view.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* await exportElementsToSVG([frame, diamond], {
|
||||||
|
* outFile: "/tmp/frame-debug.svg",
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export const exportElementsToSVG = async (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
opts?: { outFile?: string; padding?: number; background?: string },
|
||||||
|
): Promise<string> => {
|
||||||
|
// ignore frame membership so nothing gets clipped/cropped out of view
|
||||||
|
const rootElements = elements.map(
|
||||||
|
(element) => ({ ...element, frameId: null } as NonDeletedExcalidrawElement),
|
||||||
|
);
|
||||||
|
|
||||||
|
const svg = await exportToSvg(
|
||||||
|
rootElements,
|
||||||
|
{
|
||||||
|
exportBackground: true,
|
||||||
|
viewBackgroundColor: opts?.background ?? "#ffffff",
|
||||||
|
exportPadding: opts?.padding ?? 24,
|
||||||
|
frameRendering: { enabled: true, name: true, outline: true, clip: false },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ skipInliningFonts: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const svgString = svg.outerHTML;
|
||||||
|
|
||||||
|
if (opts?.outFile) {
|
||||||
|
const { writeFileSync } = await import("fs");
|
||||||
|
writeFileSync(opts.outFile, svgString);
|
||||||
|
}
|
||||||
|
|
||||||
|
return svgString;
|
||||||
|
};
|
||||||
@@ -6,12 +6,16 @@ import {
|
|||||||
FRAME_STYLE,
|
FRAME_STYLE,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
|
import { pointFrom } from "@excalidraw/math";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
|
import type { LocalPoint } from "@excalidraw/math";
|
||||||
|
|
||||||
import { prepareElementsForExport } from "../../data";
|
import { prepareElementsForExport } from "../../data";
|
||||||
import * as exportUtils from "../../scene/export";
|
import * as exportUtils from "../../scene/export";
|
||||||
import {
|
import {
|
||||||
@@ -192,6 +196,45 @@ describe("exportToSvg", () => {
|
|||||||
);
|
);
|
||||||
expect(svgElement.innerHTML).toMatchSnapshot();
|
expect(svgElement.innerHTML).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #11439: a perfectly horizontal/vertical arrow has a zero-size bounding box.
|
||||||
|
// The bound-text "gap" mask must use userSpaceOnUse units, otherwise its
|
||||||
|
// objectBoundingBox region collapses to zero area and the whole arrow line
|
||||||
|
// disappears from the SVG export (only the label remains).
|
||||||
|
it("keeps a horizontal arrow with a bound label visible (#11439)", async () => {
|
||||||
|
const arrow = API.createElement({
|
||||||
|
type: "arrow",
|
||||||
|
id: "arrow-11439",
|
||||||
|
width: 200,
|
||||||
|
height: 0,
|
||||||
|
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(200, 0)],
|
||||||
|
boundElements: [{ type: "text", id: "label-11439" }],
|
||||||
|
});
|
||||||
|
const label = API.createElement({
|
||||||
|
type: "text",
|
||||||
|
id: "label-11439",
|
||||||
|
text: "label",
|
||||||
|
width: 50,
|
||||||
|
height: 20,
|
||||||
|
containerId: "arrow-11439",
|
||||||
|
});
|
||||||
|
|
||||||
|
const svgElement = await exportUtils.exportToSvg(
|
||||||
|
[arrow, label] as NonDeletedExcalidrawElement[],
|
||||||
|
DEFAULT_OPTIONS,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mask = svgElement.querySelector("mask");
|
||||||
|
expect(mask).not.toBeNull();
|
||||||
|
expect(mask?.getAttribute("maskUnits")).toBe("userSpaceOnUse");
|
||||||
|
// a degenerate (objectBoundingBox) region would be zero-area here
|
||||||
|
expect(Number(mask?.getAttribute("width"))).toBeGreaterThan(0);
|
||||||
|
expect(Number(mask?.getAttribute("height"))).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// the masked arrow group still renders its line (not clipped away)
|
||||||
|
expect(svgElement.querySelector("g[mask] path")).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("exporting frames", () => {
|
describe("exporting frames", () => {
|
||||||
|
|||||||
@@ -224,7 +224,6 @@ export type InteractiveCanvasAppState = Readonly<
|
|||||||
newElement: AppState["newElement"];
|
newElement: AppState["newElement"];
|
||||||
isBindingEnabled: AppState["isBindingEnabled"];
|
isBindingEnabled: AppState["isBindingEnabled"];
|
||||||
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
|
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
|
||||||
gridModeEnabled: AppState["gridModeEnabled"];
|
|
||||||
suggestedBinding: AppState["suggestedBinding"];
|
suggestedBinding: AppState["suggestedBinding"];
|
||||||
isRotating: AppState["isRotating"];
|
isRotating: AppState["isRotating"];
|
||||||
elementsToHighlight: AppState["elementsToHighlight"];
|
elementsToHighlight: AppState["elementsToHighlight"];
|
||||||
@@ -574,6 +573,7 @@ export interface ExcalidrawProps {
|
|||||||
appState: AppState,
|
appState: AppState,
|
||||||
files: BinaryFiles,
|
files: BinaryFiles,
|
||||||
) => void;
|
) => void;
|
||||||
|
onThemeChange?: (theme: Theme | "system") => void;
|
||||||
/**
|
/**
|
||||||
* note: only subscribes if the props.onIncrement is defined on initial render
|
* note: only subscribes if the props.onIncrement is defined on initial render
|
||||||
*/
|
*/
|
||||||
@@ -751,6 +751,11 @@ export type CanvasActions = Partial<{
|
|||||||
export: false | ExportOpts;
|
export: false | ExportOpts;
|
||||||
loadScene: boolean;
|
loadScene: boolean;
|
||||||
saveToActiveFile: boolean;
|
saveToActiveFile: boolean;
|
||||||
|
/**
|
||||||
|
* defaults to true if `props.theme` is omitted or `props.onThemeChange`
|
||||||
|
* is supplied (at which point the theme is considered as host-app controlled),
|
||||||
|
* else default to false
|
||||||
|
* */
|
||||||
toggleTheme: boolean | null;
|
toggleTheme: boolean | null;
|
||||||
saveAsImage: boolean;
|
saveAsImage: boolean;
|
||||||
}>;
|
}>;
|
||||||
@@ -846,7 +851,6 @@ export type AppClassProperties = {
|
|||||||
onStateChange: App["onStateChange"];
|
onStateChange: App["onStateChange"];
|
||||||
|
|
||||||
lastPointerMoveCoords: App["lastPointerMoveCoords"];
|
lastPointerMoveCoords: App["lastPointerMoveCoords"];
|
||||||
lastPointerMoveEvent: App["lastPointerMoveEvent"];
|
|
||||||
bindModeHandler: App["bindModeHandler"];
|
bindModeHandler: App["bindModeHandler"];
|
||||||
|
|
||||||
setAppState: App["setAppState"];
|
setAppState: App["setAppState"];
|
||||||
|
|||||||
@@ -392,10 +392,10 @@ export const textWysiwyg = ({
|
|||||||
),
|
),
|
||||||
textAlign,
|
textAlign,
|
||||||
verticalAlign,
|
verticalAlign,
|
||||||
color:
|
color: applyDarkModeFilter(
|
||||||
appState.theme === THEME.DARK
|
updatedTextElement.strokeColor,
|
||||||
? applyDarkModeFilter(updatedTextElement.strokeColor)
|
appState.theme === THEME.DARK,
|
||||||
: updatedTextElement.strokeColor,
|
),
|
||||||
opacity: updatedTextElement.opacity / 100,
|
opacity: updatedTextElement.opacity / 100,
|
||||||
maxHeight: `${editorMaxHeight}px`,
|
maxHeight: `${editorMaxHeight}px`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import {
|
|
||||||
vectorCross,
|
|
||||||
vectorFromPoint,
|
|
||||||
type GlobalPoint,
|
|
||||||
type LocalPoint,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type { Bounds } from "@excalidraw/common";
|
|
||||||
|
|
||||||
export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
|
|
||||||
|
|
||||||
export function getBBox<P extends LocalPoint | GlobalPoint>(
|
|
||||||
line: LineSegment<P>,
|
|
||||||
): Bounds {
|
|
||||||
return [
|
|
||||||
Math.min(line[0][0], line[1][0]),
|
|
||||||
Math.min(line[0][1], line[1][1]),
|
|
||||||
Math.max(line[0][0], line[1][0]),
|
|
||||||
Math.max(line[0][1], line[1][1]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function doBBoxesIntersect(a: Bounds, b: Bounds) {
|
|
||||||
return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
const EPSILON = 0.000001;
|
|
||||||
|
|
||||||
export function isPointOnLine<P extends GlobalPoint | LocalPoint>(
|
|
||||||
l: LineSegment<P>,
|
|
||||||
p: P,
|
|
||||||
) {
|
|
||||||
const p1 = vectorFromPoint(l[1], l[0]);
|
|
||||||
const p2 = vectorFromPoint(p, l[0]);
|
|
||||||
|
|
||||||
const r = vectorCross(p1, p2);
|
|
||||||
|
|
||||||
return Math.abs(r) < EPSILON;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPointRightOfLine<P extends GlobalPoint | LocalPoint>(
|
|
||||||
l: LineSegment<P>,
|
|
||||||
p: P,
|
|
||||||
) {
|
|
||||||
const p1 = vectorFromPoint(l[1], l[0]);
|
|
||||||
const p2 = vectorFromPoint(p, l[0]);
|
|
||||||
|
|
||||||
return vectorCross(p1, p2) < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLineSegmentTouchingOrCrossingLine<
|
|
||||||
P extends GlobalPoint | LocalPoint,
|
|
||||||
>(a: LineSegment<P>, b: LineSegment<P>) {
|
|
||||||
return (
|
|
||||||
isPointOnLine(a, b[0]) ||
|
|
||||||
isPointOnLine(a, b[1]) ||
|
|
||||||
(isPointRightOfLine(a, b[0])
|
|
||||||
? !isPointRightOfLine(a, b[1])
|
|
||||||
: isPointRightOfLine(a, b[1]))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
|
|
||||||
export function doLineSegmentsIntersect<P extends GlobalPoint | LocalPoint>(
|
|
||||||
a: LineSegment<P>,
|
|
||||||
b: LineSegment<P>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
|
|
||||||
isLineSegmentTouchingOrCrossingLine(a, b) &&
|
|
||||||
isLineSegmentTouchingOrCrossingLine(b, a)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from "./export";
|
export * from "./export";
|
||||||
export * from "./withinBounds";
|
export { elementsOverlappingBBox } from "@excalidraw/element";
|
||||||
export * from "./bbox";
|
|
||||||
export { getCommonBounds } from "@excalidraw/element";
|
export { getCommonBounds } from "@excalidraw/element";
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import { arrayToMap, type Bounds } from "@excalidraw/common";
|
|
||||||
import { getElementBounds } from "@excalidraw/element";
|
|
||||||
import {
|
|
||||||
isArrowElement,
|
|
||||||
isExcalidrawElement,
|
|
||||||
isFreeDrawElement,
|
|
||||||
isLinearElement,
|
|
||||||
isTextElement,
|
|
||||||
} from "@excalidraw/element";
|
|
||||||
import {
|
|
||||||
rangeIncludesValue,
|
|
||||||
pointFrom,
|
|
||||||
pointRotateRads,
|
|
||||||
rangeInclusive,
|
|
||||||
} from "@excalidraw/math";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ExcalidrawElement,
|
|
||||||
ExcalidrawFreeDrawElement,
|
|
||||||
ExcalidrawLinearElement,
|
|
||||||
NonDeletedExcalidrawElement,
|
|
||||||
} from "@excalidraw/element/types";
|
|
||||||
import type { LocalPoint } from "@excalidraw/math";
|
|
||||||
|
|
||||||
type Element = NonDeletedExcalidrawElement;
|
|
||||||
type Elements = readonly NonDeletedExcalidrawElement[];
|
|
||||||
|
|
||||||
type Points = readonly LocalPoint[];
|
|
||||||
|
|
||||||
/** @returns vertices relative to element's top-left [0,0] position */
|
|
||||||
const getNonLinearElementRelativePoints = (
|
|
||||||
element: Exclude<
|
|
||||||
Element,
|
|
||||||
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
|
|
||||||
>,
|
|
||||||
): [
|
|
||||||
TopLeft: LocalPoint,
|
|
||||||
TopRight: LocalPoint,
|
|
||||||
BottomRight: LocalPoint,
|
|
||||||
BottomLeft: LocalPoint,
|
|
||||||
] => {
|
|
||||||
if (element.type === "diamond") {
|
|
||||||
return [
|
|
||||||
pointFrom(element.width / 2, 0),
|
|
||||||
pointFrom(element.width, element.height / 2),
|
|
||||||
pointFrom(element.width / 2, element.height),
|
|
||||||
pointFrom(0, element.height / 2),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
pointFrom(0, 0),
|
|
||||||
pointFrom(0 + element.width, 0),
|
|
||||||
pointFrom(0 + element.width, element.height),
|
|
||||||
pointFrom(0, element.height),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @returns vertices relative to element's top-left [0,0] position */
|
|
||||||
const getElementRelativePoints = (element: ExcalidrawElement): Points => {
|
|
||||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
|
||||||
return element.points;
|
|
||||||
}
|
|
||||||
return getNonLinearElementRelativePoints(element);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMinMaxPoints = (points: Points) => {
|
|
||||||
const ret = points.reduce(
|
|
||||||
(limits, [x, y]) => {
|
|
||||||
limits.minY = Math.min(limits.minY, y);
|
|
||||||
limits.minX = Math.min(limits.minX, x);
|
|
||||||
|
|
||||||
limits.maxX = Math.max(limits.maxX, x);
|
|
||||||
limits.maxY = Math.max(limits.maxY, y);
|
|
||||||
|
|
||||||
return limits;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
minX: Infinity,
|
|
||||||
minY: Infinity,
|
|
||||||
maxX: -Infinity,
|
|
||||||
maxY: -Infinity,
|
|
||||||
cx: 0,
|
|
||||||
cy: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ret.cx = (ret.maxX + ret.minX) / 2;
|
|
||||||
ret.cy = (ret.maxY + ret.minY) / 2;
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRotatedBBox = (element: Element): Bounds => {
|
|
||||||
const points = getElementRelativePoints(element);
|
|
||||||
|
|
||||||
const { cx, cy } = getMinMaxPoints(points);
|
|
||||||
const centerPoint = pointFrom<LocalPoint>(cx, cy);
|
|
||||||
|
|
||||||
const rotatedPoints = points.map((p) =>
|
|
||||||
pointRotateRads(p, centerPoint, element.angle),
|
|
||||||
);
|
|
||||||
const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
|
|
||||||
|
|
||||||
return [
|
|
||||||
minX + element.x,
|
|
||||||
minY + element.y,
|
|
||||||
maxX + element.x,
|
|
||||||
maxY + element.y,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isElementInsideBBox = (
|
|
||||||
element: Element,
|
|
||||||
bbox: Bounds,
|
|
||||||
eitherDirection = false,
|
|
||||||
): boolean => {
|
|
||||||
const elementBBox = getRotatedBBox(element);
|
|
||||||
|
|
||||||
const elementInsideBbox =
|
|
||||||
bbox[0] <= elementBBox[0] &&
|
|
||||||
bbox[2] >= elementBBox[2] &&
|
|
||||||
bbox[1] <= elementBBox[1] &&
|
|
||||||
bbox[3] >= elementBBox[3];
|
|
||||||
|
|
||||||
if (!eitherDirection) {
|
|
||||||
return elementInsideBbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elementInsideBbox) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
elementBBox[0] <= bbox[0] &&
|
|
||||||
elementBBox[2] >= bbox[2] &&
|
|
||||||
elementBBox[1] <= bbox[1] &&
|
|
||||||
elementBBox[3] >= bbox[3]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const elementPartiallyOverlapsWithOrContainsBBox = (
|
|
||||||
element: Element,
|
|
||||||
bbox: Bounds,
|
|
||||||
): boolean => {
|
|
||||||
const elementBBox = getRotatedBBox(element);
|
|
||||||
|
|
||||||
return (
|
|
||||||
(rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) ||
|
|
||||||
rangeIncludesValue(
|
|
||||||
bbox[0],
|
|
||||||
rangeInclusive(elementBBox[0], elementBBox[2]),
|
|
||||||
)) &&
|
|
||||||
(rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) ||
|
|
||||||
rangeIncludesValue(
|
|
||||||
bbox[1],
|
|
||||||
rangeInclusive(elementBBox[1], elementBBox[3]),
|
|
||||||
))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const elementsOverlappingBBox = ({
|
|
||||||
elements,
|
|
||||||
bounds,
|
|
||||||
type,
|
|
||||||
errorMargin = 0,
|
|
||||||
}: {
|
|
||||||
elements: Elements;
|
|
||||||
bounds: Bounds | ExcalidrawElement;
|
|
||||||
/** safety offset. Defaults to 0. */
|
|
||||||
errorMargin?: number;
|
|
||||||
/**
|
|
||||||
* - overlap: elements overlapping or inside bounds
|
|
||||||
* - contain: elements inside bounds or bounds inside elements
|
|
||||||
* - inside: elements inside bounds
|
|
||||||
**/
|
|
||||||
type: "overlap" | "contain" | "inside";
|
|
||||||
}) => {
|
|
||||||
if (isExcalidrawElement(bounds)) {
|
|
||||||
bounds = getElementBounds(bounds, arrayToMap(elements));
|
|
||||||
}
|
|
||||||
const adjustedBBox: Bounds = [
|
|
||||||
bounds[0] - errorMargin,
|
|
||||||
bounds[1] - errorMargin,
|
|
||||||
bounds[2] + errorMargin,
|
|
||||||
bounds[3] + errorMargin,
|
|
||||||
];
|
|
||||||
|
|
||||||
const includedElementSet = new Set<string>();
|
|
||||||
|
|
||||||
for (const element of elements) {
|
|
||||||
if (includedElementSet.has(element.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOverlaping =
|
|
||||||
type === "overlap"
|
|
||||||
? elementPartiallyOverlapsWithOrContainsBBox(element, adjustedBBox)
|
|
||||||
: type === "inside"
|
|
||||||
? isElementInsideBBox(element, adjustedBBox)
|
|
||||||
: isElementInsideBBox(element, adjustedBBox, true);
|
|
||||||
|
|
||||||
if (isOverlaping) {
|
|
||||||
includedElementSet.add(element.id);
|
|
||||||
|
|
||||||
if (element.boundElements) {
|
|
||||||
for (const boundElement of element.boundElements) {
|
|
||||||
includedElementSet.add(boundElement.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTextElement(element) && element.containerId) {
|
|
||||||
includedElementSet.add(element.containerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isArrowElement(element)) {
|
|
||||||
if (element.startBinding) {
|
|
||||||
includedElementSet.add(element.startBinding.elementId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.endBinding) {
|
|
||||||
includedElementSet.add(element.endBinding?.elementId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elements.filter((element) => includedElementSet.has(element.id));
|
|
||||||
};
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
|
||||||
|
|
||||||
import type { Bounds } from "@excalidraw/common";
|
|
||||||
|
|
||||||
import {
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox,
|
|
||||||
elementsOverlappingBBox,
|
|
||||||
isElementInsideBBox,
|
|
||||||
} from "../src/withinBounds";
|
|
||||||
|
|
||||||
const makeElement = (x: number, y: number, width: number, height: number) =>
|
|
||||||
API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeBBox = (
|
|
||||||
minX: number,
|
|
||||||
minY: number,
|
|
||||||
maxX: number,
|
|
||||||
maxY: number,
|
|
||||||
): Bounds => [minX, minY, maxX, maxY];
|
|
||||||
|
|
||||||
describe("isElementInsideBBox()", () => {
|
|
||||||
it("should return true if element is fully inside", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// bbox contains element
|
|
||||||
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
|
|
||||||
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if element is only partially overlapping", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// element contains bbox
|
|
||||||
expect(isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// element overlaps bbox from top-left
|
|
||||||
expect(isElementInsideBBox(makeElement(-10, -10, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// element overlaps bbox from top-right
|
|
||||||
expect(isElementInsideBBox(makeElement(90, -10, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// element overlaps bbox from bottom-left
|
|
||||||
expect(isElementInsideBBox(makeElement(-10, 90, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// element overlaps bbox from bottom-right
|
|
||||||
expect(isElementInsideBBox(makeElement(90, 90, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if element outside", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// outside diagonally
|
|
||||||
expect(isElementInsideBBox(makeElement(110, 110, 100, 100), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// outside on the left
|
|
||||||
expect(isElementInsideBBox(makeElement(-110, 10, 50, 50), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// outside on the right
|
|
||||||
expect(isElementInsideBBox(makeElement(110, 10, 50, 50), bbox)).toBe(false);
|
|
||||||
// outside on the top
|
|
||||||
expect(isElementInsideBBox(makeElement(10, -110, 50, 50), bbox)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// outside on the bottom
|
|
||||||
expect(isElementInsideBBox(makeElement(10, 110, 50, 50), bbox)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return true if bbox contains element and flag enabled", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// element contains bbox
|
|
||||||
expect(
|
|
||||||
isElementInsideBBox(makeElement(-10, -10, 110, 110), bbox, true),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// bbox contains element
|
|
||||||
expect(isElementInsideBBox(makeElement(0, 0, 100, 100), bbox)).toBe(true);
|
|
||||||
expect(isElementInsideBBox(makeElement(10, 10, 90, 90), bbox)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("elementPartiallyOverlapsWithOrContainsBBox()", () => {
|
|
||||||
it("should return true if element overlaps, is inside, or contains", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// bbox contains element
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(0, 0, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(10, 10, 90, 90),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// element contains bbox
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-10, -10, 110, 110),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// element overlaps bbox from top-left
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-10, -10, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
// element overlaps bbox from top-right
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(90, -10, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
// element overlaps bbox from bottom-left
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-10, 90, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
// element overlaps bbox from bottom-right
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(90, 90, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return false if element does not overlap", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
// outside diagonally
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(110, 110, 100, 100),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
// outside on the left
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(-110, 10, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
// outside on the right
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(110, 10, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
// outside on the top
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(10, -110, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
// outside on the bottom
|
|
||||||
expect(
|
|
||||||
elementPartiallyOverlapsWithOrContainsBBox(
|
|
||||||
makeElement(10, 110, 50, 50),
|
|
||||||
bbox,
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("elementsOverlappingBBox()", () => {
|
|
||||||
it("should return elements that overlap bbox", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
const rectOutside = makeElement(110, 110, 100, 100);
|
|
||||||
const rectInside = makeElement(10, 10, 90, 90);
|
|
||||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
|
||||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
elementsOverlappingBBox({
|
|
||||||
bounds: bbox,
|
|
||||||
type: "overlap",
|
|
||||||
elements: [
|
|
||||||
rectOutside,
|
|
||||||
rectInside,
|
|
||||||
rectContainingBBox,
|
|
||||||
rectOverlappingTopLeft,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([rectInside, rectContainingBBox, rectOverlappingTopLeft]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return elements inside/containing bbox", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
const rectOutside = makeElement(110, 110, 100, 100);
|
|
||||||
const rectInside = makeElement(10, 10, 90, 90);
|
|
||||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
|
||||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
elementsOverlappingBBox({
|
|
||||||
bounds: bbox,
|
|
||||||
type: "contain",
|
|
||||||
elements: [
|
|
||||||
rectOutside,
|
|
||||||
rectInside,
|
|
||||||
rectContainingBBox,
|
|
||||||
rectOverlappingTopLeft,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([rectInside, rectContainingBBox]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return elements inside bbox", () => {
|
|
||||||
const bbox = makeBBox(0, 0, 100, 100);
|
|
||||||
|
|
||||||
const rectOutside = makeElement(110, 110, 100, 100);
|
|
||||||
const rectInside = makeElement(10, 10, 90, 90);
|
|
||||||
const rectContainingBBox = makeElement(-10, -10, 110, 110);
|
|
||||||
const rectOverlappingTopLeft = makeElement(-10, -10, 50, 50);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
elementsOverlappingBBox({
|
|
||||||
bounds: bbox,
|
|
||||||
type: "inside",
|
|
||||||
elements: [
|
|
||||||
rectOutside,
|
|
||||||
rectInside,
|
|
||||||
rectContainingBBox,
|
|
||||||
rectOverlappingTopLeft,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual([rectInside]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO test linear, freedraw, and diamond element types (+rotated)
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user