Compare commits

...

16 Commits

Author SHA1 Message Date
zsviczian 0aa2608799 Update blob.ts 2025-02-04 21:12:18 +01:00
zsviczian 2e876e98d7 Update App.tsx 2025-02-04 21:11:29 +01:00
zsviczian 413f852cf6 lint 2025-02-04 20:14:31 +01:00
zsviczian 021f6d37d4 Fixes SAMSUNG update issue where blob.type === "" when adding image from gallery 2025-02-04 20:07:47 +01:00
David Luzar 424e94a403 fix: duplicating/removing frame while children selected (#9079) 2025-02-04 19:23:47 +01:00
Márk Tolmács 302664e500 fix: Elbow arrow z-index binding (#9067) 2025-02-01 19:21:03 +01:00
David Luzar 86c67bd37f fix: library item checkbox style regression (#9080) 2025-02-01 12:27:41 +01:00
David Luzar 511433988c feat: tweak slider colors to be more muted (#9076) 2025-01-31 16:52:50 +01:00
Márk Tolmács 9b6edc767a fix: Elbow arrow orthogonality (#9073) 2025-01-31 14:19:07 +01:00
David Luzar 6cdb683410 fix: button bg CSS variable leaking into other styles (#9075) 2025-01-31 12:33:54 +01:00
Shalini 84bab403ff Fix: issue #8818 Xiaolai font has been set as a fallback for Excalifont (#9055)
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2025-01-30 13:41:41 +00:00
Are 61e0bb83d0 feat: improve library sidebar performance (#9060)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-30 14:41:08 +01:00
Saikat Das bd1590fc74 feat: implement custom Range component for opacity control (#9009)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2025-01-29 21:46:40 +00:00
Marcel Mraz d29c3db7f6 fix: fonts not loading on export (again) (#9064) 2025-01-29 22:24:26 +01:00
Marcel Mraz a58822c1c1 fix: merge server-side fonts with liberation sans (#9052) 2025-01-29 22:04:49 +01:00
David Luzar a3e1619635 fix: hyperlinks html entities (#9063) 2025-01-29 19:02:54 +01:00
47 changed files with 3631 additions and 2227 deletions
@@ -0,0 +1,211 @@
import React from "react";
import { Excalidraw, mutateElement } from "../index";
import { act, assertElements, render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDeleteSelected } from "./actionDeleteSelected";
const { h } = window;
describe("deleting selected elements when frame selected should keep children + select them", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("frame only", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
it("frame + text container (text's frameId set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: f1.id,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text's frameId not set)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + text container (text selected too)", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: r1.id,
frameId: null,
});
mutateElement(r1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, r1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + labeled arrow", async () => {
const f1 = API.createElement({
type: "frame",
});
const a1 = API.createElement({
type: "arrow",
frameId: f1.id,
});
const t1 = API.createElement({
type: "text",
width: 200,
height: 100,
fontSize: 20,
containerId: a1.id,
frameId: null,
});
mutateElement(a1, {
boundElements: [{ type: "text", id: t1.id }],
});
API.setElements([f1, a1, t1]);
API.setSelectedElements([f1, t1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: a1.id, isDeleted: false, selected: true },
{ id: t1.id, isDeleted: false },
]);
});
it("frame + children selected", async () => {
const f1 = API.createElement({
type: "frame",
});
const r1 = API.createElement({
type: "rectangle",
frameId: f1.id,
});
API.setElements([f1, r1]);
API.setSelectedElements([f1, r1]);
act(() => {
h.app.actionManager.executeAction(actionDeleteSelected);
});
assertElements(h.elements, [
{ id: f1.id, isDeleted: true },
{ id: r1.id, isDeleted: false, selected: true },
]);
});
});
@@ -18,6 +18,8 @@ import {
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getContainerElement } from "../element/textElement";
import { getFrameChildren } from "../frame";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@@ -33,10 +35,50 @@ const deleteSelectedElements = (
const selectedElementIds: Record<ExcalidrawElement["id"], true> = {};
const elementsMap = app.scene.getNonDeletedElementsMap();
const processedElements = new Set<ExcalidrawElement["id"]>();
for (const frameId of framesToBeDeleted) {
const frameChildren = getFrameChildren(elements, frameId);
for (const el of frameChildren) {
if (processedElements.has(el.id)) {
continue;
}
if (isBoundToContainer(el)) {
const containerElement = getContainerElement(el, elementsMap);
if (containerElement) {
selectedElementIds[containerElement.id] = true;
}
} else {
selectedElementIds[el.id] = true;
}
processedElements.add(el.id);
}
}
let shouldSelectEditingGroup = true;
const nextElements = elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
const boundElement = isBoundToContainer(el)
? getContainerElement(el, elementsMap)
: null;
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
return el;
}
if (
boundElement?.frameId &&
framesToBeDeleted.has(boundElement?.frameId)
) {
return el;
}
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene.getNonDeletedElementsMap().get(candidate.id);
@@ -59,7 +101,9 @@ const deleteSelectedElements = (
// if deleting a frame, remove the children from it and select them
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
shouldSelectEditingGroup = false;
selectedElementIds[el.id] = true;
if (!isBoundToContainer(el)) {
selectedElementIds[el.id] = true;
}
return newElementWith(el, { frameId: null });
}
@@ -224,11 +268,13 @@ export const actionDeleteSelected = register({
storeAction: StoreAction.CAPTURE,
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),
nextElements.filter((el) => el.isDeleted),
);
nextAppState = handleGroupEditingState(nextAppState, nextElements);
@@ -0,0 +1,530 @@
import { Excalidraw } from "../index";
import {
act,
assertElements,
getCloneByOrigId,
render,
} from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { actionDuplicateSelection } from "./actionDuplicateSelection";
import React from "react";
import { ORIG_ID } from "../constants";
const { h } = window;
describe("actionDuplicateSelection", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
describe("duplicating frames", () => {
it("frame selected only", async () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame selected only (with text container)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: frame.id, selected: true },
]);
});
it("frame + text container selected (order A)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([frame, rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{
[ORIG_ID]: frame.id,
selected: true,
},
]);
});
it("frame + text container selected (order B)", async () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([text, rectangle, frame]);
API.setSelectedElements([rectangle, frame]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ id: frame.id },
{
type: "rectangle",
[ORIG_ID]: `${rectangle.id}`,
},
{
[ORIG_ID]: `${text.id}`,
type: "text",
containerId: getCloneByOrigId(rectangle.id)?.id,
frameId: getCloneByOrigId(frame.id)?.id,
},
{ [ORIG_ID]: `${frame.id}`, type: "frame", selected: true },
]);
});
});
describe("duplicating frame children", () => {
it("frame child selected", () => {
const frame = API.createElement({
type: "frame",
});
const rectangle = API.createElement({
type: "rectangle",
frameId: frame.id,
});
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
it("frame text container selected (rectangle selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame bound text selected (container not selected)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
frameId: frame.id,
},
]);
});
it("frame text container selected (text not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, rectangle]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
]);
});
// shouldn't happen
it("frame bound text selected (container not exists)", () => {
const frame = API.createElement({
type: "frame",
});
const [, text] = API.createTextContainer({ frameId: frame.id });
API.setElements([frame, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: text.id, frameId: frame.id },
{ [ORIG_ID]: text.id, frameId: frame.id },
]);
});
it("frame bound container selected (text has no frameId)", () => {
const frame = API.createElement({
type: "frame",
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
label: { frameId: null },
});
API.setElements([frame, rectangle, text]);
API.setSelectedElements([rectangle]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: frame.id },
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, containerId: rectangle.id },
{ [ORIG_ID]: rectangle.id, frameId: frame.id, selected: true },
{
[ORIG_ID]: text.id,
containerId: getCloneByOrigId(rectangle.id).id,
},
]);
});
});
describe("duplicating multiple frames", () => {
it("multiple frames selected (no children)", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
it("multiple frames selected (no children) + unrelated element", () => {
const frame1 = API.createElement({
type: "frame",
});
const rect1 = API.createElement({
type: "rectangle",
frameId: frame1.id,
});
const frame2 = API.createElement({
type: "frame",
});
const rect2 = API.createElement({
type: "rectangle",
frameId: frame2.id,
});
const ellipse = API.createElement({
type: "ellipse",
});
API.setElements([rect1, frame1, ellipse, rect2, frame2]);
API.setSelectedElements([frame1, ellipse, frame2]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rect1.id, frameId: frame1.id },
{ id: frame1.id },
{ [ORIG_ID]: rect1.id, frameId: getCloneByOrigId(frame1.id)?.id },
{ [ORIG_ID]: frame1.id, selected: true },
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, selected: true },
{ id: rect2.id, frameId: frame2.id },
{ id: frame2.id },
{ [ORIG_ID]: rect2.id, frameId: getCloneByOrigId(frame2.id)?.id },
{ [ORIG_ID]: frame2.id, selected: true },
]);
});
});
describe("duplicating containers/bound elements", () => {
it("labeled arrow (arrow selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([arrow]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
// shouldn't happen
it("labeled arrow (text selected)", () => {
const [arrow, text] = API.createLabeledArrow();
API.setElements([arrow, text]);
API.setSelectedElements([text]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: arrow.id },
{ id: text.id, containerId: arrow.id },
{ [ORIG_ID]: arrow.id, selected: true },
{ [ORIG_ID]: text.id, containerId: getCloneByOrigId(arrow.id)?.id },
]);
});
});
describe("duplicating groups", () => {
it("duplicate group containing frame (children don't have groupIds set)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{ [ORIG_ID]: rectangle.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: text.id, frameId: getCloneByOrigId(frame.id)?.id },
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicate group containing frame (children have groupIds)", () => {
const frame = API.createElement({
type: "frame",
groupIds: ["A"],
});
const [rectangle, text] = API.createTextContainer({
frameId: frame.id,
groupIds: ["A"],
});
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["A"],
});
API.setElements([rectangle, text, frame, ellipse]);
API.setSelectedElements([frame, ellipse]);
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: rectangle.id, frameId: frame.id },
{ id: text.id, frameId: frame.id },
{ id: frame.id },
{ id: ellipse.id },
{
[ORIG_ID]: rectangle.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{
[ORIG_ID]: text.id,
frameId: getCloneByOrigId(frame.id)?.id,
// FIXME shouldn't be selected (in selectGroupsForSelectedElements)
selected: true,
},
{ [ORIG_ID]: frame.id, selected: true },
{ [ORIG_ID]: ellipse.id, selected: true },
]);
});
it("duplicating element nested in group", () => {
const ellipse = API.createElement({
type: "ellipse",
groupIds: ["B"],
});
const rect1 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
const rect2 = API.createElement({
type: "rectangle",
groupIds: ["A", "B"],
});
API.setElements([ellipse, rect1, rect2]);
API.setSelectedElements([ellipse], "B");
act(() => {
h.app.actionManager.executeAction(actionDuplicateSelection);
});
assertElements(h.elements, [
{ id: ellipse.id },
{ [ORIG_ID]: ellipse.id, groupIds: ["B"], selected: true },
{ id: rect1.id, groupIds: ["A", "B"] },
{ id: rect2.id, groupIds: ["A", "B"] },
]);
});
});
});
@@ -5,7 +5,13 @@ import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { arrayToMap, getShortcutKey } from "../utils";
import {
arrayToMap,
castArray,
findLastIndex,
getShortcutKey,
invariant,
} from "../utils";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
selectGroupsForSelectedElements,
@@ -19,8 +25,13 @@ import { DEFAULT_GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
getBoundTextElement,
getContainerElement,
} from "../element/textElement";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import {
hasBoundTextElement,
isBoundToContainer,
isFrameLikeElement,
} from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons";
import {
@@ -31,7 +42,6 @@ import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionDuplicateSelection = register({
@@ -85,34 +95,66 @@ const duplicateElements = (
): Partial<ActionResult> => {
// ---------------------------------------------------------------------------
// step (1)
const sortedElements = normalizeElementOrder(elements);
const groupIdMap = new Map();
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
const elementsMap = arrayToMap(elements);
const duplicateAndOffsetElement = <
T extends ExcalidrawElement | ExcalidrawElement[],
>(
element: T,
): T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null => {
const elements = castArray(element);
const _newElements = elements.reduce(
(acc: ExcalidrawElement[], element) => {
if (processedIds.has(element.id)) {
return acc;
}
processedIds.set(element.id, true);
const newElement = duplicateElement(
appState.editingGroupId,
groupIdMap,
element,
{
x: element.x + DEFAULT_GRID_SIZE / 2,
y: element.y + DEFAULT_GRID_SIZE / 2,
},
);
processedIds.set(newElement.id, true);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
acc.push(newElement);
return acc;
},
[],
);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
return newElement;
return (
Array.isArray(element) ? _newElements : _newElements[0] || null
) as T extends ExcalidrawElement[]
? ExcalidrawElement[]
: ExcalidrawElement | null;
};
elements = normalizeElementOrder(elements);
const idsOfElementsToDuplicate = arrayToMap(
getSelectedElements(sortedElements, appState, {
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
@@ -130,122 +172,133 @@ const duplicateElements = (
// loop over them.
const processedIds = new Map<ExcalidrawElement["id"], true>();
const markAsProcessed = (elements: ExcalidrawElement[]) => {
for (const element of elements) {
processedIds.set(element.id, true);
const elementsWithClones: ExcalidrawElement[] = elements.slice();
const insertAfterIndex = (
index: number,
elements: ExcalidrawElement | null | ExcalidrawElement[],
) => {
invariant(index !== -1, "targetIndex === -1 ");
if (!Array.isArray(elements) && !elements) {
return;
}
return elements;
elementsWithClones.splice(index + 1, 0, ...castArray(elements));
};
const elementsWithClones: ExcalidrawElement[] = [];
const frameIdsToDuplicate = new Set(
elements
.filter(
(el) => idsOfElementsToDuplicate.has(el.id) && isFrameLikeElement(el),
)
.map((el) => el.id),
);
let index = -1;
while (++index < sortedElements.length) {
const element = sortedElements[index];
if (processedIds.get(element.id)) {
for (const element of elements) {
if (processedIds.has(element.id)) {
continue;
}
const boundTextElement = getBoundTextElement(element, arrayToMap(elements));
const isElementAFrameLike = isFrameLikeElement(element);
if (!idsOfElementsToDuplicate.has(element.id)) {
continue;
}
if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
// TODO:
// remove `.flatMap...`
// if the elements in a frame are grouped when the frame is grouped
const groupElements = getElementsInGroup(
sortedElements,
groupId,
).flatMap((element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
// groups
// -------------------------------------------------------------------------
elementsWithClones.push(
...markAsProcessed([
...groupElements,
...groupElements.map((element) =>
duplicateAndOffsetElement(element),
),
]),
);
continue;
}
if (boundTextElement) {
elementsWithClones.push(
...markAsProcessed([
element,
boundTextElement,
duplicateAndOffsetElement(element),
duplicateAndOffsetElement(boundTextElement),
]),
);
continue;
}
if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id);
const groupId = getSelectedGroupForElement(appState, element);
if (groupId) {
const groupElements = getElementsInGroup(elements, groupId).flatMap(
(element) =>
isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element]
: [element],
);
elementsWithClones.push(
...markAsProcessed([
...elementsInFrame,
element,
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
duplicateAndOffsetElement(element),
]),
);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.groupIds?.includes(groupId);
});
continue;
}
}
// since elements in frames have a lower z-index than the frame itself,
// they will be looped first and if their frames are selected as well,
// they will have been copied along with the frame atomically in the
// above branch, so we must skip those elements here
//
// now, for elements do not belong any frames or elements whose frames
// are selected (or elements that are left out from the above
// steps for whatever reason) we (should at least) duplicate them here
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
elementsWithClones.push(
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
insertAfterIndex(targetIndex, duplicateAndOffsetElement(groupElements));
continue;
}
// frame duplication
// -------------------------------------------------------------------------
if (element.frameId && frameIdsToDuplicate.has(element.frameId)) {
continue;
}
if (isFrameLikeElement(element)) {
const frameId = element.id;
const frameChildren = getFrameChildren(elements, frameId);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.frameId === frameId || el.id === frameId;
});
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([...frameChildren, element]),
);
continue;
}
// text container
// -------------------------------------------------------------------------
if (hasBoundTextElement(element)) {
const boundTextElement = getBoundTextElement(element, elementsMap);
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return (
el.id === element.id ||
("containerId" in el && el.containerId === element.id)
);
});
if (boundTextElement) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([element, boundTextElement]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
} else {
elementsWithClones.push(...markAsProcessed([element]));
continue;
}
}
// step (2)
if (isBoundToContainer(element)) {
const container = getContainerElement(element, elementsMap);
// second pass to remove duplicates. We loop from the end as it's likelier
// that the last elements are in the correct order (contiguous or otherwise).
// Thus we need to reverse as the last step (3).
const targetIndex = findLastIndex(elementsWithClones, (el) => {
return el.id === element.id || el.id === container?.id;
});
const finalElementsReversed: ExcalidrawElement[] = [];
if (container) {
insertAfterIndex(
targetIndex,
duplicateAndOffsetElement([container, element]),
);
} else {
insertAfterIndex(targetIndex, duplicateAndOffsetElement(element));
}
const finalElementIds = new Map<ExcalidrawElement["id"], true>();
index = elementsWithClones.length;
while (--index >= 0) {
const element = elementsWithClones[index];
if (!finalElementIds.get(element.id)) {
finalElementIds.set(element.id, true);
finalElementsReversed.push(element);
continue;
}
}
// step (3)
const finalElements = syncMovedIndices(
finalElementsReversed.reverse(),
arrayToMap(newElements),
);
// default duplication (regular elements)
// -------------------------------------------------------------------------
insertAfterIndex(
findLastIndex(elementsWithClones, (el) => el.id === element.id),
duplicateAndOffsetElement(element),
);
}
// ---------------------------------------------------------------------------
@@ -260,7 +313,7 @@ const duplicateElements = (
oldIdToDuplicatedId,
);
bindElementsToFramesAfterDuplication(
finalElements,
elementsWithClones,
oldElements,
oldIdToDuplicatedId,
);
@@ -269,7 +322,7 @@ const duplicateElements = (
excludeElementsInFramesFromSelection(newElements);
return {
elements: finalElements,
elements: elementsWithClones,
appState: {
...appState,
...selectGroupsForSelectedElements(
@@ -285,7 +338,7 @@ const duplicateElements = (
{},
),
},
getNonDeletedElements(finalElements),
getNonDeletedElements(elementsWithClones),
appState,
null,
),
@@ -121,6 +121,7 @@ import {
import { LinearElementEditor } from "../element/linearElementEditor";
import type { LocalPoint } from "../../math";
import { pointFrom } from "../../math";
import { Range } from "../components/Range";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -630,25 +631,12 @@ export const actionChangeOpacity = register({
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<label className="control-label">
{t("labels.opacity")}
<input
type="range"
min="0"
max="100"
step="10"
onChange={(event) => updateData(+event.target.value)}
value={
getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
) ?? undefined
}
/>
</label>
<Range
updateData={updateData}
elements={elements}
appState={appState}
testId="opacity"
/>
),
});
@@ -1617,6 +1605,8 @@ export const actionChangeArrowType = register({
elements,
elementsMap,
appState.zoom,
false,
true,
);
const endHoveredElement =
!newElement.endBinding &&
@@ -1625,6 +1615,8 @@ export const actionChangeArrowType = register({
elements,
elementsMap,
appState.zoom,
false,
true,
);
const startElement = startHoveredElement
? startHoveredElement
+28 -8
View File
@@ -5770,7 +5770,10 @@ class App extends React.Component<AppProps, AppState> {
});
}
if (editingLinearElement?.lastUncommittedPoint != null) {
this.maybeSuggestBindingAtCursor(scenePointer);
this.maybeSuggestBindingAtCursor(
scenePointer,
editingLinearElement.elbowed,
);
} else {
// causes stack overflow if not sync
flushSync(() => {
@@ -5790,7 +5793,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.startBoundElement,
);
} else {
this.maybeSuggestBindingAtCursor(scenePointer);
this.maybeSuggestBindingAtCursor(scenePointer, false);
}
}
@@ -7728,6 +7731,7 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isElbowArrow(element),
isElbowArrow(element),
);
this.scene.insertElement(element);
@@ -9813,13 +9817,23 @@ class App extends React.Component<AppProps, AppState> {
this.state,
);
const imageFile = await fileOpen({
let imageFile = await fileOpen({
description: "Image",
extensions: Object.keys(
IMAGE_MIME_TYPES,
) as (keyof typeof IMAGE_MIME_TYPES)[],
});
//maybe temporary fix: https://github.com/excalidraw/excalidraw/issues/9091
if (imageFile && !imageFile.type) {
const extension = imageFile.name.split(".").pop()?.toLowerCase();
const mimeType =
IMAGE_MIME_TYPES[extension as keyof typeof IMAGE_MIME_TYPES];
if (mimeType) {
imageFile = new File([imageFile], imageFile.name, { type: mimeType });
}
}
const imageElement = this.createImageElement({
sceneX: x,
sceneY: y,
@@ -10005,15 +10019,20 @@ class App extends React.Component<AppProps, AppState> {
}
};
private maybeSuggestBindingAtCursor = (pointerCoords: {
x: number;
y: number;
}): void => {
private maybeSuggestBindingAtCursor = (
pointerCoords: {
x: number;
y: number;
},
considerAll: boolean,
): void => {
const hoveredBindableElement = getHoveredElementForBinding(
pointerCoords,
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
false,
considerAll,
);
this.setState({
suggestedBindings:
@@ -10043,7 +10062,8 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getNonDeletedElements(),
this.scene.getNonDeletedElementsMap(),
this.state.zoom,
isArrowElement(linearElement) && isElbowArrow(linearElement),
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (
hoveredBindableElement != null &&
@@ -5,7 +5,7 @@ import { getLanguage, t } from "../i18n";
import clsx from "clsx";
import Collapsible from "./Stats/Collapsible";
import { atom, useAtom } from "../editor-jotai";
import { useDevice } from "..";
import { useDevice } from "./App";
import "./IconPicker.scss";
+44 -20
View File
@@ -1,4 +1,10 @@
import React, { useState, useCallback, useMemo, useRef } from "react";
import React, {
useState,
useCallback,
useMemo,
useRef,
useEffect,
} from "react";
import type Library from "../data/library";
import {
distributeLibraryItemsOnSquareGrid,
@@ -150,27 +156,40 @@ const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) => {
const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create());
const create = useCallback(
(appState: UIAppState, elements: readonly NonDeletedExcalidrawElement[]) =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
}),
[],
);
const val = useRef(create(appState, elements));
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create();
prevAppState.current = appState;
prevElements.current = elements;
}
return val.current;
const update = useCallback(() => {
if (
!isShallowEqual(
appState.selectedElementIds,
prevAppState.current.selectedElementIds,
) ||
!isShallowEqual(elements, prevElements.current)
) {
val.current = create(appState, elements);
prevAppState.current = appState;
prevElements.current = elements;
}
}, [create, appState, elements]);
return useMemo(
() => ({
update,
value: val.current,
}),
[update, val],
);
};
/**
@@ -181,6 +200,7 @@ export const LibraryMenu = () => {
const { library, id, onInsertElements } = useApp();
const appProps = useAppProps();
const appState = useUIAppState();
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements();
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
@@ -203,9 +223,13 @@ export const LibraryMenu = () => {
});
}, [setAppState]);
useEffect(() => {
return app.onPointerUpEmitter.on(() => pendingElements.update());
}, [app, pendingElements]);
return (
<LibraryMenuContent
pendingElements={pendingElements}
pendingElements={pendingElements.value}
onInsertLibraryItems={onInsertLibraryItems}
onAddToLibrary={deselectItems}
setAppState={setAppState}
+56
View File
@@ -0,0 +1,56 @@
@import "../css/variables.module.scss";
.excalidraw {
--slider-thumb-size: 16px;
.range-wrapper {
position: relative;
padding-top: 10px;
padding-bottom: 30px;
}
.range-input {
width: 100%;
height: 4px;
-webkit-appearance: none;
background: var(--color-slider-track);
border-radius: 2px;
outline: none;
}
.range-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
.range-input::-moz-range-thumb {
width: var(--slider-thumb-size);
height: var(--slider-thumb-size);
background: var(--color-slider-thumb);
border-radius: 50%;
cursor: pointer;
border: none;
}
.value-bubble {
position: absolute;
bottom: 0;
transform: translateX(-50%);
font-size: 12px;
color: var(--text-primary-color);
}
.zero-label {
position: absolute;
bottom: 0;
left: 4px;
font-size: 12px;
color: var(--text-primary-color);
}
}
+65
View File
@@ -0,0 +1,65 @@
import React, { useEffect } from "react";
import { getFormValue } from "../actions/actionProperties";
import { t } from "../i18n";
import "./Range.scss";
export type RangeProps = {
updateData: (value: number) => void;
appState: any;
elements: any;
testId?: string;
};
export const Range = ({
updateData,
appState,
elements,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null);
const value = getFormValue(
elements,
appState,
(element) => element.opacity,
true,
appState.currentItemOpacity,
);
useEffect(() => {
if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current;
const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb
const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`;
}
}, [value]);
return (
<label className="control-label">
{t("labels.opacity")}
<div className="range-wrapper">
<input
ref={rangeRef}
type="range"
min="0"
max="100"
step="10"
onChange={(event) => {
updateData(+event.target.value);
}}
value={value}
className="range-input"
data-testid={testId}
/>
<div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null}
</div>
<div className="zero-label">0</div>
</div>
</label>
);
};
@@ -1,6 +1,6 @@
.excalidraw {
.excalifont {
font-family: "Excalifont";
font-family: "Excalifont", "Xiaolai";
}
// WelcomeSreen common
+3
View File
@@ -458,3 +458,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";
/** used in tests */
export const ORIG_ID = Symbol.for("__test__originalId__");
+7 -1
View File
@@ -649,15 +649,21 @@ body.excalidraw-cursor-resize * {
@include filledButtonOnCanvas;
}
.App-mobile-menu,
.App-menu__left {
--button-border: transparent;
--button-bg: var(--color-surface-mid);
}
@at-root .excalidraw.theme--dark#{&} {
@at-root .excalidraw.theme--dark#{&} {
.App-mobile-menu,
.App-menu__left {
--button-hover-bg: #363541;
--button-bg: var(--color-surface-high);
}
}
.App-menu__left {
.buttonList {
padding: 0.25rem 0;
}
+5
View File
@@ -53,6 +53,9 @@
--scrollbar-thumb: var(--button-gray-2);
--scrollbar-thumb-hover: var(--button-gray-3);
--color-slider-track: hsl(240, 100%, 90%);
--color-slider-thumb: var(--color-gray-80);
--modal-shadow: 0px 100px 80px rgba(0, 0, 0, 0.07),
0px 41.7776px 33.4221px rgba(0, 0, 0, 0.0503198),
0px 22.3363px 17.869px rgba(0, 0, 0, 0.0417275),
@@ -207,6 +210,8 @@
--scrollbar-thumb: #{$oc-gray-8};
--scrollbar-thumb-hover: #{$oc-gray-7};
--color-slider-track: hsl(244, 23%, 39%);
// will be inverted to a lighter color.
--color-selection: #3530c4;
+2 -2
View File
@@ -25,7 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
expect(normalizeLink("[[test]]")).toBe("[[test]]");
expect(normalizeLink("<test>")).toBe("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&amp;");
expect(normalizeLink("<test>")).toBe("<test>");
expect(normalizeLink("test&")).toBe("test&");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils";
import { escapeDoubleQuotes } from "../utils";
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(sanitizeHTMLAttribute(link));
return sanitizeUrl(escapeDoubleQuotes(link));
};
export const isLocalLink = (link: string | null) => {
+69 -3
View File
@@ -49,7 +49,11 @@ import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement";
import type Scene from "../scene/Scene";
import { LinearElementEditor } from "./linearElementEditor";
import { arrayToMap, tupleToCoors } from "../utils";
import {
arrayToMap,
isBindingFallthroughEnabled,
tupleToCoors,
} from "../utils";
import { KEYS } from "../keys";
import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
@@ -75,6 +79,7 @@ import {
clamp,
} from "../../math";
import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
import { getElementsAtPosition } from "../scene/comparisons";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@@ -425,7 +430,8 @@ export const maybeBindLinearElement = (
elements,
elementsMap,
appState.zoom,
isElbowArrow(linearElement) && isElbowArrow(linearElement),
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
if (hoveredElement !== null) {
@@ -558,7 +564,64 @@ export const getHoveredElementForBinding = (
elementsMap: NonDeletedSceneElementsMap,
zoom?: AppState["zoom"],
fullShape?: boolean,
considerAllElements?: boolean,
): NonDeleted<ExcalidrawBindableElement> | null => {
if (considerAllElements) {
let cullRest = false;
const candidateElements = getElementsAtPosition(
elements,
(element) =>
isBindableElement(element, false) &&
bindingBorderTest(
element,
pointerCoords,
elementsMap,
zoom,
(fullShape ||
!isBindingFallthroughEnabled(
element as ExcalidrawBindableElement,
)) &&
// disable fullshape snapping for frame elements so we
// can bind to frame children
!isFrameLikeElement(element),
),
).filter((element) => {
if (cullRest) {
return false;
}
if (!isBindingFallthroughEnabled(element as ExcalidrawBindableElement)) {
cullRest = true;
}
return true;
}) as NonDeleted<ExcalidrawBindableElement>[] | null;
// Return early if there are no candidates or just one candidate
if (!candidateElements || candidateElements.length === 0) {
return null;
}
if (candidateElements.length === 1) {
return candidateElements[0] as NonDeleted<ExcalidrawBindableElement>;
}
// Prefer the shape with the border being tested (if any)
const borderTestElements = candidateElements.filter((element) =>
bindingBorderTest(element, pointerCoords, elementsMap, zoom, false),
);
if (borderTestElements.length === 1) {
return borderTestElements[0];
}
// Prefer smaller shapes
return candidateElements
.sort(
(a, b) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2),
)
.pop() as NonDeleted<ExcalidrawBindableElement>;
}
const hoveredElement = getElementAtPosition(
elements,
(element) =>
@@ -570,7 +633,8 @@ export const getHoveredElementForBinding = (
zoom,
// disable fullshape snapping for frame elements so we
// can bind to frame children
fullShape && !isFrameLikeElement(element),
(fullShape || !isBindingFallthroughEnabled(element)) &&
!isFrameLikeElement(element),
),
);
@@ -1220,6 +1284,8 @@ const getElligibleElementForBindingElement = (
elements,
elementsMap,
zoom,
isElbowArrow(linearElement),
isElbowArrow(linearElement),
);
};
+63 -4
View File
@@ -20,11 +20,11 @@ import {
bindPointToSnapToElementOutline,
distanceToBindableElement,
avoidRectangularCorner,
getHoveredElementForBinding,
FIXED_BINDING_DISTANCE,
getHeadingForElbowArrowSnap,
getGlobalFixedPointForBindableElement,
snapToMid,
getHoveredElementForBinding,
} from "./binding";
import type { Bounds } from "./bounds";
import type { Heading } from "./heading";
@@ -244,6 +244,12 @@ const handleSegmentRenormalization = (
);
}
import.meta.env.DEV &&
invariant(
validateElbowPoints(nextPoints),
"Invalid elbow points with fixed segments",
);
return normalizeArrowElementUpdate(
nextPoints,
filteredNextFixedSegments,
@@ -866,6 +872,8 @@ export const updateElbowArrowPoints = (
updates: {
points?: readonly LocalPoint[];
fixedSegments?: FixedSegment[] | null;
startBinding?: FixedPointBinding | null;
endBinding?: FixedPointBinding | null;
},
options?: {
isDragging?: boolean;
@@ -912,7 +920,11 @@ export const updateElbowArrowPoints = (
// 0. During all element replacement in the scene, we just need to renormalize
// the arrow
// TODO (dwelle,mtolmacs): Remove this once Scene.getScene() is removed
if (elementsMap.size === 0 && updates.points) {
if (
elementsMap.size === 0 &&
updates.points &&
validateElbowPoints(updates.points)
) {
return normalizeArrowElementUpdate(
updates.points.map((p) =>
pointFrom<GlobalPoint>(arrow.x + p[0], arrow.y + p[1]),
@@ -943,17 +955,48 @@ export const updateElbowArrowPoints = (
hoveredStartElement,
hoveredEndElement,
...rest
} = getElbowArrowData(arrow, elementsMap, updatedPoints, options);
} = getElbowArrowData(
{
x: arrow.x,
y: arrow.y,
startBinding:
typeof updates.startBinding !== "undefined"
? updates.startBinding
: arrow.startBinding,
endBinding:
typeof updates.endBinding !== "undefined"
? updates.endBinding
: arrow.endBinding,
startArrowhead: arrow.startArrowhead,
endArrowhead: arrow.endArrowhead,
},
elementsMap,
updatedPoints,
options,
);
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];
////
// 1. Renormalize the arrow
////
if (!updates.points && !updates.fixedSegments) {
if (
!updates.points &&
!updates.fixedSegments &&
!updates.startBinding &&
!updates.endBinding
) {
return handleSegmentRenormalization(arrow, elementsMap);
}
// Short circuit on no-op to avoid huge performance hit
if (
updates.startBinding === arrow.startBinding &&
updates.endBinding === arrow.endBinding
) {
return {};
}
////
// 2. Just normal elbow arrow things
////
@@ -1000,6 +1043,7 @@ export const updateElbowArrowPoints = (
////
// 5. Handle resize
////
if (updates.points && updates.fixedSegments) {
return updates;
}
@@ -2110,6 +2154,7 @@ const getHoveredElements = (
nonDeletedSceneElementsMap,
zoom,
true,
true,
),
getHoveredElementForBinding(
tupleToCoors(origEndGlobalPoint),
@@ -2117,9 +2162,23 @@ const getHoveredElements = (
nonDeletedSceneElementsMap,
zoom,
true,
true,
),
];
};
const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
a[0] === b[0] && a[1] === b[1];
const validateElbowPoints = <P extends GlobalPoint | LocalPoint>(
points: readonly P[],
tolerance: number = DEDUP_TRESHOLD,
) =>
points
.slice(1)
.map(
(p, i) =>
Math.abs(p[0] - points[i][0]) < tolerance ||
Math.abs(p[1] - points[i][1]) < tolerance,
)
.every(Boolean);
+4 -8
View File
@@ -1,11 +1,7 @@
import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types";
import {
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { escapeDoubleQuotes, getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping";
@@ -212,7 +208,7 @@ export const getEmbedLink = (
// Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed.
const safeURL = sanitizeHTMLAttribute(
const safeURL = escapeDoubleQuotes(
`https://twitter.com/x/status/${postId}`,
);
@@ -231,7 +227,7 @@ export const getEmbedLink = (
if (RE_REDDIT.test(link)) {
const [, page, postId, title] = link.match(RE_REDDIT)!;
const safeURL = sanitizeHTMLAttribute(
const safeURL = escapeDoubleQuotes(
`https://reddit.com/r/${page}/comments/${postId}/${title}`,
);
const ret: IframeDataWithSandbox = {
@@ -249,7 +245,7 @@ export const getEmbedLink = (
if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute(
const safeURL = escapeDoubleQuotes(
`https://gist.github.com/${user}/${gistId}`,
);
const ret: IframeDataWithSandbox = {
+28 -9
View File
@@ -10,13 +10,15 @@ import {
import { bindLinearElement } from "./binding";
import { LinearElementEditor } from "./linearElementEditor";
import { newArrowElement, newElement } from "./newElement";
import type {
ElementsMap,
ExcalidrawBindableElement,
ExcalidrawElement,
ExcalidrawFlowchartNodeElement,
NonDeletedSceneElementsMap,
OrderedExcalidrawElement,
import type { SceneElementsMap } from "./types";
import {
type ElementsMap,
type ExcalidrawBindableElement,
type ExcalidrawElement,
type ExcalidrawFlowchartNodeElement,
type NonDeletedSceneElementsMap,
type Ordered,
type OrderedExcalidrawElement,
} from "./types";
import { KEYS } from "../keys";
import type { AppState, PendingExcalidrawElements } from "../types";
@@ -28,9 +30,10 @@ import {
isFrameElement,
isFlowchartNodeElement,
} from "./typeChecks";
import { invariant } from "../utils";
import { invariant, toBrandedType } from "../utils";
import { pointFrom, type LocalPoint } from "../../math";
import { aabbForElement } from "../shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
type LinkDirection = "up" | "right" | "down" | "left";
@@ -467,7 +470,23 @@ const createBindingArrow = (
},
]);
return bindingArrow;
const update = updateElbowArrowPoints(
bindingArrow,
toBrandedType<SceneElementsMap>(
new Map([
...elementsMap.entries(),
[startBindingElement.id, startBindingElement],
[endBindingElement.id, endBindingElement],
[bindingArrow.id, bindingArrow],
] as [string, Ordered<ExcalidrawElement>][]),
),
{ points: bindingArrow.points },
);
return {
...bindingArrow,
...update,
};
};
export class FlowChartNavigator {
@@ -444,6 +444,8 @@ export class LinearElementEditor {
elements,
elementsMap,
appState.zoom,
isElbowArrow(element),
isElbowArrow(element),
)
: null;
@@ -796,6 +798,7 @@ export class LinearElementEditor {
elements,
elementsMap,
app.state.zoom,
linearElementEditor.elbowed,
),
};
+7 -2
View File
@@ -33,13 +33,16 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
// casting to any because can't use `in` operator
// (see https://github.com/microsoft/TypeScript/issues/21732)
const { points, fixedSegments, fileId } = updates as any;
const { points, fixedSegments, fileId, startBinding, endBinding } =
updates as any;
if (
isElbowArrow(element) &&
(Object.keys(updates).length === 0 || // normalization case
typeof points !== "undefined" || // repositioning
typeof fixedSegments !== "undefined") // segment fixing
typeof fixedSegments !== "undefined" || // segment fixing
typeof startBinding !== "undefined" ||
typeof endBinding !== "undefined") // manual binding to element
) {
const elementsMap = toBrandedType<SceneElementsMap>(
Scene.getScene(element)?.getNonDeletedElementsMap() ?? new Map(),
@@ -58,6 +61,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
{
fixedSegments,
points,
startBinding,
endBinding,
},
{
isDragging: options?.isDragging,
+22 -22
View File
@@ -45,6 +45,7 @@ import {
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
ORIG_ID,
VERTICAL_ALIGN,
} from "../constants";
import type { MarkOptional, Merge, Mutable } from "../utility-types";
@@ -592,26 +593,18 @@ export const deepCopyElement = <T extends ExcalidrawElement>(
return _deepCopyElement(val);
};
const __test__defineOrigId = (clonedObj: object, origId: string) => {
Object.defineProperty(clonedObj, ORIG_ID, {
value: origId,
writable: false,
enumerable: false,
});
};
/**
* utility wrapper to generate new id. In test env it reuses the old + postfix
* for test assertions.
* utility wrapper to generate new id.
*/
export const regenerateId = (
/** supply null if no previous id exists */
previousId: string | null,
) => {
if (isTestEnv() && previousId) {
let nextId = `${previousId}_copy`;
// `window.h` may not be defined in some unit tests
if (
window.h?.app
?.getSceneElementsIncludingDeleted()
.find((el: ExcalidrawElement) => el.id === nextId)
) {
nextId += "_copy";
}
return nextId;
}
const regenerateId = () => {
return randomId();
};
@@ -637,7 +630,11 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
): Readonly<TElement> => {
let copy = deepCopyElement(element);
copy.id = regenerateId(copy.id);
if (isTestEnv()) {
__test__defineOrigId(copy, element.id);
}
copy.id = regenerateId();
copy.boundElements = null;
copy.updated = getUpdatedTimestamp();
copy.seed = randomInteger();
@@ -646,7 +643,7 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
editingGroupId,
(groupId) => {
if (!groupIdMapForOperation.has(groupId)) {
groupIdMapForOperation.set(groupId, regenerateId(groupId));
groupIdMapForOperation.set(groupId, regenerateId());
}
return groupIdMapForOperation.get(groupId)!;
},
@@ -692,7 +689,7 @@ export const duplicateElements = (
// if we haven't migrated the element id, but an old element with the same
// id exists, generate a new id for it and return it
if (origElementsMap.has(id)) {
const newId = regenerateId(id);
const newId = regenerateId();
elementNewIdsMap.set(id, newId);
return newId;
}
@@ -706,6 +703,9 @@ export const duplicateElements = (
const clonedElement: Mutable<ExcalidrawElement> = _deepCopyElement(element);
clonedElement.id = maybeGetNewId(element.id)!;
if (isTestEnv()) {
__test__defineOrigId(clonedElement, element.id);
}
if (opts?.randomizeSeed) {
clonedElement.seed = randomInteger();
@@ -715,7 +715,7 @@ export const duplicateElements = (
if (clonedElement.groupIds) {
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
if (!groupNewIdsMap.has(groupId)) {
groupNewIdsMap.set(groupId, regenerateId(groupId));
groupNewIdsMap.set(groupId, regenerateId());
}
return groupNewIdsMap.get(groupId)!;
});
+1 -4
View File
@@ -116,8 +116,5 @@ const normalizeBoundElementsOrder = (
export const normalizeElementOrder = (
elements: readonly ExcalidrawElement[],
) => {
// console.time();
const ret = normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
// console.timeEnd();
return ret;
return normalizeBoundElementsOrder(normalizeGroupElementOrder(elements));
};
+9 -35
View File
@@ -3,7 +3,6 @@ import {
FONT_FAMILY_FALLBACKS,
CJK_HAND_DRAWN_FALLBACK_FONT,
WINDOWS_EMOJI_FALLBACK_FONT,
isSafari,
getFontFamilyFallbacks,
} from "../constants";
import { isTextElement } from "../element";
@@ -137,50 +136,28 @@ export class Fonts {
/**
* Load font faces for a given scene and trigger scene update.
*
* FontFaceSet loadingdone event we listen on may not always
* fire (looking at you Safari), so on init we manually load all
* fonts and rerender scene text elements once done.
*
* For Safari we make sure to check against each loaded font face
* with the unique characters per family in the scene,
* otherwise fonts might remain unloaded.
*/
public loadSceneFonts = async (): Promise<FontFace[]> => {
const sceneFamilies = this.getSceneFamilies();
const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(this.scene.getNonDeletedElements())
: undefined;
const charsPerFamily = Fonts.getCharsPerFamily(
this.scene.getNonDeletedElements(),
);
return Fonts.loadFontFaces(sceneFamilies, charsPerFamily);
};
/**
* Load font faces for passed elements - use when the scene is unavailable (i.e. export).
*
* For Safari we make sure to check against each loaded font face,
* with the unique characters per family in the elements
* otherwise fonts might remain unloaded.
*/
public static loadElementsFonts = async (
elements: readonly ExcalidrawElement[],
): Promise<FontFace[]> => {
const fontFamilies = Fonts.getUniqueFamilies(elements);
const charsPerFamily = isSafari
? Fonts.getCharsPerFamily(elements)
: undefined;
const charsPerFamily = Fonts.getCharsPerFamily(elements);
return Fonts.loadFontFaces(fontFamilies, charsPerFamily);
};
/**
* Load all registered font faces.
*/
public static loadAllFonts = async (): Promise<FontFace[]> => {
const allFamilies = Fonts.getAllFamilies();
return Fonts.loadFontFaces(allFamilies);
};
/**
* Generate CSS @font-face declarations for the given elements.
*/
@@ -223,7 +200,7 @@ export class Fonts {
private static async loadFontFaces(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>,
charsPerFamily: Record<number, Set<string>>,
) {
// add all registered font faces into the `document.fonts` (if not added already)
for (const { fontFaces, metadata } of Fonts.registered.values()) {
@@ -248,7 +225,7 @@ export class Fonts {
private static *fontFacesLoader(
fontFamilies: Array<ExcalidrawTextElement["fontFamily"]>,
charsPerFamily?: Record<number, Set<string>>,
charsPerFamily: Record<number, Set<string>>,
): Generator<Promise<void | readonly [number, FontFace[]]>> {
for (const [index, fontFamily] of fontFamilies.entries()) {
const font = getFontString({
@@ -256,12 +233,9 @@ export class Fonts {
fontSize: 16,
});
// WARN: without "text" param it does not have to mean that all font faces are loaded, instead it could be just one!
// for Safari on init, we rather check with the "text" param, even though it's less efficient, as otherwise fonts might remain unloaded
const text =
isSafari && charsPerFamily
? Fonts.getCharacters(charsPerFamily, fontFamily)
: "";
// WARN: without "text" param it does not have to mean that all font faces are loaded as it could be just one irrelevant font face!
// instead, we are always checking chars used in the family, so that no required font faces remain unloaded
const text = Fonts.getCharacters(charsPerFamily, fontFamily);
if (!window.document.fonts.check(font, text)) {
yield promiseTry(async () => {
+5 -6
View File
@@ -1,9 +1,8 @@
import React from "react";
import type { ExcalidrawElement } from "./element/types";
import { convertToExcalidrawElements, Excalidraw } from "./index";
import { API } from "./tests/helpers/api";
import { Keyboard, Pointer } from "./tests/helpers/ui";
import { render } from "./tests/test-utils";
import { getCloneByOrigId, render } from "./tests/test-utils";
const { h } = window;
const mouse = new Pointer("mouse");
@@ -413,10 +412,10 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
selectElementAndDuplicate(rect2);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(frame.id);
expectEqualIds([rect2_copy, rect2, frame]);
@@ -427,11 +426,11 @@ describe("adding elements to frames", () => {
dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
// move the rect2 outside the frame
selectElementAndDuplicate(rect2, [-1000, -1000]);
const rect2_copy = getCloneByOrigId(rect2.id);
expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]);
+1
View File
@@ -103,6 +103,7 @@
"@types/pako": "1.0.3",
"@types/pica": "5.1.3",
"@types/resize-observer-browser": "0.1.7",
"ansicolor": "2.0.3",
"autoprefixer": "10.4.7",
"babel-loader": "8.2.5",
"babel-plugin-transform-class-properties": "6.24.1",
+18 -15
View File
@@ -75,20 +75,23 @@ export const getElementsAtPosition = (
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => {
const iframeLikes: ExcalidrawIframeElement[] = [];
// The parameter elements comes ordered from lower z-index to higher.
// We want to preserve that order on the returned array.
// Exception being embeddables which should be on top of everything else in
// terms of hit testing.
const elsAtPos = elements.filter((element) => {
const hit = !element.isDeleted && isAtPositionFn(element);
if (hit) {
if (isIframeElement(element)) {
iframeLikes.push(element);
return false;
}
return true;
const elementsAtPosition: NonDeletedExcalidrawElement[] = [];
// We need to to hit testing from front (end of the array) to back (beginning of the array)
// because array is ordered from lower z-index to highest and we want element z-index
// with higher z-index
for (let index = elements.length - 1; index >= 0; --index) {
const element = elements[index];
if (element.isDeleted) {
continue;
}
return false;
});
return elsAtPos.concat(iframeLikes);
if (isIframeElement(element)) {
iframeLikes.push(element);
continue;
}
if (isAtPositionFn(element)) {
elementsAtPosition.push(element);
}
}
return elementsAtPosition.concat(iframeLikes);
};
@@ -2517,7 +2517,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"scrolledOutside": false,
"searchMatches": [],
"selectedElementIds": {
"id0_copy": true,
"id1": true,
},
"selectedElementsAreBeingDragged": false,
"selectedGroupIds": {},
@@ -2590,7 +2590,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"frameId": null,
"groupIds": [],
"height": 20,
"id": "id0_copy",
"id": "id1",
"index": "a1",
"isDeleted": false,
"link": null,
@@ -2680,7 +2680,7 @@ History {
"delta": Delta {
"deleted": {
"selectedElementIds": {
"id0_copy": true,
"id1": true,
},
},
"inserted": {
@@ -2693,7 +2693,7 @@ History {
"elementsChange": ElementsChange {
"added": Map {},
"removed": Map {
"id0_copy" => Delta {
"id1" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
"frameId": null,
"groupIds": [],
"height": 50,
"id": "id0_copy",
"id": "id2",
"index": "a0",
"isDeleted": false,
"link": null,
@@ -2129,7 +2129,7 @@ History {
"elementsChange": ElementsChange {
"added": Map {},
"removed": Map {
"id0_copy" => Delta {
"id2" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
@@ -10619,7 +10619,7 @@ History {
"elementsChange": ElementsChange {
"added": Map {},
"removed": Map {
"id0_copy" => Delta {
"id6" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
@@ -10628,7 +10628,7 @@ History {
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"id4_copy",
"id7",
],
"height": 10,
"index": "a0",
@@ -10652,7 +10652,7 @@ History {
"isDeleted": true,
},
},
"id1_copy" => Delta {
"id8" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
@@ -10661,7 +10661,7 @@ History {
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"id4_copy",
"id7",
],
"height": 10,
"index": "a1",
@@ -10685,7 +10685,7 @@ History {
"isDeleted": true,
},
},
"id2_copy" => Delta {
"id9" => Delta {
"deleted": {
"angle": 0,
"backgroundColor": "transparent",
@@ -10694,7 +10694,7 @@ History {
"fillStyle": "solid",
"frameId": null,
"groupIds": [
"id4_copy",
"id7",
],
"height": 10,
"index": "a2",
@@ -50,7 +50,7 @@ describe("actionStyles", () => {
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
fireEvent.change(screen.getByTestId("opacity"), {
target: { value: "60" },
});
@@ -338,7 +338,7 @@ describe("contextMenu element", () => {
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
fireEvent.change(screen.getByTestId("opacity"), {
target: { value: "60" },
});
+93 -6
View File
@@ -40,6 +40,7 @@ import { createTestHook } from "../../components/App";
import type { Action } from "../../actions/types";
import { mutateElement } from "../../element/mutateElement";
import { pointFrom, type LocalPoint, type Radians } from "../../../math";
import { selectGroupsForSelectedElements } from "../../groups";
const readFile = util.promisify(fs.readFile);
// so that window.h is available when App.tsx is not imported as well.
@@ -68,13 +69,21 @@ export class API {
});
};
static setSelectedElements = (elements: ExcalidrawElement[]) => {
static setSelectedElements = (elements: ExcalidrawElement[], editingGroupId?: string | null) => {
act(() => {
h.setState({
selectedElementIds: elements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
...selectGroupsForSelectedElements(
{
editingGroupId: editingGroupId ?? null,
selectedElementIds: elements.reduce((acc, element) => {
acc[element.id] = true;
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
elements,
h.state,
h.app,
)
});
});
};
@@ -158,7 +167,7 @@ export class API {
isDeleted?: boolean;
frameId?: ExcalidrawElement["id"] | null;
index?: ExcalidrawElement["index"];
groupIds?: string[];
groupIds?: ExcalidrawElement["groupIds"];
// generic element props
strokeColor?: ExcalidrawGenericElement["strokeColor"];
backgroundColor?: ExcalidrawGenericElement["backgroundColor"];
@@ -369,6 +378,84 @@ export class API {
return element as any;
};
static createTextContainer = (opts?: {
frameId?: ExcalidrawElement["id"];
groupIds?: ExcalidrawElement["groupIds"];
label?: {
text?: string;
frameId?: ExcalidrawElement["id"] | null;
groupIds?: ExcalidrawElement["groupIds"];
};
}) => {
const rectangle = API.createElement({
type: "rectangle",
frameId: opts?.frameId || null,
groupIds: opts?.groupIds,
});
const text = API.createElement({
type: "text",
text: opts?.label?.text || "sample-text",
width: 50,
height: 20,
fontSize: 16,
containerId: rectangle.id,
frameId:
opts?.label?.frameId === undefined
? opts?.frameId ?? null
: opts?.label?.frameId ?? null,
groupIds: opts?.label?.groupIds === undefined
? opts?.groupIds
: opts?.label?.groupIds ,
});
mutateElement(
rectangle,
{
boundElements: [{ type: "text", id: text.id }],
},
false,
);
return [rectangle, text];
};
static createLabeledArrow = (opts?: {
frameId?: ExcalidrawElement["id"];
label?: {
text?: string;
frameId?: ExcalidrawElement["id"] | null;
};
}) => {
const arrow = API.createElement({
type: "arrow",
frameId: opts?.frameId || null,
});
const text = API.createElement({
type: "text",
id: "text2",
width: 50,
height: 20,
containerId: arrow.id,
frameId:
opts?.label?.frameId === undefined
? opts?.frameId ?? null
: opts?.label?.frameId ?? null,
});
mutateElement(
arrow,
{
boundElements: [{ type: "text", id: text.id }],
},
false,
);
return [arrow, text];
};
static readFile = async <T extends "utf8" | null>(
filepath: string,
encoding?: T,
+10 -9
View File
@@ -7,6 +7,7 @@ import {
assertSelectedElements,
render,
togglePopover,
getCloneByOrigId,
} from "./test-utils";
import { Excalidraw } from "../index";
import { Keyboard, Pointer, UI } from "./helpers/ui";
@@ -15,7 +16,7 @@ import { getDefaultAppState } from "../appState";
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { EXPORT_DATA_TYPES, MIME_TYPES, ORIG_ID } from "../constants";
import type { AppState } from "../types";
import { arrayToMap } from "../utils";
import {
@@ -1138,8 +1139,8 @@ describe("history", () => {
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).toEqual({ A: true });
@@ -1151,8 +1152,8 @@ describe("history", () => {
expect(h.elements).toEqual([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: false }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: false }),
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: false }),
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: false }),
]);
expect(h.state.editingGroupId).toBeNull();
expect(h.state.selectedGroupIds).not.toEqual(
@@ -1171,14 +1172,14 @@ describe("history", () => {
expect.arrayContaining([
expect.objectContaining({ id: rect1.id, isDeleted: false }),
expect.objectContaining({ id: rect2.id, isDeleted: false }),
expect.objectContaining({ id: `${rect1.id}_copy`, isDeleted: true }),
expect.objectContaining({ id: `${rect2.id}_copy`, isDeleted: true }),
expect.objectContaining({ [ORIG_ID]: rect1.id, isDeleted: true }),
expect.objectContaining({ [ORIG_ID]: rect2.id, isDeleted: true }),
expect.objectContaining({
id: `${rect1.id}_copy_copy`,
[ORIG_ID]: getCloneByOrigId(rect1.id)?.id,
isDeleted: false,
}),
expect.objectContaining({
id: `${rect2.id}_copy_copy`,
[ORIG_ID]: getCloneByOrigId(rect2.id)?.id,
isDeleted: false,
}),
]),
+28 -23
View File
@@ -1,11 +1,11 @@
import React from "react";
import { vi } from "vitest";
import { fireEvent, render, waitFor } from "./test-utils";
import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils";
import { act, queryByTestId } from "@testing-library/react";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { MIME_TYPES } from "../constants";
import { MIME_TYPES, ORIG_ID } from "../constants";
import type { LibraryItem, LibraryItems } from "../types";
import { UI } from "./helpers/ui";
import { serializeLibraryAsJSON } from "../data/json";
@@ -76,7 +76,7 @@ describe("library", () => {
}),
);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
});
});
@@ -125,23 +125,27 @@ describe("library", () => {
);
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({
id: "rectangle1_copy",
boundElements: expect.arrayContaining([
{ type: "text", id: "text1_copy" },
{ type: "arrow", id: "arrow1_copy" },
]),
}),
expect.objectContaining({
id: "text1_copy",
containerId: "rectangle1_copy",
}),
expect.objectContaining({
id: "arrow1_copy",
endBinding: expect.objectContaining({ elementId: "rectangle1_copy" }),
}),
]);
expect(h.elements).toEqual(
expect.arrayContaining([
expect.objectContaining({
[ORIG_ID]: "rectangle1",
boundElements: expect.arrayContaining([
{ type: "text", id: getCloneByOrigId("text1").id },
{ type: "arrow", id: getCloneByOrigId("arrow1").id },
]),
}),
expect.objectContaining({
[ORIG_ID]: "text1",
containerId: getCloneByOrigId("rectangle1").id,
}),
expect.objectContaining({
[ORIG_ID]: "arrow1",
endBinding: expect.objectContaining({
elementId: getCloneByOrigId("rectangle1").id,
}),
}),
]),
);
});
});
@@ -170,10 +174,11 @@ describe("library", () => {
await waitFor(() => {
expect(h.elements).toEqual([
expect.objectContaining({
id: "elem1_copy",
[ORIG_ID]: "elem1",
}),
expect.objectContaining({
id: expect.not.stringMatching(/^(elem1_copy|elem1)$/),
id: expect.not.stringMatching(/^elem1$/),
[ORIG_ID]: expect.not.stringMatching(/^\w+$/),
}),
]);
});
@@ -189,7 +194,7 @@ describe("library", () => {
}),
);
await waitFor(() => {
expect(h.elements).toEqual([expect.objectContaining({ id: "A_copy" })]);
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
});
expect(h.state.activeTool.type).toBe("selection");
});
+151
View File
@@ -11,6 +11,10 @@ import { getSelectedElements } from "../scene/selection";
import type { ExcalidrawElement } from "../element/types";
import { UI } from "./helpers/ui";
import { diffStringsUnified } from "jest-diff";
import ansi from "ansicolor";
import { ORIG_ID } from "../constants";
import { arrayToMap } from "../utils";
import type { AllPossibleKeys } from "../utility-types";
const customQueries = {
...queries,
@@ -295,3 +299,150 @@ expect.addSnapshotSerializer({
);
},
});
export const getCloneByOrigId = <T extends boolean = false>(
origId: ExcalidrawElement["id"],
returnNullIfNotExists: T = false as T,
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
const clonedElement = window.h.elements?.find(
(el) => (el as any)[ORIG_ID] === origId,
);
if (clonedElement) {
return clonedElement;
}
if (returnNullIfNotExists !== true) {
throw new Error(`cloned element not found for origId: ${origId}`);
}
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
};
/**
* Assertion helper that strips the actual elements of extra attributes
* so that diffs are easier to read in case of failure.
*
* Asserts element order as well, and selected element ids
* (when `selected: true` set for given element).
*
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
* If you need to refer to cloned element properties, you can use
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
*/
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
actualElements: readonly ExcalidrawElement[],
/** array order matters */
expectedElements: (Partial<Record<T, any>> & {
/** meta, will be stripped for element attribute checks */
selected?: true;
} & (
| {
id: ExcalidrawElement["id"];
}
| { [ORIG_ID]?: string }
))[],
) => {
const h = window.h;
const expectedElementsWithIds: (typeof expectedElements[number] & {
id: ExcalidrawElement["id"];
})[] = expectedElements.map((el) => {
if ("id" in el) {
return el;
}
const actualElement = actualElements.find(
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
);
if (actualElement) {
return { ...el, id: actualElement.id };
}
return {
...el,
id: "UNKNOWN_ID",
};
});
const map_expectedElements = arrayToMap(expectedElementsWithIds);
const selectedElementIds = expectedElementsWithIds.reduce(
(acc: Record<ExcalidrawElement["id"], true>, el) => {
if (el.selected) {
acc[el.id] = true;
}
return acc;
},
{},
);
const mappedActualElements = actualElements.map((el) => {
const expectedElement = map_expectedElements.get(el.id);
if (expectedElement) {
const pickedAttrs: Record<string, any> = {};
for (const key of Object.keys(expectedElement)) {
if (key === "selected") {
delete expectedElement.selected;
continue;
}
pickedAttrs[key] = (el as any)[key];
}
if (ORIG_ID in expectedElement) {
// @ts-ignore
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
}
return pickedAttrs;
}
return el;
});
try {
// testing order separately for even easier diffs
expect(actualElements.map((x) => x.id)).toEqual(
expectedElementsWithIds.map((x) => x.id),
);
} catch (err: any) {
let errStr = "\n\nmismatched element order\n\n";
errStr += `actual: ${ansi.lightGray(
`[${err.actual
.map((id: string, index: number) => {
const act = actualElements[index];
return `${
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
} (${act.type.slice(0, 4)}${
ORIG_ID in act ? `${(act as any)[ORIG_ID]}` : ""
})`;
})
.join(", ")}]`,
)}\n${ansi.lightGray(
`expected: [${err.expected
.map((exp: string, index: number) => {
const expEl = actualElements.find((el) => el.id === exp);
const origEl =
expEl &&
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
return expEl
? `${
exp === err.actual[index]
? ansi.green(expEl.id)
: ansi.red(expEl.id)
} (${expEl.type.slice(0, 4)}${origEl ? `${origEl.id}` : ""})`
: exp;
})
.join(", ")}]\n`,
)}`;
const error = new Error(errStr);
const stack = err.stack.split("\n");
stack.splice(1, 1);
error.stack = stack.join("\n");
throw error;
}
expect(mappedActualElements).toEqual(
expect.arrayContaining(expectedElementsWithIds),
);
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
};
+1 -7
View File
@@ -1,4 +1,4 @@
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
import { isTransparent } from "../utils";
describe("Test isTransparent", () => {
it("should return true when color is rgb transparent", () => {
@@ -11,9 +11,3 @@ describe("Test isTransparent", () => {
expect(isTransparent("#ced4da")).toEqual(false);
});
});
describe("sanitizeHTMLAttribute()", () => {
it("should escape HTML attribute special characters & not double escape", () => {
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&amp;&quot;&#39;&gt;&lt;");
});
});
+35 -35
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
import { act, render } from "./test-utils";
import { act, getCloneByOrigId, render } from "./test-utils";
import { Excalidraw } from "../index";
import { reseed } from "../random";
import {
@@ -916,9 +916,9 @@ describe("z-index manipulation", () => {
API.executeAction(actionDuplicateSelection);
expect(h.elements).toMatchObject([
{ id: "A" },
{ id: "A_copy" },
{ id: getCloneByOrigId("A").id },
{ id: "B" },
{ id: "B_copy" },
{ id: getCloneByOrigId("B").id },
]);
populateElements([
@@ -930,12 +930,12 @@ describe("z-index manipulation", () => {
{ id: "A" },
{ id: "B" },
{
id: "A_copy",
id: getCloneByOrigId("A").id,
groupIds: [expect.stringMatching(/.{3,}/)],
},
{
id: "B_copy",
id: getCloneByOrigId("B").id,
groupIds: [expect.stringMatching(/.{3,}/)],
},
@@ -951,12 +951,12 @@ describe("z-index manipulation", () => {
{ id: "A" },
{ id: "B" },
{
id: "A_copy",
id: getCloneByOrigId("A").id,
groupIds: [expect.stringMatching(/.{3,}/)],
},
{
id: "B_copy",
id: getCloneByOrigId("B").id,
groupIds: [expect.stringMatching(/.{3,}/)],
},
@@ -972,10 +972,10 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"B",
"A_copy",
"B_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("B").id,
"C",
"C_copy",
getCloneByOrigId("C").id,
]);
populateElements([
@@ -988,12 +988,12 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"B",
"A_copy",
"B_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("B").id,
"C",
"D",
"C_copy",
"D_copy",
getCloneByOrigId("C").id,
getCloneByOrigId("D").id,
]);
populateElements(
@@ -1010,10 +1010,10 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"B",
"A_copy",
"B_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("B").id,
"C",
"C_copy",
getCloneByOrigId("C").id,
]);
populateElements(
@@ -1031,9 +1031,9 @@ describe("z-index manipulation", () => {
"A",
"B",
"C",
"A_copy",
"B_copy",
"C_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("B").id,
getCloneByOrigId("C").id,
]);
populateElements(
@@ -1054,15 +1054,15 @@ describe("z-index manipulation", () => {
"A",
"B",
"C",
"A_copy",
"B_copy",
"C_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("B").id,
getCloneByOrigId("C").id,
"D",
"E",
"F",
"D_copy",
"E_copy",
"F_copy",
getCloneByOrigId("D").id,
getCloneByOrigId("E").id,
getCloneByOrigId("F").id,
]);
populateElements(
@@ -1076,7 +1076,7 @@ describe("z-index manipulation", () => {
API.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"A_copy",
getCloneByOrigId("A").id,
"B",
"C",
]);
@@ -1093,7 +1093,7 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"B",
"B_copy",
getCloneByOrigId("B").id,
"C",
]);
@@ -1108,9 +1108,9 @@ describe("z-index manipulation", () => {
API.executeAction(actionDuplicateSelection);
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"A_copy",
getCloneByOrigId("A").id,
"B",
"B_copy",
getCloneByOrigId("B").id,
"C",
]);
});
@@ -1125,8 +1125,8 @@ describe("z-index manipulation", () => {
expect(h.elements.map((element) => element.id)).toEqual([
"A",
"C",
"A_copy",
"C_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("C").id,
"B",
]);
});
@@ -1144,9 +1144,9 @@ describe("z-index manipulation", () => {
"A",
"B",
"C",
"A_copy",
"B_copy",
"C_copy",
getCloneByOrigId("A").id,
getCloneByOrigId("B").id,
getCloneByOrigId("C").id,
"D",
]);
});
+2
View File
@@ -681,6 +681,8 @@ export type AppClassProperties = {
getEditorUIOffsets: App["getEditorUIOffsets"];
visibleElements: App["visibleElements"];
excalidrawContainerValue: App["excalidrawContainerValue"];
onPointerUpEmitter: App["onPointerUpEmitter"];
};
export type PointerDownState = Readonly<{
+3
View File
@@ -65,3 +65,6 @@ export type MakeBrand<T extends string> = {
/** Maybe just promise or already fulfilled one! */
export type MaybePromise<T> = T | Promise<T>;
// get union of all keys from the union of types
export type AllPossibleKeys<T> = T extends any ? keyof T : never;
+17 -12
View File
@@ -9,7 +9,11 @@ import {
isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants";
import type { FontFamilyValues, FontString } from "./element/types";
import type {
ExcalidrawBindableElement,
FontFamilyValues,
FontString,
} from "./element/types";
import type {
ActiveTool,
AppState,
@@ -543,6 +547,9 @@ export const isTransparent = (color: string) => {
);
};
export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) =>
el.fillStyle !== "solid" || isTransparent(el.backgroundColor);
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void
@@ -1226,15 +1233,13 @@ export class PromisePool<T> {
}
}
export const sanitizeHTMLAttribute = (html: string) => {
return (
html
// note, if we're not doing stupid things, escaping " is enough,
// but we might end up doing stupid things
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;")
);
/**
* use when you need to render unsafe string as HTML attribute, but MAKE SURE
* the attribute is double-quoted when constructing the HTML string
*/
export const escapeDoubleQuotes = (str: string) => {
return str.replace(/"/g, "&quot;");
};
export const castArray = <T>(value: T | T[]): T[] =>
Array.isArray(value) ? value : [value];
Binary file not shown.
Binary file not shown.
+29 -11
View File
@@ -123,6 +123,17 @@ module.exports.woff2ServerPlugin = (options = {}) => {
"./assets/NotoEmoji-Regular-2048.ttf",
);
const liberationPath = path.resolve(
__dirname,
"./assets/LiberationSans-Regular.ttf",
);
// need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
const liberationPath_2048 = path.resolve(
__dirname,
"./assets/LiberationSans-Regular-2048.ttf",
);
const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
type: "ttf",
});
@@ -130,6 +141,10 @@ module.exports.woff2ServerPlugin = (options = {}) => {
type: "ttf",
});
const liberationFont = Font.create(fs.readFileSync(liberationPath), {
type: "ttf",
});
const sortedFonts = Array.from(fonts.entries()).sort(
([family1], [family2]) => (family1 > family2 ? 1 : -1),
);
@@ -141,13 +156,6 @@ module.exports.woff2ServerPlugin = (options = {}) => {
continue;
}
const fallbackFontsPaths = [];
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
if (shouldIncludeXiaolaiFallback) {
fallbackFontsPaths.push(xiaolaiPath);
}
const baseFont = Regular[0];
const tempPaths = Regular.map((_, index) =>
path.resolve(outputDir, `temp_${family}_${index}.ttf`),
@@ -165,10 +173,18 @@ module.exports.woff2ServerPlugin = (options = {}) => {
const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
const fallbackFontsPaths = [];
const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
if (shouldIncludeXiaolaiFallback) {
fallbackFontsPaths.push(xiaolaiPath);
}
// add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.)
if (baseFont.data.head.unitsPerEm === 2048) {
fallbackFontsPaths.push(emojiPath_2048);
fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
} else {
fallbackFontsPaths.push(emojiPath);
fallbackFontsPaths.push(emojiPath, liberationPath);
}
// drop Vertical related metrics, otherwise it does not allow us to merge the fonts
@@ -196,10 +212,12 @@ module.exports.woff2ServerPlugin = (options = {}) => {
const base = baseFont.data.name[field];
const xiaolai = xiaolaiFont.data.name[field];
const emoji = emojiFont.data.name[field];
const liberation = liberationFont.data.name[field];
// liberation font
return shouldIncludeXiaolaiFallback
? `${base} & ${xiaolai} & ${emoji}`
: `${base} & ${emoji}`;
? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
: `${base} & ${emoji} & ${liberation}`;
};
mergedFont.set({
+5
View File
@@ -3916,6 +3916,11 @@ ansi-styles@^6.0.0, ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
ansicolor@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/ansicolor/-/ansicolor-2.0.3.tgz#ec4448ae5baf8c2d62bf2dad52eac06ba0b5ea21"
integrity sha512-pzusTqk9VHrjgMCcTPDTTvfJfx6Q3+L5tQ6yKC8Diexmoit4YROTFIkxFvRTNL9y5s0Q8HrSrgerCD5bIC+Kiw==
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"