Compare commits

...

10 Commits

Author SHA1 Message Date
dwelle 348912f32f debug: clipboard 2023-10-27 23:54:56 +02:00
Aakansha Doshi ec2de7205f fix: don't update label position when dragging labelled arrows (#6891)
* fix: don't update label position when dragging labelled arrows

* lint

* add test

* don't update coords for label when labelled arrow inside frame

* increase locales bundle size limit
2023-10-27 12:06:11 +05:30
Are d5e3f436dc feat: add approximate elements in bbox detection (#6727)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-26 23:33:00 +02:00
Aakansha Doshi dcf4592e79 feat: regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping (#7195)
* feat: regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping

* type

* increase limit as some past PR(s) increased the bundle size

* review fixes

* update changelog
2023-10-27 00:43:48 +05:30
David Luzar d1f8eec174 feat: support giphy.com embed domain (#7192) 2023-10-26 00:00:50 +02:00
David Luzar 0f81c30276 fix: frame add/remove/z-index ordering changes (#7194) 2023-10-25 23:16:02 +02:00
zsviczian f098789d16 fix: element relative position when dragging multiple elements on grid (#7107)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-25 22:48:03 +02:00
David Luzar f794b0bb90 fix: freedraw non-solid bg hitbox not working (#7193) 2023-10-25 17:21:01 +02:00
David Luzar 104f64f1dc revert: remove bound-arrows from frames (#7190) 2023-10-25 10:39:19 +02:00
Viczián András 71ad3c5356 fix: Actions panel ux improvement (#6850)
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-10-24 18:36:13 +00:00
46 changed files with 1883 additions and 926 deletions
-10
View File
@@ -20,13 +20,3 @@ Frames should be ordered where frame children come first, followed by the frame
```
If not oredered correctly, the editor will still function, but the elements may not be rendered and clipped correctly. Further, the renderer relies on this ordering for performance optimizations.
# Arrows
An arrow can be a child of a frame only if it has no binding (either start or end) to any other element, regardless of whether the bound element is inside the frame or not.
This ensures that when an arrow is bound to an element outside the frame, it's rendered and behaves correctly.
Therefore, when an arrow (that's a child of a frame) gets bound to an element, it's automatically removed from the frame.
Bound-arrow is duplicated alongside a frame only if the arrow start is bound to an element within that frame.
+94 -10
View File
@@ -10,26 +10,34 @@ import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
export const actionCopy = register({
name: "copy",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: async (elements, appState, _, app) => {
const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
includeElementsInFrames: true,
});
copyToClipboard(elementsToCopy, app.files);
try {
await copyToClipboard(elementsToCopy, app.files);
} catch (error: any) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
@@ -38,15 +46,91 @@ export const actionCopy = register({
export const actionPaste = register({
name: "paste",
trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => {
app.pasteFromClipboard(null);
perform: async (elements, appState, data, app) => {
const MIME_TYPES: Record<string, string> = {};
try {
try {
const clipboardItems = await navigator.clipboard?.read();
for (const item of clipboardItems) {
for (const type of item.types) {
try {
const blob = await item.getType(type);
MIME_TYPES[type] = await blob.text();
} catch (error: any) {
console.warn(
`Cannot retrieve ${type} from clipboardItem: ${error.message}`,
);
}
}
}
if (Object.keys(MIME_TYPES).length === 0) {
console.warn(
"No clipboard data found from clipboard.read(). Falling back to clipboard.readText()",
);
// throw so we fall back onto clipboard.readText()
throw new Error("No clipboard data found");
}
} catch (error: any) {
try {
MIME_TYPES["text/plain"] = await navigator.clipboard?.readText();
} catch (error: any) {
console.warn(`Cannot readText() from clipboard: ${error.message}`);
if (isFirefox) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
},
};
}
throw error;
}
}
} catch (error: any) {
console.error(`actionPaste: ${error.message}`);
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
try {
console.log("actionPaste (1)", { MIME_TYPES });
const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
for (const [type, value] of Object.entries(MIME_TYPES)) {
try {
event.clipboardData?.setData(type, value);
} catch (error: any) {
console.warn(
`Cannot set ${type} as clipboardData item: ${error.message}`,
);
}
}
event.clipboardData?.types.forEach((type) => {
console.log(
`actionPaste (2) event.clipboardData?.getData(${type})`,
event.clipboardData?.getData(type),
);
});
app.pasteFromClipboard(event);
} catch (error: any) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
return {
commitToHistory: false,
};
},
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
+2 -9
View File
@@ -155,12 +155,7 @@ const duplicateElements = (
groupId,
).flatMap((element) =>
isFrameElement(element)
? [
...getFrameElements(elements, element.id, {
includeBoundArrows: true,
}),
element,
]
? [...getFrameElements(elements, element.id), element]
: [element],
);
@@ -186,9 +181,7 @@ const duplicateElements = (
continue;
}
if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id, {
includeBoundArrows: true,
});
const elementsInFrame = getFrameElements(sortedElements, element.id);
elementsWithClones.push(
...markAsProcessed([
+167
View File
@@ -0,0 +1,167 @@
import { Excalidraw } from "../packages/excalidraw/index";
import { queryByTestId } from "@testing-library/react";
import { render } from "../tests/test-utils";
import { UI } from "../tests/helpers/ui";
import { API } from "../tests/helpers/api";
import { COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_PICKS } from "../colors";
import { FONT_FAMILY, STROKE_WIDTH } from "../constants";
const { h } = window;
describe("element locking", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("properties when tool selected", () => {
it("should show active background top picks", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
});
const activeColor = queryByTestId(
document.body,
`color-top-pick-${color}`,
);
expect(activeColor).toHaveClass("active");
});
it("should show fill style when background non-transparent", () => {
UI.clickTool("rectangle");
const color = DEFAULT_ELEMENT_BACKGROUND_PICKS[1];
// just in case we change it in the future
expect(color).not.toBe(COLOR_PALETTE.transparent);
h.setState({
currentItemBackgroundColor: color,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toHaveClass("active");
h.setState({
currentItemFillStyle: "solid",
});
const solidFillStyle = queryByTestId(document.body, `fill-solid`);
expect(solidFillStyle).toHaveClass("active");
});
it("should not show fill style when background transparent", () => {
UI.clickTool("rectangle");
h.setState({
currentItemBackgroundColor: COLOR_PALETTE.transparent,
currentItemFillStyle: "hachure",
});
const hachureFillButton = queryByTestId(document.body, `fill-hachure`);
expect(hachureFillButton).toBe(null);
});
it("should show horizontal text align for text tool", () => {
UI.clickTool("text");
h.setState({
currentItemTextAlign: "right",
});
const centerTextAlign = queryByTestId(document.body, `align-right`);
expect(centerTextAlign).toBeChecked();
});
});
describe("properties when elements selected", () => {
it("should show active styles when single element selected", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: "red",
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toHaveClass("active");
});
it("should not show fill style selected element's background is transparent", () => {
const rect = API.createElement({
type: "rectangle",
backgroundColor: COLOR_PALETTE.transparent,
fillStyle: "cross-hatch",
});
h.elements = [rect];
API.setSelectedElements([rect]);
const crossHatchButton = queryByTestId(document.body, `fill-cross-hatch`);
expect(crossHatchButton).toBe(null);
});
it("should highlight common stroke width of selected elements", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
const thinStrokeWidthButton = queryByTestId(
document.body,
`strokeWidth-thin`,
);
expect(thinStrokeWidthButton).toBeChecked();
});
it("should not highlight any stroke width button if no common style", () => {
const rect1 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.thin,
});
const rect2 = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1, rect2]);
expect(queryByTestId(document.body, `strokeWidth-thin`)).not.toBe(null);
expect(
queryByTestId(document.body, `strokeWidth-thin`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-bold`),
).not.toBeChecked();
expect(
queryByTestId(document.body, `strokeWidth-extraBold`),
).not.toBeChecked();
});
it("should show properties of different element types when selected", () => {
const rect = API.createElement({
type: "rectangle",
strokeWidth: STROKE_WIDTH.bold,
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY.Cascadia,
});
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
});
});
});
+84 -27
View File
@@ -1,4 +1,4 @@
import { AppState } from "../../src/types";
import { AppState, Primitive } from "../../src/types";
import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -51,6 +51,7 @@ import {
DEFAULT_FONT_SIZE,
FONT_FAMILY,
ROUNDNESS,
STROKE_WIDTH,
VERTICAL_ALIGN,
} from "../constants";
import {
@@ -82,7 +83,6 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys";
import { randomInteger } from "../random";
import {
canChangeRoundness,
canHaveArrowheads,
getCommonAttributeOfSelectedElements,
getSelectedElements,
@@ -118,25 +118,44 @@ export const changeProperty = (
});
};
export const getFormValue = function <T>(
export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[],
appState: AppState,
getAttribute: (element: ExcalidrawElement) => T,
defaultValue: T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T {
const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements);
return (
(editingElement && getAttribute(editingElement)) ??
(isSomeElementSelected(nonDeletedElements, appState)
? getCommonAttributeOfSelectedElements(
nonDeletedElements,
let ret: T | null = null;
if (editingElement) {
ret = getAttribute(editingElement);
}
if (!ret) {
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
if (hasSelection) {
ret =
getCommonAttributeOfSelectedElements(
isRelevantElement === true
? nonDeletedElements
: nonDeletedElements.filter((el) => isRelevantElement(el)),
appState,
getAttribute,
)
: defaultValue) ??
defaultValue
);
) ??
(typeof defaultValue === "function"
? defaultValue(true)
: defaultValue);
} else {
ret =
typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
}
}
return ret;
};
const offsetElementAfterFontResize = (
@@ -247,6 +266,7 @@ export const actionChangeStrokeColor = register({
elements,
appState,
(element) => element.strokeColor,
true,
appState.currentItemStrokeColor,
)}
onChange={(color) => updateData({ currentItemStrokeColor: color })}
@@ -289,6 +309,7 @@ export const actionChangeBackgroundColor = register({
elements,
appState,
(element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor,
)}
onChange={(color) => updateData({ currentItemBackgroundColor: color })}
@@ -338,23 +359,28 @@ export const actionChangeFillStyle = register({
} (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
},
{
value: "cross-hatch",
text: t("labels.crossHatch"),
icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
},
{
value: "solid",
text: t("labels.solid"),
icon: FillSolidIcon,
testId: `fill-solid`,
},
]}
value={getFormValue(
elements,
appState,
(element) => element.fillStyle,
appState.currentItemFillStyle,
(element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
)}
onClick={(value, event) => {
const nextValue =
@@ -393,26 +419,31 @@ export const actionChangeStrokeWidth = register({
group="stroke-width"
options={[
{
value: 1,
value: STROKE_WIDTH.thin,
text: t("labels.thin"),
icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
},
{
value: 2,
value: STROKE_WIDTH.bold,
text: t("labels.bold"),
icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
},
{
value: 4,
value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
},
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeWidth,
appState.currentItemStrokeWidth,
(element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
)}
onChange={(value) => updateData(value)}
/>
@@ -461,7 +492,9 @@ export const actionChangeSloppiness = register({
elements,
appState,
(element) => element.roughness,
appState.currentItemRoughness,
(element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
)}
onChange={(value) => updateData(value)}
/>
@@ -509,7 +542,9 @@ export const actionChangeStrokeStyle = register({
elements,
appState,
(element) => element.strokeStyle,
appState.currentItemStrokeStyle,
(element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
)}
onChange={(value) => updateData(value)}
/>
@@ -549,6 +584,7 @@ export const actionChangeOpacity = register({
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
@@ -607,7 +643,12 @@ export const actionChangeFontSize = register({
}
return null;
},
appState.currentItemFontSize || DEFAULT_FONT_SIZE,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
)}
onChange={(value) => updateData(value)}
/>
@@ -692,21 +733,25 @@ export const actionChangeFontFamily = register({
value: FontFamilyValues;
text: string;
icon: JSX.Element;
testId: string;
}[] = [
{
value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"),
icon: FreedrawIcon,
testId: "font-family-virgil",
},
{
value: FONT_FAMILY.Helvetica,
text: t("labels.normal"),
icon: FontFamilyNormalIcon,
testId: "font-family-normal",
},
{
value: FONT_FAMILY.Cascadia,
text: t("labels.code"),
icon: FontFamilyCodeIcon,
testId: "font-family-code",
},
];
@@ -729,7 +774,12 @@ export const actionChangeFontFamily = register({
}
return null;
},
appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection
? null
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
)}
onChange={(value) => updateData(value)}
/>
@@ -806,7 +856,10 @@ export const actionChangeTextAlign = register({
}
return null;
},
appState.currentItemTextAlign,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)}
onChange={(value) => updateData(value)}
/>
@@ -882,7 +935,9 @@ export const actionChangeVerticalAlign = register({
}
return null;
},
VERTICAL_ALIGN.MIDDLE,
(element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)}
onChange={(value) => updateData(value)}
/>
@@ -947,9 +1002,9 @@ export const actionChangeRoundness = register({
appState,
(element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) &&
appState.currentItemRoundness) ||
null,
(element) => element.hasOwnProperty("roundness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoundness,
)}
onChange={(value) => updateData(value)}
/>
@@ -1043,6 +1098,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead,
)}
onChange={(value) => updateData({ position: "start", type: value })}
@@ -1089,6 +1145,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead,
)}
onChange={(value) => updateData({ position: "end", type: value })}
+4 -7
View File
@@ -118,7 +118,7 @@ export const copyToClipboard = async (
await copyTextToSystemClipboard(json);
} catch (error: any) {
PREFER_APP_CLIPBOARD = true;
console.error(error);
throw error;
}
};
@@ -193,7 +193,7 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
* via async clipboard API if supported)
*/
const getSystemClipboard = async (
event: ClipboardEvent | null,
event: ClipboardEvent,
isPlainPaste = false,
): Promise<
| { type: "text"; value: string }
@@ -205,10 +205,7 @@ const getSystemClipboard = async (
return { type: "mixedContent", value: mixedContent };
}
const text = event
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
const text = event.clipboardData?.getData("text/plain");
return { type: "text", value: (text || "").trim() };
} catch {
@@ -220,7 +217,7 @@ const getSystemClipboard = async (
* Attempts to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
event: ClipboardEvent,
isPlainPaste = false,
): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event, isPlainPaste);
+7 -6
View File
@@ -11,7 +11,6 @@ import {
hasBackground,
hasStrokeStyle,
hasStrokeWidth,
hasText,
} from "../scene";
import { SHAPES } from "../shapes";
import { AppClassProperties, UIAppState, Zoom } from "../types";
@@ -20,7 +19,7 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks";
import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
import clsx from "clsx";
import { actionToggleZenMode } from "../actions";
import { Tooltip } from "./Tooltip";
@@ -66,7 +65,8 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons =
hasBackground(appState.activeTool.type) ||
(hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some(
(element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor),
@@ -123,14 +123,15 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</>
)}
{(hasText(appState.activeTool.type) ||
targetElements.some((element) => hasText(element.type))) && (
{(appState.activeTool.type === "text" ||
targetElements.some(isTextElement)) && (
<>
{renderAction("changeFontSize")}
{renderAction("changeFontFamily")}
{suppportsHorizontalAlign(targetElements) &&
{(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements)) &&
renderAction("changeTextAlign")}
</>
)}
+21 -6
View File
@@ -1275,6 +1275,12 @@ class App extends React.Component<AppProps, AppState> {
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(cb) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
cb?.();
});
}}
/>
)}
<StaticCanvas
@@ -2195,14 +2201,21 @@ class App extends React.Component<AppProps, AppState> {
};
public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent | null) => {
async (event: ClipboardEvent) => {
const isPlainPaste = !!(IS_PLAIN_PASTE && event);
console.warn(
"pasteFromClipboard",
event?.clipboardData?.types,
event?.clipboardData?.getData("text/plain"),
);
// #686
const target = document.activeElement;
const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) {
console.log("exit (1)");
return;
}
@@ -2215,6 +2228,7 @@ class App extends React.Component<AppProps, AppState> {
(!(elementUnderCursor instanceof HTMLCanvasElement) ||
isWritableElement(target))
) {
console.log("exit (2)");
return;
}
@@ -2554,12 +2568,18 @@ class App extends React.Component<AppProps, AppState> {
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) {
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x,
y: currentY,
});
const element = newTextElement({
...textElementProps,
x,
y: currentY,
text,
lineHeight,
frameId: topLayerFrame ? topLayerFrame.id : null,
});
acc.push(element);
currentY += element.height + LINE_GAP;
@@ -3574,11 +3594,6 @@ class App extends React.Component<AppProps, AppState> {
return getElementsAtPosition(elements, (element) =>
hitTest(element, this.state, this.frameNameBoundsCache, x, y),
).filter((element) => {
// arrows don't clip even if they're children of frames,
// so always allow hitbox regardless of beinging contained in frame
if (isArrowElement(element)) {
return true;
}
// hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element);
return containingFrame &&
+1
View File
@@ -55,6 +55,7 @@ export const TopPicks = ({
type="button"
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
>
<div className="color-picker__button-outline" />
</button>
+5 -9
View File
@@ -9,11 +9,7 @@ import {
} from "../actions/shortcuts";
import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager";
import {
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import { useExcalidrawAppState, useExcalidrawElements } from "./App";
import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
@@ -25,14 +21,14 @@ type ContextMenuProps = {
items: ContextMenuItems;
top: number;
left: number;
onClose: (cb?: () => void) => void;
};
export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => {
({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
@@ -54,7 +50,7 @@ export const ContextMenu = React.memo(
return (
<Popover
onCloseRequest={() => setAppState({ contextMenu: null })}
onCloseRequest={() => onClose()}
top={top}
left={left}
fitInViewport={true}
@@ -102,7 +98,7 @@ export const ContextMenu = React.memo(
// we need update state before executing the action in case
// the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => {
onClose(() => {
actionManager.executeAction(item, "contextMenu");
});
}}
+6
View File
@@ -302,6 +302,12 @@ export const ROUGHNESS = {
cartoonist: 2,
} as const;
export const STROKE_WIDTH = {
thin: 1,
bold: 2,
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"];
+138 -138
View File
@@ -6,11 +6,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "#d8f5a2",
"boundElements": [
{
"id": "id40",
"id": "id41",
"type": "arrow",
},
{
"id": "id41",
"id": "id42",
"type": "arrow",
},
],
@@ -45,7 +45,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id41",
"id": "id42",
"type": "arrow",
},
],
@@ -97,12 +97,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0,
0,
0.5,
0.5,
],
[
395,
35,
394.5,
34.5,
],
],
"roughness": 1,
@@ -110,7 +110,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id42",
"elementId": "id43",
"focus": -0.08139534883720931,
"gap": 1,
},
@@ -150,11 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
400,
399.5,
0,
],
],
@@ -186,7 +186,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id40",
"id": "id41",
"type": "arrow",
},
],
@@ -222,7 +222,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0,
"boundElements": [
{
"id": "id43",
"id": "id44",
"type": "arrow",
},
],
@@ -266,7 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0,
"boundElements": [
{
"id": "id43",
"id": "id44",
"type": "arrow",
},
],
@@ -309,7 +309,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id44",
"id": "id45",
"type": "text",
},
],
@@ -317,7 +317,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": {
"elementId": "text-2",
"focus": 0,
"gap": 5,
"gap": 205,
},
"fillStyle": "solid",
"frameId": null,
@@ -331,11 +331,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -355,7 +355,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 255,
"y": 239,
}
@@ -367,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id43",
"containerId": "id44",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -395,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 340,
"x": 240,
"y": 226.5,
}
`;
@@ -406,13 +406,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id33",
"id": "id34",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id35",
"elementId": "id36",
"focus": 0,
"gap": 1,
},
@@ -428,11 +428,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id34",
"elementId": "id35",
"focus": 0,
"gap": 1,
},
@@ -452,7 +452,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 255,
"y": 239,
}
@@ -464,7 +464,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id32",
"containerId": "id33",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -492,7 +492,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 340,
"x": 240,
"y": 226.5,
}
`;
@@ -503,7 +503,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id32",
"id": "id33",
"type": "arrow",
},
],
@@ -538,7 +538,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id32",
"id": "id33",
"type": "arrow",
},
],
@@ -562,7 +562,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 555,
"x": 355,
"y": 189,
}
`;
@@ -573,13 +573,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id37",
"id": "id38",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": {
"elementId": "id39",
"elementId": "id40",
"focus": 0,
"gap": 1,
},
@@ -595,11 +595,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -608,7 +608,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": {
"elementId": "id38",
"elementId": "id39",
"focus": 0,
"gap": 1,
},
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1,
"version": 3,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 255,
"y": 239,
}
@@ -631,7 +631,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id36",
"containerId": "id37",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -659,7 +659,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 340,
"x": 240,
"y": 226.5,
}
`;
@@ -671,7 +671,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0,
"boundElements": [
{
"id": "id36",
"id": "id37",
"type": "arrow",
},
],
@@ -715,7 +715,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0,
"boundElements": [
{
"id": "id36",
"id": "id37",
"type": "arrow",
},
],
@@ -747,7 +747,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>,
"verticalAlign": "top",
"width": 100,
"x": 555,
"x": 355,
"y": 226.5,
}
`;
@@ -801,11 +801,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -821,7 +821,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 100,
"y": 20,
}
@@ -846,11 +846,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 450,
"y": 20,
}
@@ -895,7 +895,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0,
],
[
300,
100,
0,
],
],
@@ -911,7 +911,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 100,
"y": 60,
}
@@ -940,7 +940,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0,
],
[
300,
100,
0,
],
],
@@ -956,7 +956,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 450,
"y": 60,
}
@@ -1221,56 +1221,6 @@ exports[`Test Transform > should transform text element 2`] = `
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id28",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 0,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0,
0,
],
[
300,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"x": 100,
"y": 100,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@@ -1294,11 +1244,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -1314,13 +1264,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 100,
"y": 200,
"y": 100,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@@ -1335,7 +1285,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 130,
"height": 0,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
@@ -1344,11 +1294,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -1357,20 +1307,20 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1098ad",
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 2,
"version": 1,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 100,
"y": 300,
"y": 200,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 3`] = `
{
"angle": 0,
"backgroundColor": "transparent",
@@ -1394,11 +1344,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100,
"points": [
[
0,
0.5,
0,
],
[
300,
99.5,
0,
],
],
@@ -1414,7 +1364,57 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1,
"version": 2,
"versionNonce": Any<Number>,
"width": 300,
"width": 100,
"x": 100,
"y": 300,
}
`;
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 4`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id32",
"type": "text",
},
],
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 130,
"id": Any<String>,
"isDeleted": false,
"lastCommittedPoint": null,
"link": null,
"locked": false,
"opacity": 100,
"points": [
[
0.5,
0,
],
[
99.5,
0,
],
],
"roughness": 1,
"roundness": null,
"seed": Any<Number>,
"startArrowhead": null,
"startBinding": null,
"strokeColor": "#1098ad",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "arrow",
"updated": 1,
"version": 2,
"versionNonce": Any<Number>,
"width": 100,
"x": 100,
"y": 400,
}
@@ -1426,7 +1426,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id24",
"containerId": "id25",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1454,7 +1454,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 130,
"x": 185,
"x": 85,
"y": 87.5,
}
`;
@@ -1465,7 +1465,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id25",
"containerId": "id26",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1493,7 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 200,
"x": 150,
"x": 50,
"y": 187.5,
}
`;
@@ -1504,7 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id26",
"containerId": "id27",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1533,7 +1533,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 175,
"x": 75,
"y": 275,
}
`;
@@ -1544,7 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id27",
"containerId": "id28",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1573,7 +1573,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>,
"verticalAlign": "middle",
"width": 150,
"x": 175,
"x": 75,
"y": 375,
}
`;
@@ -1584,7 +1584,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id18",
"id": "id19",
"type": "text",
},
],
@@ -1619,7 +1619,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id19",
"id": "id20",
"type": "text",
},
],
@@ -1654,7 +1654,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id20",
"id": "id21",
"type": "text",
},
],
@@ -1689,7 +1689,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#fff3bf",
"boundElements": [
{
"id": "id21",
"id": "id22",
"type": "text",
},
],
@@ -1724,7 +1724,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"boundElements": [
{
"id": "id22",
"id": "id23",
"type": "text",
},
],
@@ -1759,7 +1759,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#ffec99",
"boundElements": [
{
"id": "id23",
"id": "id24",
"type": "text",
},
],
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id12",
"containerId": "id13",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1833,7 +1833,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id13",
"containerId": "id14",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1873,7 +1873,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id14",
"containerId": "id15",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1915,7 +1915,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id15",
"containerId": "id16",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1955,7 +1955,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id16",
"containerId": "id17",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
@@ -1996,7 +1996,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id17",
"containerId": "id18",
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
+6 -13
View File
@@ -43,7 +43,6 @@ import {
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
import { isValidFrameChild } from "../frame";
type RestoredAppState = Omit<
AppState,
@@ -397,7 +396,7 @@ const repairBoundElement = (
};
/**
* resets `frameId` if no longer applicable.
* Remove an element's frameId if its containing frame is non-existent
*
* NOTE mutates elements.
*/
@@ -405,16 +404,12 @@ const repairFrameMembership = (
element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => {
if (!element.frameId) {
return;
}
if (element.frameId) {
const containingFrame = elementsMap.get(element.frameId);
if (
!isValidFrameChild(element) ||
// target frame not exists
!elementsMap.get(element.frameId)
) {
element.frameId = null;
if (!containingFrame) {
element.frameId = null;
}
}
};
@@ -458,8 +453,6 @@ export const restoreElements = (
// repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements);
for (const element of restoredElements) {
// repair frame membership *after* bindings we do in restoreElement()
// since we rely on bindings to be correct
if (element.frameId) {
repairFrameMembership(element, restoredElementsMap);
}
+44 -9
View File
@@ -5,7 +5,31 @@ import {
} from "./transform";
import { ExcalidrawArrowElement } from "../element/types";
const opts = { regenerateIds: false };
describe("Test Transform", () => {
it("should generate id unless opts.regenerateIds is set to false explicitly", () => {
const elements = [
{
type: "rectangle",
x: 100,
y: 100,
id: "rect-1",
},
];
let data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
);
expect(data.length).toBe(1);
expect(data[0].id).toBe("id0");
data = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(data[0].id).toBe("rect-1");
});
it("should transform regular shapes", () => {
const elements = [
{
@@ -59,6 +83,7 @@ describe("Test Transform", () => {
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
@@ -87,6 +112,7 @@ describe("Test Transform", () => {
];
convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
@@ -128,6 +154,7 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
@@ -210,6 +237,7 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(12);
@@ -267,6 +295,7 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(8);
@@ -300,6 +329,7 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
@@ -321,7 +351,7 @@ describe("Test Transform", () => {
});
expect(text).toMatchObject({
x: 340,
x: 240,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
@@ -341,7 +371,7 @@ describe("Test Transform", () => {
});
expect(ellipse).toMatchObject({
x: 555,
x: 355,
y: 189,
type: "ellipse",
boundElements: [
@@ -383,10 +413,10 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({
@@ -406,7 +436,7 @@ describe("Test Transform", () => {
});
expect(text1).toMatchObject({
x: 340,
x: 240,
y: 226.5,
type: "text",
text: "HELLO WORLD!!",
@@ -426,7 +456,7 @@ describe("Test Transform", () => {
});
expect(text3).toMatchObject({
x: 555,
x: 355,
y: 226.5,
type: "text",
boundElements: [
@@ -499,6 +529,7 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(5);
@@ -547,6 +578,7 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
@@ -600,17 +632,18 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(4);
const [, , arrow] = excaldrawElements;
const [, , arrow, text] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
y: 239,
boundElements: [
{
id: "id46",
id: text.id,
type: "text",
},
],
@@ -650,17 +683,18 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: 0,
gap: 5,
gap: 205,
});
expect(rect.boundElements).toStrictEqual([
{
id: "id47",
id: arrow.id,
type: "arrow",
},
]);
@@ -692,6 +726,7 @@ describe("Test Transform", () => {
];
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excaldrawElements.length).toBe(1);
+89 -5
View File
@@ -39,6 +39,8 @@ import {
} from "../element/types";
import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -159,7 +161,7 @@ export type ExcalidrawElementSkeleton =
} & Partial<ExcalidrawImageElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 300,
width: 100,
height: 0,
};
@@ -357,6 +359,48 @@ const bindLinearElementToElement = (
);
}
}
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = JSON.parse(JSON.stringify(linearElement.points));
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = delta;
newPoints[endPointIndex][0] -= delta;
}
// right to left so shift the arrow towards left
if (
linearElement.points[endPointIndex][0] <
linearElement.points[endPointIndex - 1][0]
) {
newPoints[0][0] = -delta;
newPoints[endPointIndex][0] += delta;
}
// top to bottom so shift the arrow towards top
if (
linearElement.points[endPointIndex][1] >
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = delta;
newPoints[endPointIndex][1] -= delta;
}
// bottom to top so shift the arrow towards bottom
if (
linearElement.points[endPointIndex][1] <
linearElement.points[endPointIndex - 1][1]
) {
newPoints[0][1] = -delta;
newPoints[endPointIndex][1] += delta;
}
Object.assign(linearElement, { points: newPoints });
return {
linearElement,
startBoundElement,
@@ -384,18 +428,28 @@ class ElementStore {
}
export const convertToExcalidrawElements = (
elements: ExcalidrawElementSkeleton[] | null,
elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
) => {
if (!elements) {
if (!elementsSkeleton) {
return [];
}
const elements: ExcalidrawElementSkeleton[] = JSON.parse(
JSON.stringify(elementsSkeleton),
);
const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements
for (const element of elements) {
let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId() });
}
switch (element.type) {
case "rectangle":
case "ellipse":
@@ -444,6 +498,11 @@ export const convertToExcalidrawElements = (
],
...element,
});
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break;
}
case "text": {
@@ -499,6 +558,9 @@ export const convertToExcalidrawElements = (
} else {
elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element);
if (originalId) {
oldToNewElementIdMap.set(originalId, excalidrawElement.id);
}
}
}
@@ -524,6 +586,18 @@ export const convertToExcalidrawElements = (
element.type === "arrow" ? element?.start : undefined;
const originalEnd =
element.type === "arrow" ? element?.end : undefined;
if (originalStart && originalStart.id) {
const newStartId = oldToNewElementIdMap.get(originalStart.id);
if (newStartId) {
Object.assign(originalStart, { id: newStartId });
}
}
if (originalEnd && originalEnd.id) {
const newEndId = oldToNewElementIdMap.get(originalEnd.id);
if (newEndId) {
Object.assign(originalEnd, { id: newEndId });
}
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
container as ExcalidrawArrowElement,
@@ -539,13 +613,23 @@ export const convertToExcalidrawElements = (
} else {
switch (element.type) {
case "arrow": {
const { start, end } = element;
if (start && start.id) {
const newStartId = oldToNewElementIdMap.get(start.id);
Object.assign(start, { id: newStartId });
}
if (end && end.id) {
const newEndId = oldToNewElementIdMap.get(end.id);
Object.assign(end, { id: newEndId });
}
const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement,
element.start,
element.end,
start,
end,
elementStore,
);
elementStore.add(linearElement);
elementStore.add(startBoundElement);
elementStore.add(endBoundElement);
+1 -1
View File
@@ -392,7 +392,7 @@ export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
appState: Pick<UIAppState, "zoom">,
): [x: number, y: number, width: number, height: number] => {
): Bounds => {
const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value;
const linkHeight = size / appState.zoom.value;
-10
View File
@@ -27,7 +27,6 @@ import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { isValidFrameChild } from "../frame";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -212,15 +211,6 @@ export const bindLinearElement = (
}),
});
}
if (linearElement.frameId && !isValidFrameChild(linearElement)) {
mutateElement(
linearElement,
{
frameId: null,
},
false,
);
}
};
// Don't bind both ends of a simple segment
+16 -16
View File
@@ -34,7 +34,12 @@ export type RectangleBox = {
type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
export type Bounds = readonly [
minX: number,
minY: number,
maxX: number,
maxY: number,
];
export class ElementBounds {
private static boundsCache = new WeakMap<
@@ -63,7 +68,7 @@ export class ElementBounds {
}
private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number];
let bounds: Bounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
@@ -387,7 +392,7 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = (
ops: Op[],
transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => {
): Bounds => {
let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce(
@@ -435,9 +440,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY];
};
const getBoundsFromPoints = (
export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"],
): [number, number, number, number] => {
): Bounds => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
@@ -589,7 +594,7 @@ const getLinearElementRotatedBounds = (
element: ExcalidrawLinearElement,
cx: number,
cy: number,
): [number, number, number, number] => {
): Bounds => {
if (element.points.length < 2) {
const [pointX, pointY] = element.points[0];
const [x, y] = rotate(
@@ -600,7 +605,7 @@ const getLinearElementRotatedBounds = (
element.angle,
);
let coords: [number, number, number, number] = [x, y, x, y];
let coords: Bounds = [x, y, x, y];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -625,12 +630,7 @@ const getLinearElementRotatedBounds = (
const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: [number, number, number, number] = [
res[0],
res[1],
res[2],
res[3],
];
let coords: Bounds = [res[0], res[1], res[2], res[3]];
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -692,7 +692,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number,
nextHeight: number,
normalizePoints: boolean,
): [number, number, number, number] => {
): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [
element.x,
@@ -709,7 +709,7 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints,
);
let bounds: [number, number, number, number];
let bounds: Bounds;
if (isFreeDrawElement(element)) {
// Free Draw
@@ -740,7 +740,7 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = (
element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[],
): [number, number, number, number] => {
): Bounds => {
// This might be computationally heavey
const gen = rough.generator();
const curve =
+3 -1
View File
@@ -494,7 +494,9 @@ const hitTestFreeDrawElement = (
// for filled freedraw shapes, support
// selecting from inside
if (shape && shape.sets.length) {
return hitTestCurveInside(shape, x, y, "round");
return element.fillStyle === "solid"
? hitTestCurveInside(shape, x, y, "round")
: hitTestRoughShape(shape, x, y, threshold);
}
return false;
+51 -37
View File
@@ -1,5 +1,5 @@
import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds";
import { Bounds, getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers";
import { NonDeletedExcalidrawElement } from "./types";
@@ -8,7 +8,11 @@ import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups";
import { getGridPoint } from "../math";
import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks";
import {
isArrowElement,
isBoundToContainer,
isFrameElement,
} from "./typeChecks";
export const dragSelectedElements = (
pointerDownState: PointerDownState,
@@ -35,44 +39,41 @@ export const dragSelectedElements = (
if (frames.length > 0) {
const elementsInFrames = scene
.getNonDeletedElements()
.filter((e) => !isBoundToContainer(e))
.filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
}
const commonBounds = getCommonBounds(
Array.from(elementsToUpdate).map(
(el) => pointerDownState.originalElements.get(el.id) ?? el,
),
);
const adjustedOffset = calculateOffset(
commonBounds,
offset,
snapOffset,
gridSize,
);
elementsToUpdate.forEach((element) => {
updateElementCoords(
pointerDownState,
element,
offset,
snapOffset,
gridSize,
);
updateElementCoords(pointerDownState, element, adjustedOffset);
// update coords of bound text only if we're dragging the container directly
// (we don't drag the group that it's part of)
if (
// Don't update coords of arrow label since we calculate its position during render
!isArrowElement(element) &&
// container isn't part of any group
// (perf optim so we don't check `isSelectedViaGroup()` in every case)
!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
(!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element)))
) {
const textElement = getBoundTextElement(element);
if (
textElement &&
// when container is added to a frame, so will its bound text
// so the text is already in `elementsToUpdate` and we should avoid
// updating its coords again
(!textElement.frameId || !frames.includes(textElement.frameId))
) {
updateElementCoords(
pointerDownState,
textElement,
offset,
snapOffset,
gridSize,
);
if (textElement) {
updateElementCoords(pointerDownState, textElement, adjustedOffset);
}
}
updateBoundElements(element, {
@@ -81,23 +82,20 @@ export const dragSelectedElements = (
});
};
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
const calculateOffset = (
commonBounds: Bounds,
dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"],
) => {
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
): { x: number; y: number } => {
const [x, y] = commonBounds;
let nextX = x + dragOffset.x + snapOffset.x;
let nextY = y + dragOffset.y + snapOffset.y;
if (snapOffset.x === 0 || snapOffset.y === 0) {
const [nextGridX, nextGridY] = getGridPoint(
originalElement.x + dragOffset.x,
originalElement.y + dragOffset.y,
x + dragOffset.x,
y + dragOffset.y,
gridSize,
);
@@ -109,6 +107,22 @@ const updateElementCoords = (
nextY = nextGridY;
}
}
return {
x: nextX - x,
y: nextY - y,
};
};
const updateElementCoords = (
pointerDownState: PointerDownState,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number },
) => {
const originalElement =
pointerDownState.originalElements.get(element.id) ?? element;
const nextX = originalElement.x + dragOffset.x;
const nextY = originalElement.y + dragOffset.y;
mutateElement(element, {
x: nextX,
+8
View File
@@ -48,6 +48,9 @@ const RE_VALTOWN =
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
const RE_GIPHY =
/giphy.com\/(?:clips|embed|gifs)\/[a-zA-Z0-9]*?-?([a-zA-Z0-9]+)(?:[^a-zA-Z0-9]|$)/;
const ALLOWED_DOMAINS = new Set([
"youtube.com",
"youtu.be",
@@ -60,6 +63,7 @@ const ALLOWED_DOMAINS = new Set([
"*.simplepdf.eu",
"stackblitz.com",
"val.town",
"giphy.com",
]);
const createSrcDoc = (body: string) => {
@@ -309,6 +313,10 @@ export const extractSrc = (htmlString: string): string => {
return gistMatch[1];
}
if (RE_GIPHY.test(htmlString)) {
return `https://giphy.com/embed/${RE_GIPHY.exec(htmlString)![1]}`;
}
const match = htmlString.match(RE_GENERIC_EMBED);
if (match && match.length === 2) {
return match[1];
+2 -1
View File
@@ -21,6 +21,7 @@ import {
} from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import {
Bounds,
getCurvePathOps,
getElementPointsCoords,
getMinMaxXYFromCurvePathOps,
@@ -1316,7 +1317,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement,
elementBounds: [number, number, number, number],
elementBounds: Bounds,
boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds;
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MaybeTransformHandleType,
} from "./transformHandles";
import { AppState, Zoom } from "../types";
import { Bounds } from "./bounds";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@@ -87,7 +88,7 @@ export const getElementWithTransformHandleType = (
};
export const getTransformHandleTypeFromCoords = (
[x1, y1, x2, y2]: readonly [number, number, number, number],
[x1, y1, x2, y2]: Bounds,
scenePointerX: number,
scenePointerY: number,
zoom: Zoom,
+2 -2
View File
@@ -4,7 +4,7 @@ import {
PointerType,
} from "./types";
import { getElementAbsoluteCoords } from "./bounds";
import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
@@ -23,7 +23,7 @@ export type TransformHandleDirection =
export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = [number, number, number, number];
export type TransformHandle = Bounds;
export type TransformHandles = Partial<{
[T in TransformHandleType]: TransformHandle;
}>;
+13 -13
View File
@@ -123,7 +123,7 @@ describe("adding elements to frames", () => {
const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
describe("when frame is in a layer below", async () => {
describe.skip("when frame is in a layer below", async () => {
it("should add an element", async () => {
h.elements = [frame, rect2];
@@ -167,7 +167,7 @@ describe("adding elements to frames", () => {
});
});
describe("when frame is in a layer above", async () => {
describe.skip("when frame is in a layer above", async () => {
it("should add an element", async () => {
h.elements = [rect2, frame];
@@ -212,7 +212,7 @@ describe("adding elements to frames", () => {
});
describe("when frame is in an inner layer", async () => {
it("should add elements", async () => {
it.skip("should add elements", async () => {
h.elements = [rect2, frame, rect3];
func(frame, rect2);
@@ -223,7 +223,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, rect3, frame]);
});
it("should add elements when there are other other elements in between", async () => {
it.skip("should add elements when there are other other elements in between", async () => {
h.elements = [rect2, rect1, frame, rect4, rect3];
func(frame, rect2);
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
});
it("should add elements when there are other elements in between and the order is reversed", async () => {
it.skip("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2);
@@ -289,7 +289,7 @@ describe("adding elements to frames", () => {
describe("resizing frame over elements", async () => {
await commonTestCases(resizeFrameOverElement);
it("resizing over text containers and labelled arrows", async () => {
it.skip("resizing over text containers and labelled arrows", async () => {
await resizingTest(
"rectangle",
["frame", "rectangle", "text"],
@@ -339,7 +339,7 @@ describe("adding elements to frames", () => {
// );
});
it("should add arrow bound with text when frame is in a layer below", async () => {
it.skip("should add arrow bound with text when frame is in a layer below", async () => {
h.elements = [frame, arrow, text];
resizeFrameOverElement(frame, arrow);
@@ -359,7 +359,7 @@ describe("adding elements to frames", () => {
expectEqualIds([arrow, text, frame]);
});
it("should add arrow bound with text when frame is in an inner layer", async () => {
it.skip("should add arrow bound with text when frame is in an inner layer", async () => {
h.elements = [arrow, frame, text];
resizeFrameOverElement(frame, arrow);
@@ -371,7 +371,7 @@ describe("adding elements to frames", () => {
});
describe("resizing frame over elements but downwards", async () => {
it("should add elements when frame is in a layer below", async () => {
it.skip("should add elements when frame is in a layer below", async () => {
h.elements = [frame, rect1, rect2, rect3, rect4];
resizeFrameOverElement(frame, rect4);
@@ -382,7 +382,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, rect3, frame, rect4, rect1]);
});
it("should add elements when frame is in a layer above", async () => {
it.skip("should add elements when frame is in a layer above", async () => {
h.elements = [rect1, rect2, rect3, rect4, frame];
resizeFrameOverElement(frame, rect4);
@@ -393,7 +393,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
});
it("should add elements when frame is in an inner layer", async () => {
it.skip("should add elements when frame is in an inner layer", async () => {
h.elements = [rect1, rect2, frame, rect3, rect4];
resizeFrameOverElement(frame, rect4);
@@ -408,7 +408,7 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
it("should drag element inside, duplicate it and keep it in frame", () => {
it.skip("should drag element inside, duplicate it and keep it in frame", () => {
h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2);
@@ -422,7 +422,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2_copy, rect2, frame]);
});
it("should drag element inside, duplicate it and remove it from frame", () => {
it.skip("should drag element inside, duplicate it and remove it from frame", () => {
h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2);
+50 -246
View File
@@ -19,10 +19,10 @@ import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect } from "./packages/utils";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
@@ -56,130 +56,21 @@ export const bindElementsToFramesAfterDuplication = (
}
};
// --------------------------- Frame Geometry ---------------------------------
class Point {
x: number;
y: number;
export function isElementIntersectingFrame(
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) {
const frameLineSegments = getElementLineSegments(frame);
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
const elementLineSegments = getElementLineSegments(element);
class LineSegment {
first: Point;
second: Point;
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
constructor(pointA: Point, pointB: Point) {
this.first = pointA;
this.second = pointB;
}
public getBoundingBox(): [Point, Point] {
return [
new Point(
Math.min(this.first.x, this.second.x),
Math.min(this.first.y, this.second.y),
),
new Point(
Math.max(this.first.x, this.second.x),
Math.max(this.first.y, this.second.y),
),
];
}
}
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
class FrameGeometry {
private static EPSILON = 0.000001;
private static crossProduct(a: Point, b: Point) {
return a.x * b.y - b.x * a.y;
}
private static doBoundingBoxesIntersect(
a: [Point, Point],
b: [Point, Point],
) {
return (
a[0].x <= b[1].x &&
a[1].x >= b[0].x &&
a[0].y <= b[1].y &&
a[1].y >= b[0].y
);
}
private static isPointOnLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
const r = this.crossProduct(aTmp.second, bTmp);
return Math.abs(r) < this.EPSILON;
}
private static isPointRightOfLine(a: LineSegment, b: Point) {
const aTmp = new LineSegment(
new Point(0, 0),
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
);
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
return this.crossProduct(aTmp.second, bTmp) < 0;
}
private static lineSegmentTouchesOrCrossesLine(
a: LineSegment,
b: LineSegment,
) {
return (
this.isPointOnLine(a, b.first) ||
this.isPointOnLine(a, b.second) ||
(this.isPointRightOfLine(a, b.first)
? !this.isPointRightOfLine(a, b.second)
: this.isPointRightOfLine(a, b.second))
);
}
private static doLineSegmentsIntersect(
a: [readonly [number, number], readonly [number, number]],
b: [readonly [number, number], readonly [number, number]],
) {
const aSegment = new LineSegment(
new Point(a[0][0], a[0][1]),
new Point(a[1][0], a[1][1]),
);
const bSegment = new LineSegment(
new Point(b[0][0], b[0][1]),
new Point(b[1][0], b[1][1]),
);
const box1 = aSegment.getBoundingBox();
const box2 = bSegment.getBoundingBox();
return (
this.doBoundingBoxesIntersect(box1, box2) &&
this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
);
}
public static isElementIntersectingFrame(
element: ExcalidrawElement,
frame: ExcalidrawFrameElement,
) {
const frameLineSegments = getElementLineSegments(frame);
const elementLineSegments = getElementLineSegments(element);
const intersecting = frameLineSegments.some((frameLineSegment) =>
elementLineSegments.some((elementLineSegment) =>
this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
return intersecting;
}
return intersecting;
}
export const getElementsCompletelyInFrame = (
@@ -207,10 +98,7 @@ export const isElementContainingFrame = (
export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement,
) =>
elements.filter((element) =>
FrameGeometry.isElementIntersectingFrame(element, frame),
);
) => elements.filter((element) => isElementIntersectingFrame(element, frame));
export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[],
@@ -236,7 +124,7 @@ export const elementOverlapsWithFrame = (
) => {
return (
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame) ||
isElementIntersectingFrame(element, frame) ||
isElementContainingFrame([frame], element, frame)
);
};
@@ -273,7 +161,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
return !!elementsInGroup.find(
(element) =>
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame),
isElementIntersectingFrame(element, frame),
);
};
@@ -294,7 +182,7 @@ export const groupsAreCompletelyOutOfFrame = (
elementsInGroup.find(
(element) =>
elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame),
isElementIntersectingFrame(element, frame),
) === undefined
);
};
@@ -323,24 +211,7 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
export const getFrameElements = (
allElements: ExcalidrawElementsIncludingDeleted,
frameId: string,
opts?: { includeBoundArrows?: boolean },
) => {
return allElements.filter((element) => {
if (element.frameId === frameId) {
return true;
}
if (opts?.includeBoundArrows && element.type === "arrow") {
const bindingId = element.startBinding?.elementId;
if (bindingId) {
const boundElement = Scene.getScene(element)?.getElement(bindingId);
if (boundElement?.frameId === frameId) {
return true;
}
}
}
return false;
});
};
) => allElements.filter((element) => element.frameId === frameId);
export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted,
@@ -371,7 +242,7 @@ export const getElementsInResizingFrame = (
);
for (const element of elementsNotCompletelyInFrame) {
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) {
if (!isElementIntersectingFrame(element, frame)) {
if (element.groupIds.length === 0) {
nextElementsInFrame.delete(element);
}
@@ -468,14 +339,6 @@ export const getContainingFrame = (
return null;
};
export const isValidFrameChild = (element: ExcalidrawElement) => {
return (
element.type !== "frame" &&
// arrows that are bound to elements cannot be frame children
(element.type !== "arrow" || (!element.startBinding && !element.endBinding))
);
};
// --------------------------- Frame Operations -------------------------------
/**
@@ -488,20 +351,17 @@ export const addElementsToFrame = (
elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement,
) => {
const { allElementsIndexMap, currTargetFrameChildrenMap } =
allElements.reduce(
(acc, element, index) => {
acc.allElementsIndexMap.set(element.id, index);
if (element.frameId === frame.id) {
acc.currTargetFrameChildrenMap.set(element.id, true);
}
return acc;
},
{
allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(),
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const { currTargetFrameChildrenMap } = allElements.reduce(
(acc, element, index) => {
if (element.frameId === frame.id) {
acc.currTargetFrameChildrenMap.set(element.id, true);
}
return acc;
},
{
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
@@ -514,9 +374,6 @@ export const addElementsToFrame = (
elementsToAdd,
)) {
if (!currTargetFrameChildrenMap.has(element.id)) {
if (!isValidFrameChild(element)) {
continue;
}
finalElementsToAdd.push(element);
}
@@ -530,66 +387,6 @@ export const addElementsToFrame = (
}
}
const finalElementsToAddSet = new Set(finalElementsToAdd.map((el) => el.id));
const nextElements: ExcalidrawElement[] = [];
const processedElements = new Set<ExcalidrawElement["id"]>();
for (const element of allElements) {
if (processedElements.has(element.id)) {
continue;
}
processedElements.add(element.id);
if (
finalElementsToAddSet.has(element.id) ||
(element.frameId && element.frameId === frame.id)
) {
// will be added in bulk once we process target frame
continue;
}
// target frame
if (element.id === frame.id) {
const currFrameChildren = getFrameElements(allElements, frame.id);
currFrameChildren.forEach((child) => {
processedElements.add(child.id);
});
// if not found, add all children on top by assigning the lowest index
const targetFrameIndex = allElementsIndexMap.get(frame.id) ?? -1;
const { newChildren_left, newChildren_right } = finalElementsToAdd.reduce(
(acc, element) => {
// if index not found, add on top of current frame children
const elementIndex = allElementsIndexMap.get(element.id) ?? Infinity;
if (elementIndex < targetFrameIndex) {
acc.newChildren_left.push(element);
} else {
acc.newChildren_right.push(element);
}
return acc;
},
{
newChildren_left: [] as ExcalidrawElement[],
newChildren_right: [] as ExcalidrawElement[],
},
);
nextElements.push(
...newChildren_left,
...currFrameChildren,
...newChildren_right,
element,
);
continue;
}
nextElements.push(element);
}
for (const element of finalElementsToAdd) {
mutateElement(
element,
@@ -599,8 +396,7 @@ export const addElementsToFrame = (
false,
);
}
return nextElements;
return allElements.slice();
};
export const removeElementsFromFrame = (
@@ -608,20 +404,34 @@ export const removeElementsFromFrame = (
elementsToRemove: NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const _elementsToRemove: ExcalidrawElement[] = [];
const _elementsToRemove = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"],
ExcalidrawElement[]
>();
for (const element of elementsToRemove) {
if (element.frameId) {
_elementsToRemove.push(element);
_elementsToRemove.set(element.id, element);
const arr = toRemoveElementsByFrame.get(element.frameId) || [];
arr.push(element);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToRemove.push(boundTextElement);
_elementsToRemove.set(boundTextElement.id, boundTextElement);
arr.push(boundTextElement);
}
toRemoveElementsByFrame.set(element.frameId, arr);
}
}
for (const element of _elementsToRemove) {
for (const [, element] of _elementsToRemove) {
mutateElement(
element,
{
@@ -631,13 +441,7 @@ export const removeElementsFromFrame = (
);
}
const nextElements = moveOneRight(
allElements,
appState,
Array.from(_elementsToRemove),
);
return nextElements;
return allElements.slice();
};
export const removeAllElementsFromFrame = (
+4
View File
@@ -505,3 +505,7 @@ export const rangeIntersection = (
return null;
};
export const isValueInRange = (value: number, min: number, max: number) => {
return value >= min && value <= max;
};
+65
View File
@@ -0,0 +1,65 @@
import { Bounds } from "../element/bounds";
import { Point } from "../types";
export type LineSegment = [Point, Point];
export function getBBox(line: LineSegment): 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 crossProduct(a: Point, b: Point) {
return a[0] * b[1] - b[0] * a[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];
}
export function translate(a: Point, b: Point): Point {
return [a[0] - b[0], a[1] - b[1]];
}
const EPSILON = 0.000001;
export function isPointOnLine(l: LineSegment, p: Point) {
const p1 = translate(l[1], l[0]);
const p2 = translate(p, l[0]);
const r = crossProduct(p1, p2);
return Math.abs(r) < EPSILON;
}
export function isPointRightOfLine(l: LineSegment, p: Point) {
const p1 = translate(l[1], l[0]);
const p2 = translate(p, l[0]);
return crossProduct(p1, p2) < 0;
}
export function isLineSegmentTouchingOrCrossingLine(
a: LineSegment,
b: LineSegment,
) {
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(a: LineSegment, b: LineSegment) {
return (
doBBoxesIntersect(getBBox(a), getBBox(b)) &&
isLineSegmentTouchingOrCrossingLine(a, b) &&
isLineSegmentTouchingOrCrossingLine(b, a)
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
[
{
"path": "dist/excalidraw.production.min.js",
"limit": "305 kB"
"limit": "320 kB"
},
{
"path": "dist/excalidraw-assets/locales",
"name": "dist/excalidraw-assets/locales",
"limit": "270 kB"
"limit": "290 kB"
},
{
"path": "dist/excalidraw-assets/vendor-*.js",
+3 -1
View File
@@ -15,7 +15,9 @@ Please add the latest change on the top under the correct section.
### Features
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [7078](https://github.com/excalidraw/excalidraw/pull/7078)
- Export `elementsOverlappingBBox`, `isElementInsideBBox`, `elementPartiallyOverlapsWithOrContainsBBox` helpers for filtering/checking if elements within bounds. [#6727](https://github.com/excalidraw/excalidraw/pull/6727)
- Regenerate ids by default when using transform api and also update bindings by 0.5px to avoid possible overlapping [#7195](https://github.com/excalidraw/excalidraw/pull/7195)
- Add `selected` prop for `MainMenu.Item` and `MainMenu.ItemCustom` components to indicate active state. [#7078](https://github.com/excalidraw/excalidraw/pull/7078)
## 0.16.1 (2023-09-21)
+6
View File
@@ -254,3 +254,9 @@ export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform";
export {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "../withinBounds";
+6
View File
@@ -229,6 +229,12 @@ export const exportToClipboard = async (
}
};
export * from "./bbox";
export {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "./withinBounds";
export { serializeAsJSON, serializeLibraryAsJSON } from "../data/json";
export {
loadFromBlob,
+262
View File
@@ -0,0 +1,262 @@
import { Bounds } from "../element/bounds";
import { API } from "../tests/helpers/api";
import {
elementPartiallyOverlapsWithOrContainsBBox,
elementsOverlappingBBox,
isElementInsideBBox,
} from "./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)
});
+206
View File
@@ -0,0 +1,206 @@
import type {
ExcalidrawElement,
ExcalidrawFreeDrawElement,
ExcalidrawLinearElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
isArrowElement,
isFreeDrawElement,
isLinearElement,
isTextElement,
} from "../element/typeChecks";
import { isValueInRange, rotatePoint } from "../math";
import type { Point } from "../types";
import { Bounds } from "../element/bounds";
type Element = NonDeletedExcalidrawElement;
type Elements = readonly NonDeletedExcalidrawElement[];
type Points = readonly Point[];
/** @returns vertices relative to element's top-left [0,0] position */
const getNonLinearElementRelativePoints = (
element: Exclude<
Element,
ExcalidrawLinearElement | ExcalidrawFreeDrawElement
>,
): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => {
if (element.type === "diamond") {
return [
[element.width / 2, 0],
[element.width, element.height / 2],
[element.width / 2, element.height],
[0, element.height / 2],
];
}
return [
[0, 0],
[0 + element.width, 0],
[0 + element.width, element.height],
[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: Point = [cx, cy];
const rotatedPoints = points.map((point) =>
rotatePoint([point[0], point[1]], 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 (
(isValueInRange(elementBBox[0], bbox[0], bbox[2]) ||
isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) &&
(isValueInRange(elementBBox[1], bbox[1], bbox[3]) ||
isValueInRange(bbox[1], elementBBox[1], elementBBox[3]))
);
};
export const elementsOverlappingBBox = ({
elements,
bounds,
type,
errorMargin = 0,
}: {
elements: Elements;
bounds: Bounds;
/** 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";
}) => {
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 -5
View File
@@ -69,7 +69,6 @@ import {
} from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps";
import {
isArrowElement,
isEmbeddableElement,
isFrameElement,
isLinearElement,
@@ -985,10 +984,7 @@ const _renderStaticScene = ({
// TODO do we need to check isElementInFrame here?
if (frame && isElementInFrame(element, elements, appState)) {
// do not clip arrows
if (!isArrowElement(element)) {
frameClip(frame, context, renderConfig, appState);
}
frameClip(frame, context, renderConfig, appState);
}
renderElement(element, rc, context, renderConfig, appState);
context.restore();
-2
View File
@@ -39,8 +39,6 @@ export const canChangeRoundness = (type: string) =>
type === "line" ||
type === "diamond";
export const hasText = (type: string) => type === "text";
export const canHaveArrowheads = (type: string) => type === "arrow";
export const getElementAtPosition = (
+6 -2
View File
@@ -1,6 +1,10 @@
import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types";
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
import {
Bounds,
getCommonBounds,
getElementAbsoluteCoords,
} from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils";
import { AppState, BinaryFiles } from "../types";
@@ -221,7 +225,7 @@ export const exportToSvg = async (
const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number,
): [number, number, number, number] => {
): Bounds => {
// we should decide if we are exporting the whole canvas
// if so, we are not clipping elements in the frame
// and therefore, we should not do anything special
-1
View File
@@ -14,7 +14,6 @@ export {
canHaveArrowheads,
canChangeRoundness,
getElementAtPosition,
hasText,
getElementsAtPosition,
} from "./comparisons";
export { getNormalizedZoom } from "./zoom";
@@ -14255,217 +14255,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
exports[`regression tests > should group elements and ungroup them > [end of test] number of renders 1`] = `21`;
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] appState 1`] = `
{
"activeEmbeddable": null,
"activeTool": {
"customType": null,
"lastActiveTool": null,
"locked": false,
"type": "selection",
},
"collaborators": Map {},
"contextMenu": null,
"currentChartType": "bar",
"currentItemBackgroundColor": "#ffc9c9",
"currentItemEndArrowhead": "arrow",
"currentItemFillStyle": "solid",
"currentItemFontFamily": 1,
"currentItemFontSize": 20,
"currentItemOpacity": 100,
"currentItemRoughness": 1,
"currentItemRoundness": "round",
"currentItemStartArrowhead": null,
"currentItemStrokeColor": "#1e1e1e",
"currentItemStrokeStyle": "solid",
"currentItemStrokeWidth": 2,
"currentItemTextAlign": "left",
"cursorButton": "up",
"defaultSidebarDockedPreference": false,
"draggingElement": null,
"editingElement": null,
"editingFrame": null,
"editingGroupId": null,
"editingLinearElement": null,
"elementsToHighlight": null,
"errorMessage": null,
"exportBackground": true,
"exportEmbedScene": false,
"exportScale": 1,
"exportWithDarkMode": false,
"fileHandle": null,
"frameRendering": {
"clip": true,
"enabled": true,
"name": true,
"outline": true,
},
"frameToHighlight": null,
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
"objectsSnapModeEnabled": false,
"offsetLeft": 0,
"offsetTop": 0,
"openDialog": null,
"openMenu": null,
"openPopup": "elementBackground",
"openSidebar": null,
"originSnapOffset": null,
"pasteDialog": {
"data": null,
"shown": false,
},
"penDetected": false,
"penMode": false,
"pendingImageElementId": null,
"previousSelectedElementIds": {},
"resizingElement": null,
"scrollX": 0,
"scrollY": 0,
"scrolledOutside": false,
"selectedElementIds": {
"id0": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
"selectedLinearElement": null,
"selectionElement": null,
"shouldCacheIgnoreZoom": false,
"showHyperlinkPopup": false,
"showStats": false,
"showWelcomeScreen": true,
"snapLines": [],
"startBoundElement": null,
"suggestedBindings": [],
"theme": "light",
"toast": null,
"viewBackgroundColor": "#ffffff",
"viewModeEnabled": false,
"width": 1024,
"zenModeEnabled": false,
"zoom": {
"value": 1,
},
}
`;
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] history 1`] = `
{
"recording": false,
"redoStack": [],
"stateHistory": [
{
"appState": {
"editingGroupId": null,
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": {},
"selectedGroupIds": {},
"viewBackgroundColor": "#ffffff",
},
"elements": [],
},
{
"appState": {
"editingGroupId": null,
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": {
"id0": true,
},
"selectedGroupIds": {},
"viewBackgroundColor": "#ffffff",
},
"elements": [
{
"angle": 0,
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"id": "id0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 2,
"versionNonce": 453191,
"width": 10,
"x": 0,
"y": 0,
},
],
},
{
"appState": {
"editingGroupId": null,
"editingLinearElement": null,
"name": "Untitled-201933152653",
"selectedElementIds": {
"id0": true,
},
"selectedGroupIds": {},
"viewBackgroundColor": "#ffffff",
},
"elements": [
{
"angle": 0,
"backgroundColor": "#ffc9c9",
"boundElements": null,
"fillStyle": "solid",
"frameId": null,
"groupIds": [],
"height": 10,
"id": "id0",
"isDeleted": false,
"link": null,
"locked": false,
"opacity": 100,
"roughness": 1,
"roundness": {
"type": 3,
},
"seed": 1278240551,
"strokeColor": "#1e1e1e",
"strokeStyle": "solid",
"strokeWidth": 2,
"type": "rectangle",
"updated": 1,
"version": 3,
"versionNonce": 2019559783,
"width": 10,
"x": 0,
"y": 0,
},
],
},
],
}
`;
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] number of elements 1`] = `0`;
exports[`regression tests > should show fill icons when element has non transparent background > [end of test] number of renders 1`] = `9`;
exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] appState 1`] = `
{
"activeEmbeddable": null,
+1 -1
View File
@@ -94,7 +94,7 @@ export class API {
angle?: number;
id?: string;
isDeleted?: boolean;
frameId?: ExcalidrawElement["id"];
frameId?: ExcalidrawElement["id"] | null;
groupIds?: string[];
// generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"];
+24
View File
@@ -1202,5 +1202,29 @@ describe("Test Linear Elements", () => {
}),
);
});
it("should not update label position when arrow dragged", () => {
createTwoPointerLinearElement("arrow");
let arrow = h.elements[0] as ExcalidrawLinearElement;
createBoundTextElement(DEFAULT_TEXT, arrow);
let label = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(arrow.x).toBe(20);
expect(arrow.y).toBe(20);
expect(label.x).toBe(0);
expect(label.y).toBe(0);
mouse.reset();
mouse.select(arrow);
mouse.select(label);
mouse.downAt(arrow.x, arrow.y);
mouse.moveTo(arrow.x + 20, arrow.y + 30);
mouse.up(arrow.x + 20, arrow.y + 30);
arrow = h.elements[0] as ExcalidrawLinearElement;
label = h.elements[1] as ExcalidrawTextElementWithContainer;
expect(arrow.x).toBe(80);
expect(arrow.y).toBe(100);
expect(label.x).toBe(0);
expect(label.y).toBe(0);
});
});
});
@@ -535,6 +535,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
>
<button
class="color-picker__button active"
data-testid="color-top-pick-#ffffff"
style="--swatch-color: #ffffff;"
title="#ffffff"
type="button"
@@ -545,6 +546,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
class="color-picker__button"
data-testid="color-top-pick-#f8f9fa"
style="--swatch-color: #f8f9fa;"
title="#f8f9fa"
type="button"
@@ -555,6 +557,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
class="color-picker__button"
data-testid="color-top-pick-#f5faff"
style="--swatch-color: #f5faff;"
title="#f5faff"
type="button"
@@ -565,6 +568,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
class="color-picker__button"
data-testid="color-top-pick-#fffce8"
style="--swatch-color: #fffce8;"
title="#fffce8"
type="button"
@@ -575,6 +579,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button>
<button
class="color-picker__button"
data-testid="color-top-pick-#fdf8f6"
style="--swatch-color: #fdf8f6;"
title="#fdf8f6"
type="button"
-14
View File
@@ -1089,20 +1089,6 @@ describe("regression tests", () => {
});
assertSelectedElements(rect3);
});
it("should show fill icons when element has non transparent background", async () => {
UI.clickTool("rectangle");
expect(screen.queryByText(/fill/i)).not.toBeNull();
mouse.down();
mouse.up(10, 10);
expect(screen.queryByText(/fill/i)).toBeNull();
togglePopover("Background");
UI.clickOnTestId("color-red");
// select rectangle
mouse.reset();
mouse.click();
expect(screen.queryByText(/fill/i)).not.toBeNull();
});
});
it(
+300 -1
View File
@@ -12,6 +12,11 @@ import {
import { AppState } from "../types";
import { API } from "./helpers/api";
import { selectGroupsForSelectedElements } from "../groups";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawSelectionElement,
} from "../element/types";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -23,9 +28,15 @@ beforeEach(() => {
const { h } = window;
type ExcalidrawElementType = Exclude<
ExcalidrawElement,
ExcalidrawSelectionElement
>["type"];
const populateElements = (
elements: {
id: string;
type?: ExcalidrawElementType;
isDeleted?: boolean;
isSelected?: boolean;
groupIds?: string[];
@@ -34,6 +45,7 @@ const populateElements = (
width?: number;
height?: number;
containerId?: string;
frameId?: ExcalidrawFrameElement["id"];
}[],
appState?: Partial<AppState>,
) => {
@@ -50,9 +62,11 @@ const populateElements = (
width = 100,
height = 100,
containerId = null,
frameId = null,
type,
}) => {
const element = API.createElement({
type: containerId ? "text" : "rectangle",
type: type ?? (containerId ? "text" : "rectangle"),
id,
isDeleted,
x,
@@ -61,6 +75,7 @@ const populateElements = (
height,
groupIds,
containerId,
frameId: frameId || null,
});
if (isSelected) {
selectedElementIds[element.id] = true;
@@ -116,6 +131,8 @@ const assertZindex = ({
isSelected?: true;
groupIds?: string[];
containerId?: string;
frameId?: ExcalidrawFrameElement["id"];
type?: ExcalidrawElementType;
}[];
appState?: Partial<AppState>;
operations: [Actions, string[]][];
@@ -1183,3 +1200,285 @@ describe("z-index manipulation", () => {
});
});
});
describe("z-indexing with frames", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
// naming scheme:
// F# ... frame element
// F#_# ... frame child of F# (rectangle)
// R# ... unrelated element (rectangle)
it("moving whole frame by one (normalized)", () => {
// normalized frame order
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R1" },
{ id: "R2" },
],
operations: [
// +1
[actionBringForward, ["R1", "F1_1", "F1_2", "F1", "R2"]],
// +1
[actionBringForward, ["R1", "R2", "F1_1", "F1_2", "F1"]],
// noop
[actionBringForward, ["R1", "R2", "F1_1", "F1_2", "F1"]],
// -1
[actionSendBackward, ["R1", "F1_1", "F1_2", "F1", "R2"]],
// -1
[actionSendBackward, ["F1_1", "F1_2", "F1", "R1", "R2"]],
// noop
[actionSendBackward, ["F1_1", "F1_2", "F1", "R1", "R2"]],
],
});
});
it("moving whole frame by one (DENORMALIZED)", () => {
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "R1" },
{ id: "R2" },
],
operations: [
// +1
[actionBringForward, ["R1", "F1_1", "F1", "F1_2", "R2"]],
// +1
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
// noop
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R1" },
{ id: "F1_2", frameId: "F1" },
{ id: "R2" },
],
operations: [
// +1
[actionBringForward, ["R1", "F1_1", "F1", "R2", "F1_2"]],
// +1
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
// noop
[actionBringForward, ["R1", "R2", "F1_1", "F1", "F1_2"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "R1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R2" },
{ id: "F1_2", frameId: "F1" },
{ id: "R3" },
],
operations: [
// +1
[actionBringForward, ["R1", "F1_1", "R2", "F1", "R3", "F1_2"]],
// +1
// FIXME incorrect, should put F1_1 after R3
[actionBringForward, ["R1", "R2", "F1_1", "R3", "F1", "F1_2"]],
// +1
// FIXME should be noop from previous step after it's fixed
[actionBringForward, ["R1", "R2", "R3", "F1_1", "F1", "F1_2"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "R1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R2" },
{ id: "F1_2", frameId: "F1" },
{ id: "R3" },
],
operations: [
// -1
[actionSendBackward, ["F1_1", "F1", "R1", "F1_2", "R2", "R3"]],
// -1
[actionSendBackward, ["F1_1", "F1", "F1_2", "R1", "R2", "R3"]],
],
});
});
it("moving selected frame children by one (normalized)", () => {
// normalized frame order
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "R1" },
],
operations: [
// +1
[actionBringForward, ["F1_2", "F1_1", "F1", "R1"]],
// noop
[actionBringForward, ["F1_2", "F1_1", "F1", "R1"]],
],
});
// normalized frame order, multiple frames
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame" },
{ id: "R1" },
{ id: "F2_1", frameId: "F2", isSelected: true },
{ id: "F2_2", frameId: "F2" },
{ id: "F2", type: "frame" },
{ id: "R2" },
],
operations: [
// +1
[
actionBringForward,
["F1_2", "F1_1", "F1", "R1", "F2_2", "F2_1", "F2", "R2"],
],
// noop
[
actionBringForward,
["F1_2", "F1_1", "F1", "R1", "F2_2", "F2_1", "F2", "R2"],
],
],
});
});
it("moving selected frame children by one (DENORMALIZED)", () => {
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "F1", type: "frame" },
{ id: "F1_2", frameId: "F1" },
{ id: "R1" },
],
operations: [
// +1
// NOTE not sure what we wanna do here
[actionBringForward, ["F1", "F1_2", "F1_1", "R1"]],
// noop
[actionBringForward, ["F1", "F1_2", "F1_1", "R1"]],
// -1
[actionSendBackward, ["F1", "F1_1", "F1_2", "R1"]],
// noop
[actionSendBackward, ["F1", "F1_1", "F1_2", "R1"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1", isSelected: true },
{ id: "R1" },
{ id: "F1", type: "frame" },
{ id: "F1_2", frameId: "F1" },
{ id: "R2" },
],
operations: [
// +1
// NOTE not sure what we wanna do here
[actionBringForward, ["R1", "F1", "F1_2", "F1_1", "R2"]],
// noop
[actionBringForward, ["R1", "F1", "F1_2", "F1_1", "R2"]],
// -1
[actionSendBackward, ["R1", "F1", "F1_1", "F1_2", "R2"]],
// noop
[actionSendBackward, ["R1", "F1", "F1_1", "F1_2", "R2"]],
],
});
});
it("moving whole frame to front/end", () => {
// normalized frame order
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "F1_2", frameId: "F1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R1" },
{ id: "R2" },
],
operations: [
// +∞
[actionBringToFront, ["R1", "R2", "F1_1", "F1_2", "F1"]],
// noop
[actionBringToFront, ["R1", "R2", "F1_1", "F1_2", "F1"]],
// -∞
[actionSendToBack, ["F1_1", "F1_2", "F1", "R1", "R2"]],
// noop
[actionSendToBack, ["F1_1", "F1_2", "F1", "R1", "R2"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "F1_2", frameId: "F1" },
{ id: "R1" },
{ id: "R2" },
],
operations: [
// +∞
[actionBringToFront, ["R1", "R2", "F1_1", "F1", "F1_2"]],
// noop
[actionBringToFront, ["R1", "R2", "F1_1", "F1", "F1_2"]],
// -∞
[actionSendToBack, ["F1_1", "F1", "F1_2", "R1", "R2"]],
// noop
[actionSendToBack, ["F1_1", "F1", "F1_2", "R1", "R2"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R1" },
{ id: "F1_2", frameId: "F1" },
{ id: "R2" },
],
operations: [
// +∞
[actionBringToFront, ["R1", "R2", "F1_1", "F1", "F1_2"]],
],
});
// DENORMALIZED FRAME ORDER
assertZindex({
elements: [
{ id: "F1_1", frameId: "F1" },
{ id: "R1" },
{ id: "F1", type: "frame", isSelected: true },
{ id: "R2" },
{ id: "F1_2", frameId: "F1" },
{ id: "R3" },
],
operations: [
// +1
[actionBringToFront, ["R1", "R2", "R3", "F1_1", "F1", "F1_2"]],
],
});
});
});
+9
View File
@@ -695,3 +695,12 @@ export type KeyboardModifiersObject = {
altKey: boolean;
metaKey: boolean;
};
export type Primitive =
| number
| string
| boolean
| bigint
| symbol
| null
| undefined;
+167 -109
View File
@@ -1,16 +1,14 @@
import { bumpVersion } from "./element/mutateElement";
import { isFrameElement } from "./element/typeChecks";
import { ExcalidrawElement } from "./element/types";
import { groupByFrames } from "./frame";
import { ExcalidrawElement, ExcalidrawFrameElement } from "./element/types";
import { getElementsInGroup } from "./groups";
import { getSelectedElements } from "./scene";
import Scene from "./scene/Scene";
import { AppState } from "./types";
import { arrayToMap, findIndex, findLastIndex } from "./utils";
// elements that do not belong to a frame are considered a root element
const isRootElement = (element: ExcalidrawElement) => {
return !element.frameId;
const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
return element.frameId === frameId || element.id === frameId;
};
/**
@@ -35,6 +33,7 @@ const getIndicesToMove = (
? elementsToBeMoved
: getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
while (++index < elements.length) {
@@ -106,6 +105,26 @@ const getTargetIndexAccountingForBinding = (
}
};
const getContiguousFrameRangeElements = (
allElements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameElement["id"],
) => {
let rangeStart = -1;
let rangeEnd = -1;
allElements.forEach((element, index) => {
if (isOfTargetFrame(element, frameId)) {
if (rangeStart === -1) {
rangeStart = index;
}
rangeEnd = index;
}
});
if (rangeStart === -1) {
return [];
}
return allElements.slice(rangeStart, rangeEnd + 1);
};
/**
* Returns next candidate index that's available to be moved to. Currently that
* is a non-deleted element, and not inside a group (unless we're editing it).
@@ -115,6 +134,11 @@ const getTargetIndex = (
elements: readonly ExcalidrawElement[],
boundaryIndex: number,
direction: "left" | "right",
/**
* Frame id if moving frame children.
* If whole frame (including all children) is being moved, supply `null`.
*/
containingFrame: ExcalidrawFrameElement["id"] | null,
) => {
const sourceElement = elements[boundaryIndex];
@@ -122,6 +146,9 @@ const getTargetIndex = (
if (element.isDeleted) {
return false;
}
if (containingFrame) {
return element.frameId === containingFrame;
}
// if we're editing group, find closest sibling irrespective of whether
// there's a different-group element between them (for legacy reasons)
if (appState.editingGroupId) {
@@ -132,8 +159,12 @@ const getTargetIndex = (
const candidateIndex =
direction === "left"
? findLastIndex(elements, indexFilter, Math.max(0, boundaryIndex - 1))
: findIndex(elements, indexFilter, boundaryIndex + 1);
? findLastIndex(
elements,
(el) => indexFilter(el),
Math.max(0, boundaryIndex - 1),
)
: findIndex(elements, (el) => indexFilter(el), boundaryIndex + 1);
const nextElement = elements[candidateIndex];
@@ -156,6 +187,19 @@ const getTargetIndex = (
}
}
if (
!containingFrame &&
(nextElement.frameId || nextElement.type === "frame")
) {
const frameElements = getContiguousFrameRangeElements(
elements,
nextElement.frameId || nextElement.id,
);
return direction === "left"
? elements.indexOf(frameElements[0])
: elements.indexOf(frameElements[frameElements.length - 1]);
}
if (!nextElement.groupIds.length) {
return (
getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
@@ -195,13 +239,12 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
}, {} as Record<string, ExcalidrawElement>);
};
const _shiftElements = (
const shiftElementsByOne = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
let groupedIndices = toContiguousGroups(indicesToMove);
@@ -209,16 +252,30 @@ const _shiftElements = (
groupedIndices = groupedIndices.reverse();
}
const selectedFrames = new Set<ExcalidrawFrameElement["id"]>(
indicesToMove
.filter((idx) => elements[idx].type === "frame")
.map((idx) => elements[idx].id),
);
groupedIndices.forEach((indices, i) => {
const leadingIndex = indices[0];
const trailingIndex = indices[indices.length - 1];
const boundaryIndex = direction === "left" ? leadingIndex : trailingIndex;
const containingFrame = indices.some((idx) => {
const el = elements[idx];
return el.frameId && selectedFrames.has(el.frameId);
})
? null
: elements[boundaryIndex]?.frameId;
const targetIndex = getTargetIndex(
appState,
elements,
boundaryIndex,
direction,
containingFrame,
);
if (targetIndex === -1 || boundaryIndex === targetIndex) {
@@ -263,34 +320,25 @@ const _shiftElements = (
});
};
const shiftElements = (
appState: AppState,
const shiftElementsToEnd = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
containingFrame: ExcalidrawFrameElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shift(
elements,
appState,
direction,
_shiftElements,
elementsToBeMoved,
);
};
const _shiftElementsToEnd = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
) => {
const indicesToMove = getIndicesToMove(elements, appState);
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number;
let trailingIndex: number;
if (direction === "left") {
if (appState.editingGroupId) {
if (containingFrame) {
leadingIndex = findIndex(elements, (el) =>
isOfTargetFrame(el, containingFrame),
);
} else if (appState.editingGroupId) {
const groupElements = getElementsInGroup(
elements,
appState.editingGroupId,
@@ -305,7 +353,11 @@ const _shiftElementsToEnd = (
trailingIndex = indicesToMove[indicesToMove.length - 1];
} else {
if (appState.editingGroupId) {
if (containingFrame) {
trailingIndex = findLastIndex(elements, (el) =>
isOfTargetFrame(el, containingFrame),
);
} else if (appState.editingGroupId) {
const groupElements = getElementsInGroup(
elements,
appState.editingGroupId,
@@ -321,6 +373,10 @@ const _shiftElementsToEnd = (
leadingIndex = indicesToMove[0];
}
if (leadingIndex === -1) {
leadingIndex = 0;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]);
@@ -349,121 +405,123 @@ const _shiftElementsToEnd = (
];
};
const shiftElementsToEnd = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shift(
elements,
appState,
direction,
_shiftElementsToEnd,
elementsToBeMoved,
);
};
function shift(
elements: readonly ExcalidrawElement[],
function shiftElementsAccountingForFrames(
allElements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
shiftFunction: (
elements: ExcalidrawElement[],
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
containingFrame: ExcalidrawFrameElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => ExcalidrawElement[] | readonly ExcalidrawElement[],
elementsToBeMoved?: readonly ExcalidrawElement[],
) {
const elementsMap = arrayToMap(elements);
const frameElementsMap = groupByFrames(elements);
// in case root is non-existent, we promote children elements to root
let rootElements = elements.filter(
(element) =>
isRootElement(element) ||
(element.frameId && !elementsMap.has(element.frameId)),
const elementsToMove = arrayToMap(
getSelectedElements(allElements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
);
// and remove non-existet root
for (const frameId of frameElementsMap.keys()) {
if (!elementsMap.has(frameId)) {
frameElementsMap.delete(frameId);
const frameAwareContiguousElementsToMove: {
regularElements: ExcalidrawElement[];
frameChildren: Map<ExcalidrawFrameElement["id"], ExcalidrawElement[]>;
} = { regularElements: [], frameChildren: new Map() };
const fullySelectedFrames = new Set<ExcalidrawFrameElement["id"]>();
for (const element of allElements) {
if (elementsToMove.has(element.id) && isFrameElement(element)) {
fullySelectedFrames.add(element.id);
}
}
// shift the root elements first
rootElements = shiftFunction(
rootElements,
for (const element of allElements) {
if (elementsToMove.has(element.id)) {
if (
isFrameElement(element) ||
(element.frameId && fullySelectedFrames.has(element.frameId))
) {
frameAwareContiguousElementsToMove.regularElements.push(element);
} else if (!element.frameId) {
frameAwareContiguousElementsToMove.regularElements.push(element);
} else {
const frameChildren =
frameAwareContiguousElementsToMove.frameChildren.get(
element.frameId,
) || [];
frameChildren.push(element);
frameAwareContiguousElementsToMove.frameChildren.set(
element.frameId,
frameChildren,
);
}
}
}
let nextElements = allElements;
const frameChildrenSets = Array.from(
frameAwareContiguousElementsToMove.frameChildren.entries(),
);
for (const [frameId, children] of frameChildrenSets) {
nextElements = shiftFunction(
allElements,
appState,
direction,
frameId,
children,
);
}
return shiftFunction(
nextElements,
appState,
direction,
elementsToBeMoved,
) as ExcalidrawElement[];
// shift the elements in frames if needed
frameElementsMap.forEach((frameElements, frameId) => {
if (!appState.selectedElementIds[frameId]) {
frameElementsMap.set(
frameId,
shiftFunction(
frameElements,
appState,
direction,
elementsToBeMoved,
) as ExcalidrawElement[],
);
}
});
// return the final elements
let finalElements: ExcalidrawElement[] = [];
rootElements.forEach((element) => {
if (isFrameElement(element)) {
finalElements = [
...finalElements,
...(frameElementsMap.get(element.id) ?? []),
element,
];
} else {
finalElements = [...finalElements, element];
}
});
return finalElements;
null,
frameAwareContiguousElementsToMove.regularElements,
);
}
// public API
// -----------------------------------------------------------------------------
export const moveOneLeft = (
elements: readonly ExcalidrawElement[],
allElements: readonly ExcalidrawElement[],
appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shiftElements(appState, elements, "left", elementsToBeMoved);
return shiftElementsByOne(allElements, appState, "left");
};
export const moveOneRight = (
elements: readonly ExcalidrawElement[],
allElements: readonly ExcalidrawElement[],
appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shiftElements(appState, elements, "right", elementsToBeMoved);
return shiftElementsByOne(allElements, appState, "right");
};
export const moveAllLeft = (
elements: readonly ExcalidrawElement[],
allElements: readonly ExcalidrawElement[],
appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shiftElementsToEnd(elements, appState, "left", elementsToBeMoved);
return shiftElementsAccountingForFrames(
allElements,
appState,
"left",
shiftElementsToEnd,
);
};
export const moveAllRight = (
elements: readonly ExcalidrawElement[],
allElements: readonly ExcalidrawElement[],
appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shiftElementsToEnd(elements, appState, "right", elementsToBeMoved);
return shiftElementsAccountingForFrames(
allElements,
appState,
"right",
shiftElementsToEnd,
);
};