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. 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 { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: async (elements, appState, _, app) => {
const elementsToCopy = app.scene.getSelectedElements({ const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: 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 { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
@@ -38,15 +46,91 @@ export const actionCopy = register({
export const actionPaste = register({ export const actionPaste = register({
name: "paste", name: "paste",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => { perform: async (elements, appState, data, app) => {
app.pasteFromClipboard(null); 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 { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
+2 -9
View File
@@ -155,12 +155,7 @@ const duplicateElements = (
groupId, groupId,
).flatMap((element) => ).flatMap((element) =>
isFrameElement(element) isFrameElement(element)
? [ ? [...getFrameElements(elements, element.id), element]
...getFrameElements(elements, element.id, {
includeBoundArrows: true,
}),
element,
]
: [element], : [element],
); );
@@ -186,9 +181,7 @@ const duplicateElements = (
continue; continue;
} }
if (isElementAFrame) { if (isElementAFrame) {
const elementsInFrame = getFrameElements(sortedElements, element.id, { const elementsInFrame = getFrameElements(sortedElements, element.id);
includeBoundArrows: true,
});
elementsWithClones.push( elementsWithClones.push(
...markAsProcessed([ ...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 { import {
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE, DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS,
@@ -51,6 +51,7 @@ import {
DEFAULT_FONT_SIZE, DEFAULT_FONT_SIZE,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
STROKE_WIDTH,
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { import {
@@ -82,7 +83,6 @@ import { getLanguage, t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import { import {
canChangeRoundness,
canHaveArrowheads, canHaveArrowheads,
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
getSelectedElements, getSelectedElements,
@@ -118,25 +118,44 @@ export const changeProperty = (
}); });
}; };
export const getFormValue = function <T>( export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
defaultValue: T, isRelevantElement: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingElement = appState.editingElement; const editingElement = appState.editingElement;
const nonDeletedElements = getNonDeletedElements(elements); const nonDeletedElements = getNonDeletedElements(elements);
return (
(editingElement && getAttribute(editingElement)) ?? let ret: T | null = null;
(isSomeElementSelected(nonDeletedElements, appState)
? getCommonAttributeOfSelectedElements( if (editingElement) {
nonDeletedElements, ret = getAttribute(editingElement);
}
if (!ret) {
const hasSelection = isSomeElementSelected(nonDeletedElements, appState);
if (hasSelection) {
ret =
getCommonAttributeOfSelectedElements(
isRelevantElement === true
? nonDeletedElements
: nonDeletedElements.filter((el) => isRelevantElement(el)),
appState, appState,
getAttribute, getAttribute,
) ) ??
: defaultValue) ?? (typeof defaultValue === "function"
defaultValue ? defaultValue(true)
); : defaultValue);
} else {
ret =
typeof defaultValue === "function" ? defaultValue(false) : defaultValue;
}
}
return ret;
}; };
const offsetElementAfterFontResize = ( const offsetElementAfterFontResize = (
@@ -247,6 +266,7 @@ export const actionChangeStrokeColor = register({
elements, elements,
appState, appState,
(element) => element.strokeColor, (element) => element.strokeColor,
true,
appState.currentItemStrokeColor, appState.currentItemStrokeColor,
)} )}
onChange={(color) => updateData({ currentItemStrokeColor: color })} onChange={(color) => updateData({ currentItemStrokeColor: color })}
@@ -289,6 +309,7 @@ export const actionChangeBackgroundColor = register({
elements, elements,
appState, appState,
(element) => element.backgroundColor, (element) => element.backgroundColor,
true,
appState.currentItemBackgroundColor, appState.currentItemBackgroundColor,
)} )}
onChange={(color) => updateData({ currentItemBackgroundColor: color })} onChange={(color) => updateData({ currentItemBackgroundColor: color })}
@@ -338,23 +359,28 @@ export const actionChangeFillStyle = register({
} (${getShortcutKey("Alt-Click")})`, } (${getShortcutKey("Alt-Click")})`,
icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon, icon: allElementsZigZag ? FillZigZagIcon : FillHachureIcon,
active: allElementsZigZag ? true : undefined, active: allElementsZigZag ? true : undefined,
testId: `fill-hachure`,
}, },
{ {
value: "cross-hatch", value: "cross-hatch",
text: t("labels.crossHatch"), text: t("labels.crossHatch"),
icon: FillCrossHatchIcon, icon: FillCrossHatchIcon,
testId: `fill-cross-hatch`,
}, },
{ {
value: "solid", value: "solid",
text: t("labels.solid"), text: t("labels.solid"),
icon: FillSolidIcon, icon: FillSolidIcon,
testId: `fill-solid`,
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => element.fillStyle, (element) => element.fillStyle,
appState.currentItemFillStyle, (element) => element.hasOwnProperty("fillStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemFillStyle,
)} )}
onClick={(value, event) => { onClick={(value, event) => {
const nextValue = const nextValue =
@@ -393,26 +419,31 @@ export const actionChangeStrokeWidth = register({
group="stroke-width" group="stroke-width"
options={[ options={[
{ {
value: 1, value: STROKE_WIDTH.thin,
text: t("labels.thin"), text: t("labels.thin"),
icon: StrokeWidthBaseIcon, icon: StrokeWidthBaseIcon,
testId: "strokeWidth-thin",
}, },
{ {
value: 2, value: STROKE_WIDTH.bold,
text: t("labels.bold"), text: t("labels.bold"),
icon: StrokeWidthBoldIcon, icon: StrokeWidthBoldIcon,
testId: "strokeWidth-bold",
}, },
{ {
value: 4, value: STROKE_WIDTH.extraBold,
text: t("labels.extraBold"), text: t("labels.extraBold"),
icon: StrokeWidthExtraBoldIcon, icon: StrokeWidthExtraBoldIcon,
testId: "strokeWidth-extraBold",
}, },
]} ]}
value={getFormValue( value={getFormValue(
elements, elements,
appState, appState,
(element) => element.strokeWidth, (element) => element.strokeWidth,
appState.currentItemStrokeWidth, (element) => element.hasOwnProperty("strokeWidth"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeWidth,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -461,7 +492,9 @@ export const actionChangeSloppiness = register({
elements, elements,
appState, appState,
(element) => element.roughness, (element) => element.roughness,
appState.currentItemRoughness, (element) => element.hasOwnProperty("roughness"),
(hasSelection) =>
hasSelection ? null : appState.currentItemRoughness,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -509,7 +542,9 @@ export const actionChangeStrokeStyle = register({
elements, elements,
appState, appState,
(element) => element.strokeStyle, (element) => element.strokeStyle,
appState.currentItemStrokeStyle, (element) => element.hasOwnProperty("strokeStyle"),
(hasSelection) =>
hasSelection ? null : appState.currentItemStrokeStyle,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -549,6 +584,7 @@ export const actionChangeOpacity = register({
elements, elements,
appState, appState,
(element) => element.opacity, (element) => element.opacity,
true,
appState.currentItemOpacity, appState.currentItemOpacity,
) ?? undefined ) ?? undefined
} }
@@ -607,7 +643,12 @@ export const actionChangeFontSize = register({
} }
return null; 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)} onChange={(value) => updateData(value)}
/> />
@@ -692,21 +733,25 @@ export const actionChangeFontFamily = register({
value: FontFamilyValues; value: FontFamilyValues;
text: string; text: string;
icon: JSX.Element; icon: JSX.Element;
testId: string;
}[] = [ }[] = [
{ {
value: FONT_FAMILY.Virgil, value: FONT_FAMILY.Virgil,
text: t("labels.handDrawn"), text: t("labels.handDrawn"),
icon: FreedrawIcon, icon: FreedrawIcon,
testId: "font-family-virgil",
}, },
{ {
value: FONT_FAMILY.Helvetica, value: FONT_FAMILY.Helvetica,
text: t("labels.normal"), text: t("labels.normal"),
icon: FontFamilyNormalIcon, icon: FontFamilyNormalIcon,
testId: "font-family-normal",
}, },
{ {
value: FONT_FAMILY.Cascadia, value: FONT_FAMILY.Cascadia,
text: t("labels.code"), text: t("labels.code"),
icon: FontFamilyCodeIcon, icon: FontFamilyCodeIcon,
testId: "font-family-code",
}, },
]; ];
@@ -729,7 +774,12 @@ export const actionChangeFontFamily = register({
} }
return null; 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)} onChange={(value) => updateData(value)}
/> />
@@ -806,7 +856,10 @@ export const actionChangeTextAlign = register({
} }
return null; return null;
}, },
appState.currentItemTextAlign, (element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) =>
hasSelection ? null : appState.currentItemTextAlign,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -882,7 +935,9 @@ export const actionChangeVerticalAlign = register({
} }
return null; return null;
}, },
VERTICAL_ALIGN.MIDDLE, (element) =>
isTextElement(element) || getBoundTextElement(element) !== null,
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -947,9 +1002,9 @@ export const actionChangeRoundness = register({
appState, appState,
(element) => (element) =>
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp", hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
(canChangeRoundness(appState.activeTool.type) && (element) => element.hasOwnProperty("roundness"),
appState.currentItemRoundness) || (hasSelection) =>
null, hasSelection ? null : appState.currentItemRoundness,
)} )}
onChange={(value) => updateData(value)} onChange={(value) => updateData(value)}
/> />
@@ -1043,6 +1098,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead ? element.startArrowhead
: appState.currentItemStartArrowhead, : appState.currentItemStartArrowhead,
true,
appState.currentItemStartArrowhead, appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
@@ -1089,6 +1145,7 @@ export const actionChangeArrowhead = register({
isLinearElement(element) && canHaveArrowheads(element.type) isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead ? element.endArrowhead
: appState.currentItemEndArrowhead, : appState.currentItemEndArrowhead,
true,
appState.currentItemEndArrowhead, appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
+4 -7
View File
@@ -118,7 +118,7 @@ export const copyToClipboard = async (
await copyTextToSystemClipboard(json); await copyTextToSystemClipboard(json);
} catch (error: any) { } catch (error: any) {
PREFER_APP_CLIPBOARD = true; PREFER_APP_CLIPBOARD = true;
console.error(error); throw error;
} }
}; };
@@ -193,7 +193,7 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
* via async clipboard API if supported) * via async clipboard API if supported)
*/ */
const getSystemClipboard = async ( const getSystemClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise< ): Promise<
| { type: "text"; value: string } | { type: "text"; value: string }
@@ -205,10 +205,7 @@ const getSystemClipboard = async (
return { type: "mixedContent", value: mixedContent }; return { type: "mixedContent", value: mixedContent };
} }
const text = event const text = event.clipboardData?.getData("text/plain");
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return { type: "text", value: (text || "").trim() }; return { type: "text", value: (text || "").trim() };
} catch { } catch {
@@ -220,7 +217,7 @@ const getSystemClipboard = async (
* Attempts to parse clipboard. Prefers system clipboard. * Attempts to parse clipboard. Prefers system clipboard.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event, isPlainPaste); const systemClipboard = await getSystemClipboard(event, isPlainPaste);
+7 -6
View File
@@ -11,7 +11,6 @@ import {
hasBackground, hasBackground,
hasStrokeStyle, hasStrokeStyle,
hasStrokeWidth, hasStrokeWidth,
hasText,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppClassProperties, UIAppState, Zoom } from "../types"; import { AppClassProperties, UIAppState, Zoom } from "../types";
@@ -20,7 +19,7 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { hasBoundTextElement } from "../element/typeChecks"; import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
import clsx from "clsx"; import clsx from "clsx";
import { actionToggleZenMode } from "../actions"; import { actionToggleZenMode } from "../actions";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
@@ -66,7 +65,8 @@ export const SelectedShapeActions = ({
const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const isRTL = document.documentElement.getAttribute("dir") === "rtl";
const showFillIcons = const showFillIcons =
hasBackground(appState.activeTool.type) || (hasBackground(appState.activeTool.type) &&
!isTransparent(appState.currentItemBackgroundColor)) ||
targetElements.some( targetElements.some(
(element) => (element) =>
hasBackground(element.type) && !isTransparent(element.backgroundColor), hasBackground(element.type) && !isTransparent(element.backgroundColor),
@@ -123,14 +123,15 @@ export const SelectedShapeActions = ({
<>{renderAction("changeRoundness")}</> <>{renderAction("changeRoundness")}</>
)} )}
{(hasText(appState.activeTool.type) || {(appState.activeTool.type === "text" ||
targetElements.some((element) => hasText(element.type))) && ( targetElements.some(isTextElement)) && (
<> <>
{renderAction("changeFontSize")} {renderAction("changeFontSize")}
{renderAction("changeFontFamily")} {renderAction("changeFontFamily")}
{suppportsHorizontalAlign(targetElements) && {(appState.activeTool.type === "text" ||
suppportsHorizontalAlign(targetElements)) &&
renderAction("changeTextAlign")} renderAction("changeTextAlign")}
</> </>
)} )}
+21 -6
View File
@@ -1275,6 +1275,12 @@ class App extends React.Component<AppProps, AppState> {
top={this.state.contextMenu.top} top={this.state.contextMenu.top}
left={this.state.contextMenu.left} left={this.state.contextMenu.left}
actionManager={this.actionManager} actionManager={this.actionManager}
onClose={(cb) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
cb?.();
});
}}
/> />
)} )}
<StaticCanvas <StaticCanvas
@@ -2195,14 +2201,21 @@ class App extends React.Component<AppProps, AppState> {
}; };
public pasteFromClipboard = withBatchedUpdates( public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent | null) => { async (event: ClipboardEvent) => {
const isPlainPaste = !!(IS_PLAIN_PASTE && event); const isPlainPaste = !!(IS_PLAIN_PASTE && event);
console.warn(
"pasteFromClipboard",
event?.clipboardData?.types,
event?.clipboardData?.getData("text/plain"),
);
// #686 // #686
const target = document.activeElement; const target = document.activeElement;
const isExcalidrawActive = const isExcalidrawActive =
this.excalidrawContainerRef.current?.contains(target); this.excalidrawContainerRef.current?.contains(target);
if (event && !isExcalidrawActive) { if (event && !isExcalidrawActive) {
console.log("exit (1)");
return; return;
} }
@@ -2215,6 +2228,7 @@ class App extends React.Component<AppProps, AppState> {
(!(elementUnderCursor instanceof HTMLCanvasElement) || (!(elementUnderCursor instanceof HTMLCanvasElement) ||
isWritableElement(target)) isWritableElement(target))
) { ) {
console.log("exit (2)");
return; return;
} }
@@ -2554,12 +2568,18 @@ class App extends React.Component<AppProps, AppState> {
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily); const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
if (text.length) { if (text.length) {
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
x,
y: currentY,
});
const element = newTextElement({ const element = newTextElement({
...textElementProps, ...textElementProps,
x, x,
y: currentY, y: currentY,
text, text,
lineHeight, lineHeight,
frameId: topLayerFrame ? topLayerFrame.id : null,
}); });
acc.push(element); acc.push(element);
currentY += element.height + LINE_GAP; currentY += element.height + LINE_GAP;
@@ -3574,11 +3594,6 @@ class App extends React.Component<AppProps, AppState> {
return getElementsAtPosition(elements, (element) => return getElementsAtPosition(elements, (element) =>
hitTest(element, this.state, this.frameNameBoundsCache, x, y), hitTest(element, this.state, this.frameNameBoundsCache, x, y),
).filter((element) => { ).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 // hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element); const containingFrame = getContainingFrame(element);
return containingFrame && return containingFrame &&
+1
View File
@@ -55,6 +55,7 @@ export const TopPicks = ({
type="button" type="button"
title={color} title={color}
onClick={() => onChange(color)} onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
> >
<div className="color-picker__button-outline" /> <div className="color-picker__button-outline" />
</button> </button>
+5 -9
View File
@@ -9,11 +9,7 @@ import {
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { import { useExcalidrawAppState, useExcalidrawElements } from "./App";
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react"; import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
@@ -25,14 +21,14 @@ type ContextMenuProps = {
items: ContextMenuItems; items: ContextMenuItems;
top: number; top: number;
left: number; left: number;
onClose: (cb?: () => void) => void;
}; };
export const CONTEXT_MENU_SEPARATOR = "separator"; export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo( export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => { ({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
@@ -54,7 +50,7 @@ export const ContextMenu = React.memo(
return ( return (
<Popover <Popover
onCloseRequest={() => setAppState({ contextMenu: null })} onCloseRequest={() => onClose()}
top={top} top={top}
left={left} left={left}
fitInViewport={true} fitInViewport={true}
@@ -102,7 +98,7 @@ export const ContextMenu = React.memo(
// we need update state before executing the action in case // we need update state before executing the action in case
// the action uses the appState it's being passed (that still // the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state. // contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => { onClose(() => {
actionManager.executeAction(item, "contextMenu"); actionManager.executeAction(item, "contextMenu");
}); });
}} }}
+6
View File
@@ -302,6 +302,12 @@ export const ROUGHNESS = {
cartoonist: 2, cartoonist: 2,
} as const; } as const;
export const STROKE_WIDTH = {
thin: 1,
bold: 2,
extraBold: 4,
} as const;
export const DEFAULT_ELEMENT_PROPS: { export const DEFAULT_ELEMENT_PROPS: {
strokeColor: ExcalidrawElement["strokeColor"]; strokeColor: ExcalidrawElement["strokeColor"];
backgroundColor: ExcalidrawElement["backgroundColor"]; 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", "backgroundColor": "#d8f5a2",
"boundElements": [ "boundElements": [
{ {
"id": "id40", "id": "id41",
"type": "arrow", "type": "arrow",
}, },
{ {
"id": "id41", "id": "id42",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -45,7 +45,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id41", "id": "id42",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -97,12 +97,12 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0.5,
], ],
[ [
395, 394.5,
35, 34.5,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -110,7 +110,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id42", "elementId": "id43",
"focus": -0.08139534883720931, "focus": -0.08139534883720931,
"gap": 1, "gap": 1,
}, },
@@ -150,11 +150,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
400, 399.5,
0, 0,
], ],
], ],
@@ -186,7 +186,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id40", "id": "id41",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -222,7 +222,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id43", "id": "id44",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -266,7 +266,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id43", "id": "id44",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -309,7 +309,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id44", "id": "id45",
"type": "text", "type": "text",
}, },
], ],
@@ -317,7 +317,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"endBinding": { "endBinding": {
"elementId": "text-2", "elementId": "text-2",
"focus": 0, "focus": 0,
"gap": 5, "gap": 205,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
@@ -331,11 +331,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -355,7 +355,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 255, "x": 255,
"y": 239, "y": 239,
} }
@@ -367,7 +367,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id43", "containerId": "id44",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -395,7 +395,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 340, "x": 240,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -406,13 +406,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id33", "id": "id34",
"type": "text", "type": "text",
}, },
], ],
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id35", "elementId": "id36",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -428,11 +428,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -441,7 +441,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id34", "elementId": "id35",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -452,7 +452,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 255, "x": 255,
"y": 239, "y": 239,
} }
@@ -464,7 +464,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id32", "containerId": "id33",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -492,7 +492,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 340, "x": 240,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -503,7 +503,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id32", "id": "id33",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -538,7 +538,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id32", "id": "id33",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -562,7 +562,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"version": 2, "version": 2,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 100, "width": 100,
"x": 555, "x": 355,
"y": 189, "y": 189,
} }
`; `;
@@ -573,13 +573,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id37", "id": "id38",
"type": "text", "type": "text",
}, },
], ],
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": { "endBinding": {
"elementId": "id39", "elementId": "id40",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -595,11 +595,11 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -608,7 +608,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": { "startBinding": {
"elementId": "id38", "elementId": "id39",
"focus": 0, "focus": 0,
"gap": 1, "gap": 1,
}, },
@@ -619,7 +619,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"updated": 1, "updated": 1,
"version": 3, "version": 3,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 255, "x": 255,
"y": 239, "y": 239,
} }
@@ -631,7 +631,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id36", "containerId": "id37",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -659,7 +659,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 340, "x": 240,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -671,7 +671,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id36", "id": "id37",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -715,7 +715,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"baseline": 0, "baseline": 0,
"boundElements": [ "boundElements": [
{ {
"id": "id36", "id": "id37",
"type": "arrow", "type": "arrow",
}, },
], ],
@@ -747,7 +747,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "top", "verticalAlign": "top",
"width": 100, "width": 100,
"x": 555, "x": 355,
"y": 226.5, "y": 226.5,
} }
`; `;
@@ -801,11 +801,11 @@ exports[`Test Transform > should transform linear elements 1`] = `
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -821,7 +821,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100, "x": 100,
"y": 20, "y": 20,
} }
@@ -846,11 +846,11 @@ exports[`Test Transform > should transform linear elements 2`] = `
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -866,7 +866,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 450, "x": 450,
"y": 20, "y": 20,
} }
@@ -895,7 +895,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
0, 0,
], ],
[ [
300, 100,
0, 0,
], ],
], ],
@@ -911,7 +911,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 100, "x": 100,
"y": 60, "y": 60,
} }
@@ -940,7 +940,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
0, 0,
], ],
[ [
300, 100,
0, 0,
], ],
], ],
@@ -956,7 +956,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 450, "x": 450,
"y": 60, "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`] = ` 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, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -1294,11 +1244,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -1314,13 +1264,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1, "updated": 1,
"version": 1, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 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, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -1335,7 +1285,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 130, "height": 0,
"id": Any<String>, "id": Any<String>,
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
@@ -1344,11 +1294,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -1357,20 +1307,20 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"seed": Any<Number>, "seed": Any<Number>,
"startArrowhead": null, "startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#1098ad", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 2, "version": 1,
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"width": 300, "width": 100,
"x": 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, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
@@ -1394,11 +1344,11 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"opacity": 100, "opacity": 100,
"points": [ "points": [
[ [
0, 0.5,
0, 0,
], ],
[ [
300, 99.5,
0, 0,
], ],
], ],
@@ -1414,7 +1364,57 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": Any<Number>, "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, "x": 100,
"y": 400, "y": 400,
} }
@@ -1426,7 +1426,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id24", "containerId": "id25",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1454,7 +1454,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 130, "width": 130,
"x": 185, "x": 85,
"y": 87.5, "y": 87.5,
} }
`; `;
@@ -1465,7 +1465,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id25", "containerId": "id26",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1493,7 +1493,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 200, "width": 200,
"x": 150, "x": 50,
"y": 187.5, "y": 187.5,
} }
`; `;
@@ -1504,7 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id26", "containerId": "id27",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1533,7 +1533,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 150, "width": 150,
"x": 175, "x": 75,
"y": 275, "y": 275,
} }
`; `;
@@ -1544,7 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id27", "containerId": "id28",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1573,7 +1573,7 @@ LABELLED ARROW",
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 150, "width": 150,
"x": 175, "x": 75,
"y": 375, "y": 375,
} }
`; `;
@@ -1584,7 +1584,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id18", "id": "id19",
"type": "text", "type": "text",
}, },
], ],
@@ -1619,7 +1619,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id19", "id": "id20",
"type": "text", "type": "text",
}, },
], ],
@@ -1654,7 +1654,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id20", "id": "id21",
"type": "text", "type": "text",
}, },
], ],
@@ -1689,7 +1689,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#fff3bf", "backgroundColor": "#fff3bf",
"boundElements": [ "boundElements": [
{ {
"id": "id21", "id": "id22",
"type": "text", "type": "text",
}, },
], ],
@@ -1724,7 +1724,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": [ "boundElements": [
{ {
"id": "id22", "id": "id23",
"type": "text", "type": "text",
}, },
], ],
@@ -1759,7 +1759,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "#ffec99", "backgroundColor": "#ffec99",
"boundElements": [ "boundElements": [
{ {
"id": "id23", "id": "id24",
"type": "text", "type": "text",
}, },
], ],
@@ -1794,7 +1794,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id12", "containerId": "id13",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1833,7 +1833,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id13", "containerId": "id14",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1873,7 +1873,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id14", "containerId": "id15",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1915,7 +1915,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id15", "containerId": "id16",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1955,7 +1955,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id16", "containerId": "id17",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
@@ -1996,7 +1996,7 @@ exports[`Test Transform > should transform to text containers when label provide
"backgroundColor": "transparent", "backgroundColor": "transparent",
"baseline": 0, "baseline": 0,
"boundElements": null, "boundElements": null,
"containerId": "id17", "containerId": "id18",
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
+6 -13
View File
@@ -43,7 +43,6 @@ import {
measureBaseline, measureBaseline,
} from "../element/textElement"; } from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { isValidFrameChild } from "../frame";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, 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. * NOTE mutates elements.
*/ */
@@ -405,16 +404,12 @@ const repairFrameMembership = (
element: Mutable<ExcalidrawElement>, element: Mutable<ExcalidrawElement>,
elementsMap: Map<string, Mutable<ExcalidrawElement>>, elementsMap: Map<string, Mutable<ExcalidrawElement>>,
) => { ) => {
if (!element.frameId) { if (element.frameId) {
return; const containingFrame = elementsMap.get(element.frameId);
}
if ( if (!containingFrame) {
!isValidFrameChild(element) || element.frameId = null;
// target frame not exists }
!elementsMap.get(element.frameId)
) {
element.frameId = null;
} }
}; };
@@ -458,8 +453,6 @@ export const restoreElements = (
// repair binding. Mutates elements. // repair binding. Mutates elements.
const restoredElementsMap = arrayToMap(restoredElements); const restoredElementsMap = arrayToMap(restoredElements);
for (const element of 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) { if (element.frameId) {
repairFrameMembership(element, restoredElementsMap); repairFrameMembership(element, restoredElementsMap);
} }
+44 -9
View File
@@ -5,7 +5,31 @@ import {
} from "./transform"; } from "./transform";
import { ExcalidrawArrowElement } from "../element/types"; import { ExcalidrawArrowElement } from "../element/types";
const opts = { regenerateIds: false };
describe("Test Transform", () => { 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", () => { it("should transform regular shapes", () => {
const elements = [ const elements = [
{ {
@@ -59,6 +83,7 @@ describe("Test Transform", () => {
convertToExcalidrawElements( convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => { ).forEach((ele) => {
expect(ele).toMatchSnapshot({ expect(ele).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
@@ -87,6 +112,7 @@ describe("Test Transform", () => {
]; ];
convertToExcalidrawElements( convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
).forEach((ele) => { ).forEach((ele) => {
expect(ele).toMatchSnapshot({ expect(ele).toMatchSnapshot({
seed: expect.any(Number), seed: expect.any(Number),
@@ -128,6 +154,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
@@ -210,6 +237,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(12); expect(excaldrawElements.length).toBe(12);
@@ -267,6 +295,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(8); expect(excaldrawElements.length).toBe(8);
@@ -300,6 +329,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
@@ -321,7 +351,7 @@ describe("Test Transform", () => {
}); });
expect(text).toMatchObject({ expect(text).toMatchObject({
x: 340, x: 240,
y: 226.5, y: 226.5,
type: "text", type: "text",
text: "HELLO WORLD!!", text: "HELLO WORLD!!",
@@ -341,7 +371,7 @@ describe("Test Transform", () => {
}); });
expect(ellipse).toMatchObject({ expect(ellipse).toMatchObject({
x: 555, x: 355,
y: 189, y: 189,
type: "ellipse", type: "ellipse",
boundElements: [ boundElements: [
@@ -383,10 +413,10 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements; const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({ expect(arrow).toMatchObject({
@@ -406,7 +436,7 @@ describe("Test Transform", () => {
}); });
expect(text1).toMatchObject({ expect(text1).toMatchObject({
x: 340, x: 240,
y: 226.5, y: 226.5,
type: "text", type: "text",
text: "HELLO WORLD!!", text: "HELLO WORLD!!",
@@ -426,7 +456,7 @@ describe("Test Transform", () => {
}); });
expect(text3).toMatchObject({ expect(text3).toMatchObject({
x: 555, x: 355,
y: 226.5, y: 226.5,
type: "text", type: "text",
boundElements: [ boundElements: [
@@ -499,6 +529,7 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(5); expect(excaldrawElements.length).toBe(5);
@@ -547,6 +578,7 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
@@ -600,17 +632,18 @@ describe("Test Transform", () => {
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(4); expect(excaldrawElements.length).toBe(4);
const [, , arrow] = excaldrawElements; const [, , arrow, text] = excaldrawElements;
expect(arrow).toMatchObject({ expect(arrow).toMatchObject({
type: "arrow", type: "arrow",
x: 255, x: 255,
y: 239, y: 239,
boundElements: [ boundElements: [
{ {
id: "id46", id: text.id,
type: "text", type: "text",
}, },
], ],
@@ -650,17 +683,18 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(2); expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements; const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1", elementId: "rect-1",
focus: 0, focus: 0,
gap: 5, gap: 205,
}); });
expect(rect.boundElements).toStrictEqual([ expect(rect.boundElements).toStrictEqual([
{ {
id: "id47", id: arrow.id,
type: "arrow", type: "arrow",
}, },
]); ]);
@@ -692,6 +726,7 @@ describe("Test Transform", () => {
]; ];
const excaldrawElements = convertToExcalidrawElements( const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[], elements as ExcalidrawElementSkeleton[],
opts,
); );
expect(excaldrawElements.length).toBe(1); expect(excaldrawElements.length).toBe(1);
+89 -5
View File
@@ -39,6 +39,8 @@ import {
} from "../element/types"; } from "../element/types";
import { MarkOptional } from "../utility-types"; import { MarkOptional } from "../utility-types";
import { assertNever, getFontString } from "../utils"; import { assertNever, getFontString } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
export type ValidLinearElement = { export type ValidLinearElement = {
type: "arrow" | "line"; type: "arrow" | "line";
@@ -159,7 +161,7 @@ export type ExcalidrawElementSkeleton =
} & Partial<ExcalidrawImageElement>); } & Partial<ExcalidrawImageElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = { const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 300, width: 100,
height: 0, 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 { return {
linearElement, linearElement,
startBoundElement, startBoundElement,
@@ -384,18 +428,28 @@ class ElementStore {
} }
export const convertToExcalidrawElements = ( export const convertToExcalidrawElements = (
elements: ExcalidrawElementSkeleton[] | null, elementsSkeleton: ExcalidrawElementSkeleton[] | null,
opts?: { regenerateIds: boolean },
) => { ) => {
if (!elements) { if (!elementsSkeleton) {
return []; return [];
} }
const elements: ExcalidrawElementSkeleton[] = JSON.parse(
JSON.stringify(elementsSkeleton),
);
const elementStore = new ElementStore(); const elementStore = new ElementStore();
const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>(); const elementsWithIds = new Map<string, ExcalidrawElementSkeleton>();
const oldToNewElementIdMap = new Map<string, string>();
// Create individual elements // Create individual elements
for (const element of elements) { for (const element of elements) {
let excalidrawElement: ExcalidrawElement; let excalidrawElement: ExcalidrawElement;
const originalId = element.id;
if (opts?.regenerateIds !== false) {
Object.assign(element, { id: randomId() });
}
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "ellipse": case "ellipse":
@@ -444,6 +498,11 @@ export const convertToExcalidrawElements = (
], ],
...element, ...element,
}); });
Object.assign(
excalidrawElement,
getSizeFromPoints(excalidrawElement.points),
);
break; break;
} }
case "text": { case "text": {
@@ -499,6 +558,9 @@ export const convertToExcalidrawElements = (
} else { } else {
elementStore.add(excalidrawElement); elementStore.add(excalidrawElement);
elementsWithIds.set(excalidrawElement.id, element); 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; element.type === "arrow" ? element?.start : undefined;
const originalEnd = const originalEnd =
element.type === "arrow" ? element?.end : undefined; 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 } = const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement( bindLinearElementToElement(
container as ExcalidrawArrowElement, container as ExcalidrawArrowElement,
@@ -539,13 +613,23 @@ export const convertToExcalidrawElements = (
} else { } else {
switch (element.type) { switch (element.type) {
case "arrow": { 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 } = const { linearElement, startBoundElement, endBoundElement } =
bindLinearElementToElement( bindLinearElementToElement(
excalidrawElement as ExcalidrawArrowElement, excalidrawElement as ExcalidrawArrowElement,
element.start, start,
element.end, end,
elementStore, elementStore,
); );
elementStore.add(linearElement); elementStore.add(linearElement);
elementStore.add(startBoundElement); elementStore.add(startBoundElement);
elementStore.add(endBoundElement); elementStore.add(endBoundElement);
+1 -1
View File
@@ -392,7 +392,7 @@ export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds, [x1, y1, x2, y2]: Bounds,
angle: number, angle: number,
appState: Pick<UIAppState, "zoom">, appState: Pick<UIAppState, "zoom">,
): [x: number, y: number, width: number, height: number] => { ): Bounds => {
const size = DEFAULT_LINK_SIZE; const size = DEFAULT_LINK_SIZE;
const linkWidth = size / appState.zoom.value; const linkWidth = size / appState.zoom.value;
const linkHeight = 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 { arrayToMap, tupleToCoors } from "../utils";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement"; import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { isValidFrameChild } from "../frame";
export type SuggestedBinding = export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement> | 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 // 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; type MaybeQuadraticSolution = [number | null, number | null] | false;
// x and y position of top left corner, x and y position of bottom right corner // 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 { export class ElementBounds {
private static boundsCache = new WeakMap< private static boundsCache = new WeakMap<
@@ -63,7 +68,7 @@ export class ElementBounds {
} }
private static calculateBounds(element: ExcalidrawElement): Bounds { private static calculateBounds(element: ExcalidrawElement): Bounds {
let bounds: [number, number, number, number]; let bounds: Bounds;
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
@@ -387,7 +392,7 @@ const getCubicBezierCurveBound = (
export const getMinMaxXYFromCurvePathOps = ( export const getMinMaxXYFromCurvePathOps = (
ops: Op[], ops: Op[],
transformXY?: (x: number, y: number) => [number, number], transformXY?: (x: number, y: number) => [number, number],
): [number, number, number, number] => { ): Bounds => {
let currentP: Point = [0, 0]; let currentP: Point = [0, 0];
const { minX, minY, maxX, maxY } = ops.reduce( const { minX, minY, maxX, maxY } = ops.reduce(
@@ -435,9 +440,9 @@ export const getMinMaxXYFromCurvePathOps = (
return [minX, minY, maxX, maxY]; return [minX, minY, maxX, maxY];
}; };
const getBoundsFromPoints = ( export const getBoundsFromPoints = (
points: ExcalidrawFreeDrawElement["points"], points: ExcalidrawFreeDrawElement["points"],
): [number, number, number, number] => { ): Bounds => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
@@ -589,7 +594,7 @@ const getLinearElementRotatedBounds = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
cx: number, cx: number,
cy: number, cy: number,
): [number, number, number, number] => { ): Bounds => {
if (element.points.length < 2) { if (element.points.length < 2) {
const [pointX, pointY] = element.points[0]; const [pointX, pointY] = element.points[0];
const [x, y] = rotate( const [x, y] = rotate(
@@ -600,7 +605,7 @@ const getLinearElementRotatedBounds = (
element.angle, element.angle,
); );
let coords: [number, number, number, number] = [x, y, x, y]; let coords: Bounds = [x, y, x, y];
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -625,12 +630,7 @@ const getLinearElementRotatedBounds = (
const transformXY = (x: number, y: number) => const transformXY = (x: number, y: number) =>
rotate(element.x + x, element.y + y, cx, cy, element.angle); rotate(element.x + x, element.y + y, cx, cy, element.angle);
const res = getMinMaxXYFromCurvePathOps(ops, transformXY); const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
let coords: [number, number, number, number] = [ let coords: Bounds = [res[0], res[1], res[2], res[3]];
res[0],
res[1],
res[2],
res[3],
];
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { if (boundTextElement) {
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
@@ -692,7 +692,7 @@ export const getResizedElementAbsoluteCoords = (
nextWidth: number, nextWidth: number,
nextHeight: number, nextHeight: number,
normalizePoints: boolean, normalizePoints: boolean,
): [number, number, number, number] => { ): Bounds => {
if (!(isLinearElement(element) || isFreeDrawElement(element))) { if (!(isLinearElement(element) || isFreeDrawElement(element))) {
return [ return [
element.x, element.x,
@@ -709,7 +709,7 @@ export const getResizedElementAbsoluteCoords = (
normalizePoints, normalizePoints,
); );
let bounds: [number, number, number, number]; let bounds: Bounds;
if (isFreeDrawElement(element)) { if (isFreeDrawElement(element)) {
// Free Draw // Free Draw
@@ -740,7 +740,7 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = ( export const getElementPointsCoords = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[], points: readonly (readonly [number, number])[],
): [number, number, number, number] => { ): Bounds => {
// This might be computationally heavey // This might be computationally heavey
const gen = rough.generator(); const gen = rough.generator();
const curve = const curve =
+3 -1
View File
@@ -494,7 +494,9 @@ const hitTestFreeDrawElement = (
// for filled freedraw shapes, support // for filled freedraw shapes, support
// selecting from inside // selecting from inside
if (shape && shape.sets.length) { 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; return false;
+51 -37
View File
@@ -1,5 +1,5 @@
import { updateBoundElements } from "./binding"; import { updateBoundElements } from "./binding";
import { getCommonBounds } from "./bounds"; import { Bounds, getCommonBounds } from "./bounds";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { getPerfectElementSize } from "./sizeHelpers"; import { getPerfectElementSize } from "./sizeHelpers";
import { NonDeletedExcalidrawElement } from "./types"; import { NonDeletedExcalidrawElement } from "./types";
@@ -8,7 +8,11 @@ import { getBoundTextElement } from "./textElement";
import { isSelectedViaGroup } from "../groups"; import { isSelectedViaGroup } from "../groups";
import { getGridPoint } from "../math"; import { getGridPoint } from "../math";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { isFrameElement } from "./typeChecks"; import {
isArrowElement,
isBoundToContainer,
isFrameElement,
} from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
pointerDownState: PointerDownState, pointerDownState: PointerDownState,
@@ -35,44 +39,41 @@ export const dragSelectedElements = (
if (frames.length > 0) { if (frames.length > 0) {
const elementsInFrames = scene const elementsInFrames = scene
.getNonDeletedElements() .getNonDeletedElements()
.filter((e) => !isBoundToContainer(e))
.filter((e) => e.frameId !== null) .filter((e) => e.frameId !== null)
.filter((e) => frames.includes(e.frameId!)); .filter((e) => frames.includes(e.frameId!));
elementsInFrames.forEach((element) => elementsToUpdate.add(element)); 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) => { elementsToUpdate.forEach((element) => {
updateElementCoords( updateElementCoords(pointerDownState, element, adjustedOffset);
pointerDownState,
element,
offset,
snapOffset,
gridSize,
);
// update coords of bound text only if we're dragging the container directly // update coords of bound text only if we're dragging the container directly
// (we don't drag the group that it's part of) // (we don't drag the group that it's part of)
if ( if (
// Don't update coords of arrow label since we calculate its position during render
!isArrowElement(element) &&
// container isn't part of any group // container isn't part of any group
// (perf optim so we don't check `isSelectedViaGroup()` in every case) // (perf optim so we don't check `isSelectedViaGroup()` in every case)
!element.groupIds.length || (!element.groupIds.length ||
// container is part of a group, but we're dragging the container directly // container is part of a group, but we're dragging the container directly
(appState.editingGroupId && !isSelectedViaGroup(appState, element)) (appState.editingGroupId && !isSelectedViaGroup(appState, element)))
) { ) {
const textElement = getBoundTextElement(element); const textElement = getBoundTextElement(element);
if ( if (textElement) {
textElement && updateElementCoords(pointerDownState, textElement, adjustedOffset);
// 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,
);
} }
} }
updateBoundElements(element, { updateBoundElements(element, {
@@ -81,23 +82,20 @@ export const dragSelectedElements = (
}); });
}; };
const updateElementCoords = ( const calculateOffset = (
pointerDownState: PointerDownState, commonBounds: Bounds,
element: NonDeletedExcalidrawElement,
dragOffset: { x: number; y: number }, dragOffset: { x: number; y: number },
snapOffset: { x: number; y: number }, snapOffset: { x: number; y: number },
gridSize: AppState["gridSize"], gridSize: AppState["gridSize"],
) => { ): { x: number; y: number } => {
const originalElement = const [x, y] = commonBounds;
pointerDownState.originalElements.get(element.id) ?? element; let nextX = x + dragOffset.x + snapOffset.x;
let nextY = y + dragOffset.y + snapOffset.y;
let nextX = originalElement.x + dragOffset.x + snapOffset.x;
let nextY = originalElement.y + dragOffset.y + snapOffset.y;
if (snapOffset.x === 0 || snapOffset.y === 0) { if (snapOffset.x === 0 || snapOffset.y === 0) {
const [nextGridX, nextGridY] = getGridPoint( const [nextGridX, nextGridY] = getGridPoint(
originalElement.x + dragOffset.x, x + dragOffset.x,
originalElement.y + dragOffset.y, y + dragOffset.y,
gridSize, gridSize,
); );
@@ -109,6 +107,22 @@ const updateElementCoords = (
nextY = nextGridY; 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, { mutateElement(element, {
x: nextX, x: nextX,
+8
View File
@@ -48,6 +48,9 @@ const RE_VALTOWN =
const RE_GENERIC_EMBED = const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i; /^<(?: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([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
@@ -60,6 +63,7 @@ const ALLOWED_DOMAINS = new Set([
"*.simplepdf.eu", "*.simplepdf.eu",
"stackblitz.com", "stackblitz.com",
"val.town", "val.town",
"giphy.com",
]); ]);
const createSrcDoc = (body: string) => { const createSrcDoc = (body: string) => {
@@ -309,6 +313,10 @@ export const extractSrc = (htmlString: string): string => {
return gistMatch[1]; 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); const match = htmlString.match(RE_GENERIC_EMBED);
if (match && match.length === 2) { if (match && match.length === 2) {
return match[1]; return match[1];
+2 -1
View File
@@ -21,6 +21,7 @@ import {
} from "../math"; } from "../math";
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from "."; import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
import { import {
Bounds,
getCurvePathOps, getCurvePathOps,
getElementPointsCoords, getElementPointsCoords,
getMinMaxXYFromCurvePathOps, getMinMaxXYFromCurvePathOps,
@@ -1316,7 +1317,7 @@ export class LinearElementEditor {
static getMinMaxXYWithBoundText = ( static getMinMaxXYWithBoundText = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
elementBounds: [number, number, number, number], elementBounds: Bounds,
boundTextElement: ExcalidrawTextElementWithContainer, boundTextElement: ExcalidrawTextElementWithContainer,
): [number, number, number, number, number, number] => { ): [number, number, number, number, number, number] => {
let [x1, y1, x2, y2] = elementBounds; let [x1, y1, x2, y2] = elementBounds;
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MaybeTransformHandleType, MaybeTransformHandleType,
} from "./transformHandles"; } from "./transformHandles";
import { AppState, Zoom } from "../types"; import { AppState, Zoom } from "../types";
import { Bounds } from "./bounds";
const isInsideTransformHandle = ( const isInsideTransformHandle = (
transformHandle: TransformHandle, transformHandle: TransformHandle,
@@ -87,7 +88,7 @@ export const getElementWithTransformHandleType = (
}; };
export const getTransformHandleTypeFromCoords = ( export const getTransformHandleTypeFromCoords = (
[x1, y1, x2, y2]: readonly [number, number, number, number], [x1, y1, x2, y2]: Bounds,
scenePointerX: number, scenePointerX: number,
scenePointerY: number, scenePointerY: number,
zoom: Zoom, zoom: Zoom,
+2 -2
View File
@@ -4,7 +4,7 @@ import {
PointerType, PointerType,
} from "./types"; } from "./types";
import { getElementAbsoluteCoords } from "./bounds"; import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types"; import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
@@ -23,7 +23,7 @@ export type TransformHandleDirection =
export type TransformHandleType = TransformHandleDirection | "rotation"; export type TransformHandleType = TransformHandleDirection | "rotation";
export type TransformHandle = [number, number, number, number]; export type TransformHandle = Bounds;
export type TransformHandles = Partial<{ export type TransformHandles = Partial<{
[T in TransformHandleType]: TransformHandle; [T in TransformHandleType]: TransformHandle;
}>; }>;
+13 -13
View File
@@ -123,7 +123,7 @@ describe("adding elements to frames", () => {
const commonTestCases = async ( const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame, 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 () => { it("should add an element", async () => {
h.elements = [frame, rect2]; 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 () => { it("should add an element", async () => {
h.elements = [rect2, frame]; h.elements = [rect2, frame];
@@ -212,7 +212,7 @@ describe("adding elements to frames", () => {
}); });
describe("when frame is in an inner layer", async () => { 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]; h.elements = [rect2, frame, rect3];
func(frame, rect2); func(frame, rect2);
@@ -223,7 +223,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, rect3, frame]); 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]; h.elements = [rect2, rect1, frame, rect4, rect3];
func(frame, rect2); func(frame, rect2);
@@ -234,7 +234,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]); 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]; h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2); func(frame, rect2);
@@ -289,7 +289,7 @@ describe("adding elements to frames", () => {
describe("resizing frame over elements", async () => { describe("resizing frame over elements", async () => {
await commonTestCases(resizeFrameOverElement); await commonTestCases(resizeFrameOverElement);
it("resizing over text containers and labelled arrows", async () => { it.skip("resizing over text containers and labelled arrows", async () => {
await resizingTest( await resizingTest(
"rectangle", "rectangle",
["frame", "rectangle", "text"], ["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]; h.elements = [frame, arrow, text];
resizeFrameOverElement(frame, arrow); resizeFrameOverElement(frame, arrow);
@@ -359,7 +359,7 @@ describe("adding elements to frames", () => {
expectEqualIds([arrow, text, frame]); 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]; h.elements = [arrow, frame, text];
resizeFrameOverElement(frame, arrow); resizeFrameOverElement(frame, arrow);
@@ -371,7 +371,7 @@ describe("adding elements to frames", () => {
}); });
describe("resizing frame over elements but downwards", async () => { 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]; h.elements = [frame, rect1, rect2, rect3, rect4];
resizeFrameOverElement(frame, rect4); resizeFrameOverElement(frame, rect4);
@@ -382,7 +382,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2, rect3, frame, rect4, rect1]); 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]; h.elements = [rect1, rect2, rect3, rect4, frame];
resizeFrameOverElement(frame, rect4); resizeFrameOverElement(frame, rect4);
@@ -393,7 +393,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect1, rect2, rect3, frame, rect4]); 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]; h.elements = [rect1, rect2, frame, rect3, rect4];
resizeFrameOverElement(frame, rect4); resizeFrameOverElement(frame, rect4);
@@ -408,7 +408,7 @@ describe("adding elements to frames", () => {
describe("dragging elements into the frame", async () => { describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame); 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]; h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2); dragElementIntoFrame(frame, rect2);
@@ -422,7 +422,7 @@ describe("adding elements to frames", () => {
expectEqualIds([rect2_copy, rect2, frame]); 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]; h.elements = [frame, rect2];
dragElementIntoFrame(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 { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element"; import { isFrameElement } from "./element";
import { moveOneRight } from "./zindex";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds"; import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect } from "./packages/utils";
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
@@ -56,130 +56,21 @@ export const bindElementsToFramesAfterDuplication = (
} }
}; };
// --------------------------- Frame Geometry --------------------------------- export function isElementIntersectingFrame(
class Point { element: ExcalidrawElement,
x: number; frame: ExcalidrawFrameElement,
y: number; ) {
const frameLineSegments = getElementLineSegments(frame);
constructor(x: number, y: number) { const elementLineSegments = getElementLineSegments(element);
this.x = x;
this.y = y;
}
}
class LineSegment { const intersecting = frameLineSegments.some((frameLineSegment) =>
first: Point; elementLineSegments.some((elementLineSegment) =>
second: Point; doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
),
);
constructor(pointA: Point, pointB: Point) { return intersecting;
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;
}
} }
export const getElementsCompletelyInFrame = ( export const getElementsCompletelyInFrame = (
@@ -207,10 +98,7 @@ export const isElementContainingFrame = (
export const getElementsIntersectingFrame = ( export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => ) => elements.filter((element) => isElementIntersectingFrame(element, frame));
elements.filter((element) =>
FrameGeometry.isElementIntersectingFrame(element, frame),
);
export const elementsAreInFrameBounds = ( export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -236,7 +124,7 @@ export const elementOverlapsWithFrame = (
) => { ) => {
return ( return (
elementsAreInFrameBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame) || isElementIntersectingFrame(element, frame) ||
isElementContainingFrame([frame], element, frame) isElementContainingFrame([frame], element, frame)
); );
}; };
@@ -273,7 +161,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
return !!elementsInGroup.find( return !!elementsInGroup.find(
(element) => (element) =>
elementsAreInFrameBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
); );
}; };
@@ -294,7 +182,7 @@ export const groupsAreCompletelyOutOfFrame = (
elementsInGroup.find( elementsInGroup.find(
(element) => (element) =>
elementsAreInFrameBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
FrameGeometry.isElementIntersectingFrame(element, frame), isElementIntersectingFrame(element, frame),
) === undefined ) === undefined
); );
}; };
@@ -323,24 +211,7 @@ export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
export const getFrameElements = ( export const getFrameElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frameId: string, frameId: string,
opts?: { includeBoundArrows?: boolean }, ) => allElements.filter((element) => element.frameId === frameId);
) => {
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;
});
};
export const getElementsInResizingFrame = ( export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
@@ -371,7 +242,7 @@ export const getElementsInResizingFrame = (
); );
for (const element of elementsNotCompletelyInFrame) { for (const element of elementsNotCompletelyInFrame) {
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) { if (!isElementIntersectingFrame(element, frame)) {
if (element.groupIds.length === 0) { if (element.groupIds.length === 0) {
nextElementsInFrame.delete(element); nextElementsInFrame.delete(element);
} }
@@ -468,14 +339,6 @@ export const getContainingFrame = (
return null; 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 ------------------------------- // --------------------------- Frame Operations -------------------------------
/** /**
@@ -488,20 +351,17 @@ export const addElementsToFrame = (
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameElement,
) => { ) => {
const { allElementsIndexMap, currTargetFrameChildrenMap } = const { currTargetFrameChildrenMap } = allElements.reduce(
allElements.reduce( (acc, element, index) => {
(acc, element, index) => { if (element.frameId === frame.id) {
acc.allElementsIndexMap.set(element.id, index); acc.currTargetFrameChildrenMap.set(element.id, true);
if (element.frameId === frame.id) { }
acc.currTargetFrameChildrenMap.set(element.id, true); return acc;
} },
return acc; {
}, currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
{ },
allElementsIndexMap: new Map<ExcalidrawElement["id"], number>(), );
currTargetFrameChildrenMap: new Map<ExcalidrawElement["id"], true>(),
},
);
const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id)); const suppliedElementsToAddSet = new Set(elementsToAdd.map((el) => el.id));
@@ -514,9 +374,6 @@ export const addElementsToFrame = (
elementsToAdd, elementsToAdd,
)) { )) {
if (!currTargetFrameChildrenMap.has(element.id)) { if (!currTargetFrameChildrenMap.has(element.id)) {
if (!isValidFrameChild(element)) {
continue;
}
finalElementsToAdd.push(element); 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) { for (const element of finalElementsToAdd) {
mutateElement( mutateElement(
element, element,
@@ -599,8 +396,7 @@ export const addElementsToFrame = (
false, false,
); );
} }
return allElements.slice();
return nextElements;
}; };
export const removeElementsFromFrame = ( export const removeElementsFromFrame = (
@@ -608,20 +404,34 @@ export const removeElementsFromFrame = (
elementsToRemove: NonDeletedExcalidrawElement[], elementsToRemove: NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const _elementsToRemove: ExcalidrawElement[] = []; const _elementsToRemove = new Map<
ExcalidrawElement["id"],
ExcalidrawElement
>();
const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"],
ExcalidrawElement[]
>();
for (const element of elementsToRemove) { for (const element of elementsToRemove) {
if (element.frameId) { if (element.frameId) {
_elementsToRemove.push(element); _elementsToRemove.set(element.id, element);
const arr = toRemoveElementsByFrame.get(element.frameId) || [];
arr.push(element);
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
if (boundTextElement) { 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( mutateElement(
element, element,
{ {
@@ -631,13 +441,7 @@ export const removeElementsFromFrame = (
); );
} }
const nextElements = moveOneRight( return allElements.slice();
allElements,
appState,
Array.from(_elementsToRemove),
);
return nextElements;
}; };
export const removeAllElementsFromFrame = ( export const removeAllElementsFromFrame = (
+4
View File
@@ -505,3 +505,7 @@ export const rangeIntersection = (
return null; 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", "path": "dist/excalidraw.production.min.js",
"limit": "305 kB" "limit": "320 kB"
}, },
{ {
"path": "dist/excalidraw-assets/locales", "path": "dist/excalidraw-assets/locales",
"name": "dist/excalidraw-assets/locales", "name": "dist/excalidraw-assets/locales",
"limit": "270 kB" "limit": "290 kB"
}, },
{ {
"path": "dist/excalidraw-assets/vendor-*.js", "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 ### 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) ## 0.16.1 (2023-09-21)
+6
View File
@@ -254,3 +254,9 @@ export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url"; export { normalizeLink } from "../../data/url";
export { convertToExcalidrawElements } from "../../data/transform"; 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 { serializeAsJSON, serializeLibraryAsJSON } from "../data/json";
export { export {
loadFromBlob, 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"; } from "../element/Hyperlink";
import { renderSnaps } from "./renderSnaps"; import { renderSnaps } from "./renderSnaps";
import { import {
isArrowElement,
isEmbeddableElement, isEmbeddableElement,
isFrameElement, isFrameElement,
isLinearElement, isLinearElement,
@@ -985,10 +984,7 @@ const _renderStaticScene = ({
// TODO do we need to check isElementInFrame here? // TODO do we need to check isElementInFrame here?
if (frame && isElementInFrame(element, elements, appState)) { if (frame && isElementInFrame(element, elements, appState)) {
// do not clip arrows frameClip(frame, context, renderConfig, appState);
if (!isArrowElement(element)) {
frameClip(frame, context, renderConfig, appState);
}
} }
renderElement(element, rc, context, renderConfig, appState); renderElement(element, rc, context, renderConfig, appState);
context.restore(); context.restore();
-2
View File
@@ -39,8 +39,6 @@ export const canChangeRoundness = (type: string) =>
type === "line" || type === "line" ||
type === "diamond"; type === "diamond";
export const hasText = (type: string) => type === "text";
export const canHaveArrowheads = (type: string) => type === "arrow"; export const canHaveArrowheads = (type: string) => type === "arrow";
export const getElementAtPosition = ( export const getElementAtPosition = (
+6 -2
View File
@@ -1,6 +1,10 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { NonDeletedExcalidrawElement } from "../element/types"; 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 { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import { distance, isOnlyExportingSingleFrame } from "../utils"; import { distance, isOnlyExportingSingleFrame } from "../utils";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
@@ -221,7 +225,7 @@ export const exportToSvg = async (
const getCanvasSize = ( const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
exportPadding: number, exportPadding: number,
): [number, number, number, number] => { ): Bounds => {
// we should decide if we are exporting the whole canvas // we should decide if we are exporting the whole canvas
// if so, we are not clipping elements in the frame // if so, we are not clipping elements in the frame
// and therefore, we should not do anything special // and therefore, we should not do anything special
-1
View File
@@ -14,7 +14,6 @@ export {
canHaveArrowheads, canHaveArrowheads,
canChangeRoundness, canChangeRoundness,
getElementAtPosition, getElementAtPosition,
hasText,
getElementsAtPosition, getElementsAtPosition,
} from "./comparisons"; } from "./comparisons";
export { getNormalizedZoom } from "./zoom"; 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 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`] = ` exports[`regression tests > single-clicking on a subgroup of a selected group should not alter selection > [end of test] appState 1`] = `
{ {
"activeEmbeddable": null, "activeEmbeddable": null,
+1 -1
View File
@@ -94,7 +94,7 @@ export class API {
angle?: number; angle?: number;
id?: string; id?: string;
isDeleted?: boolean; isDeleted?: boolean;
frameId?: ExcalidrawElement["id"]; frameId?: ExcalidrawElement["id"] | null;
groupIds?: string[]; groupIds?: string[];
// generic element props // generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"]; 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 <button
class="color-picker__button active" class="color-picker__button active"
data-testid="color-top-pick-#ffffff"
style="--swatch-color: #ffffff;" style="--swatch-color: #ffffff;"
title="#ffffff" title="#ffffff"
type="button" type="button"
@@ -545,6 +546,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button> </button>
<button <button
class="color-picker__button" class="color-picker__button"
data-testid="color-top-pick-#f8f9fa"
style="--swatch-color: #f8f9fa;" style="--swatch-color: #f8f9fa;"
title="#f8f9fa" title="#f8f9fa"
type="button" type="button"
@@ -555,6 +557,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button> </button>
<button <button
class="color-picker__button" class="color-picker__button"
data-testid="color-top-pick-#f5faff"
style="--swatch-color: #f5faff;" style="--swatch-color: #f5faff;"
title="#f5faff" title="#f5faff"
type="button" type="button"
@@ -565,6 +568,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button> </button>
<button <button
class="color-picker__button" class="color-picker__button"
data-testid="color-top-pick-#fffce8"
style="--swatch-color: #fffce8;" style="--swatch-color: #fffce8;"
title="#fffce8" title="#fffce8"
type="button" type="button"
@@ -575,6 +579,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
</button> </button>
<button <button
class="color-picker__button" class="color-picker__button"
data-testid="color-top-pick-#fdf8f6"
style="--swatch-color: #fdf8f6;" style="--swatch-color: #fdf8f6;"
title="#fdf8f6" title="#fdf8f6"
type="button" type="button"
-14
View File
@@ -1089,20 +1089,6 @@ describe("regression tests", () => {
}); });
assertSelectedElements(rect3); 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( it(
+300 -1
View File
@@ -12,6 +12,11 @@ import {
import { AppState } from "../types"; import { AppState } from "../types";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { selectGroupsForSelectedElements } from "../groups"; import { selectGroupsForSelectedElements } from "../groups";
import {
ExcalidrawElement,
ExcalidrawFrameElement,
ExcalidrawSelectionElement,
} from "../element/types";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -23,9 +28,15 @@ beforeEach(() => {
const { h } = window; const { h } = window;
type ExcalidrawElementType = Exclude<
ExcalidrawElement,
ExcalidrawSelectionElement
>["type"];
const populateElements = ( const populateElements = (
elements: { elements: {
id: string; id: string;
type?: ExcalidrawElementType;
isDeleted?: boolean; isDeleted?: boolean;
isSelected?: boolean; isSelected?: boolean;
groupIds?: string[]; groupIds?: string[];
@@ -34,6 +45,7 @@ const populateElements = (
width?: number; width?: number;
height?: number; height?: number;
containerId?: string; containerId?: string;
frameId?: ExcalidrawFrameElement["id"];
}[], }[],
appState?: Partial<AppState>, appState?: Partial<AppState>,
) => { ) => {
@@ -50,9 +62,11 @@ const populateElements = (
width = 100, width = 100,
height = 100, height = 100,
containerId = null, containerId = null,
frameId = null,
type,
}) => { }) => {
const element = API.createElement({ const element = API.createElement({
type: containerId ? "text" : "rectangle", type: type ?? (containerId ? "text" : "rectangle"),
id, id,
isDeleted, isDeleted,
x, x,
@@ -61,6 +75,7 @@ const populateElements = (
height, height,
groupIds, groupIds,
containerId, containerId,
frameId: frameId || null,
}); });
if (isSelected) { if (isSelected) {
selectedElementIds[element.id] = true; selectedElementIds[element.id] = true;
@@ -116,6 +131,8 @@ const assertZindex = ({
isSelected?: true; isSelected?: true;
groupIds?: string[]; groupIds?: string[];
containerId?: string; containerId?: string;
frameId?: ExcalidrawFrameElement["id"];
type?: ExcalidrawElementType;
}[]; }[];
appState?: Partial<AppState>; appState?: Partial<AppState>;
operations: [Actions, string[]][]; 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; altKey: boolean;
metaKey: 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 { bumpVersion } from "./element/mutateElement";
import { isFrameElement } from "./element/typeChecks"; import { isFrameElement } from "./element/typeChecks";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement, ExcalidrawFrameElement } from "./element/types";
import { groupByFrames } from "./frame";
import { getElementsInGroup } from "./groups"; import { getElementsInGroup } from "./groups";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import Scene from "./scene/Scene"; import Scene from "./scene/Scene";
import { AppState } from "./types"; import { AppState } from "./types";
import { arrayToMap, findIndex, findLastIndex } from "./utils"; import { arrayToMap, findIndex, findLastIndex } from "./utils";
// elements that do not belong to a frame are considered a root element const isOfTargetFrame = (element: ExcalidrawElement, frameId: string) => {
const isRootElement = (element: ExcalidrawElement) => { return element.frameId === frameId || element.id === frameId;
return !element.frameId;
}; };
/** /**
@@ -35,6 +33,7 @@ const getIndicesToMove = (
? elementsToBeMoved ? elementsToBeMoved
: getSelectedElements(elements, appState, { : getSelectedElements(elements, appState, {
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true,
}), }),
); );
while (++index < elements.length) { 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 * 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). * is a non-deleted element, and not inside a group (unless we're editing it).
@@ -115,6 +134,11 @@ const getTargetIndex = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
boundaryIndex: number, boundaryIndex: number,
direction: "left" | "right", 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]; const sourceElement = elements[boundaryIndex];
@@ -122,6 +146,9 @@ const getTargetIndex = (
if (element.isDeleted) { if (element.isDeleted) {
return false; return false;
} }
if (containingFrame) {
return element.frameId === containingFrame;
}
// if we're editing group, find closest sibling irrespective of whether // if we're editing group, find closest sibling irrespective of whether
// there's a different-group element between them (for legacy reasons) // there's a different-group element between them (for legacy reasons)
if (appState.editingGroupId) { if (appState.editingGroupId) {
@@ -132,8 +159,12 @@ const getTargetIndex = (
const candidateIndex = const candidateIndex =
direction === "left" direction === "left"
? findLastIndex(elements, indexFilter, Math.max(0, boundaryIndex - 1)) ? findLastIndex(
: findIndex(elements, indexFilter, boundaryIndex + 1); elements,
(el) => indexFilter(el),
Math.max(0, boundaryIndex - 1),
)
: findIndex(elements, (el) => indexFilter(el), boundaryIndex + 1);
const nextElement = elements[candidateIndex]; 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) { if (!nextElement.groupIds.length) {
return ( return (
getTargetIndexAccountingForBinding(nextElement, elements, direction) ?? getTargetIndexAccountingForBinding(nextElement, elements, direction) ??
@@ -195,13 +239,12 @@ const getTargetElementsMap = <T extends ExcalidrawElement>(
}, {} as Record<string, ExcalidrawElement>); }, {} as Record<string, ExcalidrawElement>);
}; };
const _shiftElements = ( const shiftElementsByOne = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved); const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
let groupedIndices = toContiguousGroups(indicesToMove); let groupedIndices = toContiguousGroups(indicesToMove);
@@ -209,16 +252,30 @@ const _shiftElements = (
groupedIndices = groupedIndices.reverse(); 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) => { groupedIndices.forEach((indices, i) => {
const leadingIndex = indices[0]; const leadingIndex = indices[0];
const trailingIndex = indices[indices.length - 1]; const trailingIndex = indices[indices.length - 1];
const boundaryIndex = direction === "left" ? leadingIndex : trailingIndex; 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( const targetIndex = getTargetIndex(
appState, appState,
elements, elements,
boundaryIndex, boundaryIndex,
direction, direction,
containingFrame,
); );
if (targetIndex === -1 || boundaryIndex === targetIndex) { if (targetIndex === -1 || boundaryIndex === targetIndex) {
@@ -263,34 +320,25 @@ const _shiftElements = (
}); });
}; };
const shiftElements = ( const shiftElementsToEnd = (
appState: AppState,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right", direction: "left" | "right",
containingFrame: ExcalidrawFrameElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[], elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shift( const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
elements,
appState,
direction,
_shiftElements,
elementsToBeMoved,
);
};
const _shiftElementsToEnd = (
elements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
) => {
const indicesToMove = getIndicesToMove(elements, appState);
const targetElementsMap = getTargetElementsMap(elements, indicesToMove); const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
const displacedElements: ExcalidrawElement[] = []; const displacedElements: ExcalidrawElement[] = [];
let leadingIndex: number; let leadingIndex: number;
let trailingIndex: number; let trailingIndex: number;
if (direction === "left") { if (direction === "left") {
if (appState.editingGroupId) { if (containingFrame) {
leadingIndex = findIndex(elements, (el) =>
isOfTargetFrame(el, containingFrame),
);
} else if (appState.editingGroupId) {
const groupElements = getElementsInGroup( const groupElements = getElementsInGroup(
elements, elements,
appState.editingGroupId, appState.editingGroupId,
@@ -305,7 +353,11 @@ const _shiftElementsToEnd = (
trailingIndex = indicesToMove[indicesToMove.length - 1]; trailingIndex = indicesToMove[indicesToMove.length - 1];
} else { } else {
if (appState.editingGroupId) { if (containingFrame) {
trailingIndex = findLastIndex(elements, (el) =>
isOfTargetFrame(el, containingFrame),
);
} else if (appState.editingGroupId) {
const groupElements = getElementsInGroup( const groupElements = getElementsInGroup(
elements, elements,
appState.editingGroupId, appState.editingGroupId,
@@ -321,6 +373,10 @@ const _shiftElementsToEnd = (
leadingIndex = indicesToMove[0]; leadingIndex = indicesToMove[0];
} }
if (leadingIndex === -1) {
leadingIndex = 0;
}
for (let index = leadingIndex; index < trailingIndex + 1; index++) { for (let index = leadingIndex; index < trailingIndex + 1; index++) {
if (!indicesToMove.includes(index)) { if (!indicesToMove.includes(index)) {
displacedElements.push(elements[index]); displacedElements.push(elements[index]);
@@ -349,121 +405,123 @@ const _shiftElementsToEnd = (
]; ];
}; };
const shiftElementsToEnd = ( function shiftElementsAccountingForFrames(
elements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
appState: AppState,
direction: "left" | "right",
elementsToBeMoved?: readonly ExcalidrawElement[],
) => {
return shift(
elements,
appState,
direction,
_shiftElementsToEnd,
elementsToBeMoved,
);
};
function shift(
elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
shiftFunction: ( shiftFunction: (
elements: ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
containingFrame: ExcalidrawFrameElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[], elementsToBeMoved?: readonly ExcalidrawElement[],
) => ExcalidrawElement[] | readonly ExcalidrawElement[], ) => ExcalidrawElement[] | readonly ExcalidrawElement[],
elementsToBeMoved?: readonly ExcalidrawElement[],
) { ) {
const elementsMap = arrayToMap(elements); const elementsToMove = arrayToMap(
const frameElementsMap = groupByFrames(elements); getSelectedElements(allElements, appState, {
includeBoundTextElement: true,
// in case root is non-existent, we promote children elements to root includeElementsInFrames: true,
let rootElements = elements.filter( }),
(element) =>
isRootElement(element) ||
(element.frameId && !elementsMap.has(element.frameId)),
); );
// and remove non-existet root
for (const frameId of frameElementsMap.keys()) { const frameAwareContiguousElementsToMove: {
if (!elementsMap.has(frameId)) { regularElements: ExcalidrawElement[];
frameElementsMap.delete(frameId); 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 for (const element of allElements) {
rootElements = shiftFunction( if (elementsToMove.has(element.id)) {
rootElements, 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, appState,
direction, direction,
elementsToBeMoved, null,
) as ExcalidrawElement[]; frameAwareContiguousElementsToMove.regularElements,
);
// 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;
} }
// public API // public API
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const moveOneLeft = ( export const moveOneLeft = (
elements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElements(appState, elements, "left", elementsToBeMoved); return shiftElementsByOne(allElements, appState, "left");
}; };
export const moveOneRight = ( export const moveOneRight = (
elements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElements(appState, elements, "right", elementsToBeMoved); return shiftElementsByOne(allElements, appState, "right");
}; };
export const moveAllLeft = ( export const moveAllLeft = (
elements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElementsToEnd(elements, appState, "left", elementsToBeMoved); return shiftElementsAccountingForFrames(
allElements,
appState,
"left",
shiftElementsToEnd,
);
}; };
export const moveAllRight = ( export const moveAllRight = (
elements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
return shiftElementsToEnd(elements, appState, "right", elementsToBeMoved); return shiftElementsAccountingForFrames(
allElements,
appState,
"right",
shiftElementsToEnd,
);
}; };