Compare commits

..

5 Commits

Author SHA1 Message Date
Daniel J. Geiger
48c1f93e3e fix: Include src/data/restore.ts. 2023-09-08 13:37:08 -05:00
Daniel J. Geiger
3dd446dc15 feat: Add a subtype?: string property to ExcalidrawElement. 2023-09-08 13:17:03 -05:00
Rajnikant dash
56c21529db docs: Adding the json Schema to the documentation (#6817)
Co-authored-by: Rajni2002 <rajnikant.dash@everlytics.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-09-07 12:43:37 +02:00
Marcel Mraz
a13aed92f2 fix: z-index inconsistencies during addition / deletion in frames (#6914)
Co-authored-by: Marcel Mraz <marcel.mraz@adacta-fintech.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
2023-09-06 22:41:44 +00:00
Aakansha Doshi
134df7bfbb fix: update size-limit so react is not installed as dependency (#6964) 2023-09-06 10:39:04 +05:30
7 changed files with 510 additions and 81 deletions

View File

@@ -0,0 +1,75 @@
# JSON Schema
The Excalidraw data format uses plaintext JSON.
## Excalidraw files
When saving an Excalidraw scene locally to a file, the JSON file (`.excalidraw`) is using the below format.
### Attributes
| Attribute | Description | Value |
| --- | --- | --- |
| `type` | The type of the Excalidraw schema | `"excalidraw"` |
| `version` | The version of the Excalidraw schema | number |
| `source` | The source URL of the Excalidraw application | `"https://excalidraw.com"` |
| `elements` | An array of objects representing excalidraw elements on canvas | Array containing excalidraw element objects |
| `appState` | Additional application state/configuration | Object containing application state properties |
| `files` | Data for excalidraw `image` elements | Object containing image data |
### JSON Schema example
```json
{
// schema information
"type": "excalidraw",
"version": 2,
"source": "https://excalidraw.com",
// elements on canvas
"elements": [
// example element
{
"id": "pologsyG-tAraPgiN9xP9b",
"type": "rectangle",
"x": 928,
"y": 319,
"width": 134,
"height": 90
/* ...other element properties */
}
/* other elements */
],
// editor state (canvas config, preferences, ...)
"appState": {
"gridSize": null,
"viewBackgroundColor": "#ffffff"
},
// files data for "image" elements, using format `{ [fileId]: fileData }`
"files": {
// example of an image data object
"3cebd7720911620a3938ce77243696149da03861": {
"mimeType": "image/png",
"id": "3cebd7720911620a3938c.77243626149da03861",
"dataURL": "data:image/png;base64,iVBORWOKGgoAAAANSUhEUgA=",
"created": 1690295874454,
"lastRetrieved": 1690295874454
}
/* ...other image data objects */
}
}
```
## Excalidraw clipboard format
When copying selected excalidraw elements to clipboard, the JSON schema is similar to `.excalidraw` format, except it differs in attributes.
### Attributes
| Attribute | Description | Example Value |
| --- | --- | --- |
| `type` | The type of the Excalidraw document. | "excalidraw/clipboard" |
| `elements` | An array of objects representing excalidraw elements on canvas. | Array containing excalidraw element objects (see example below) |
| `files` | Data for excalidraw `image` elements. | Object containing image data |

View File

@@ -23,7 +23,6 @@ const sidebars = {
},
items: ["introduction/development", "introduction/contributing"],
},
{
type: "category",
label: "@excalidraw/excalidraw",
@@ -92,6 +91,11 @@ const sidebars = {
"@excalidraw/excalidraw/development",
],
},
{
type: "category",
label: "Codebase",
items: ["codebase/json-schema"],
},
],
};

View File

@@ -6501,7 +6501,7 @@ class App extends React.Component<AppProps, AppState> {
}
nextElements = updateFrameMembershipOfSelectedElements(
this.scene.getElementsIncludingDeleted(),
nextElements,
this.state,
this,
);

View File

@@ -92,7 +92,8 @@ const repairBinding = (binding: PointBinding | null) => {
};
const restoreElementWithProperties = <
T extends Required<Omit<ExcalidrawElement, "customData">> & {
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
subtype?: ExcalidrawElement["subtype"];
customData?: ExcalidrawElement["customData"];
/** @deprecated */
boundElementIds?: readonly ExcalidrawElement["id"][];
@@ -158,6 +159,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false,
};
if ("subtype" in element) {
base.subtype = element.subtype;
}
if ("customData" in element) {
base.customData = element.customData;
}

View File

@@ -65,6 +65,7 @@ type _ExcalidrawElementBase = Readonly<{
updated: number;
link: string | null;
locked: boolean;
subtype?: string;
customData?: Record<string, any>;
}>;

View File

@@ -1,9 +1,10 @@
import { ExcalidrawElement } from "./element/types";
import {
convertToExcalidrawElements,
Excalidraw,
} from "./packages/excalidraw/index";
import { API } from "./tests/helpers/api";
import { Pointer } from "./tests/helpers/ui";
import { Keyboard, Pointer } from "./tests/helpers/ui";
import { render } from "./tests/test-utils";
const { h } = window;
@@ -28,83 +29,301 @@ describe("adding elements to frames", () => {
}, []);
};
describe("resizing frame over elements", () => {
const testElements = async (
containerType: "arrow" | "rectangle",
initialOrder: ElementType[],
expectedOrder: ElementType[],
) => {
await render(<Excalidraw />);
function resizeFrameOverElement(
frame: ExcalidrawElement,
element: ExcalidrawElement,
) {
mouse.clickAt(0, 0);
mouse.downAt(frame.x + frame.width, frame.y + frame.height);
mouse.moveTo(
element.x + element.width + 50,
element.y + element.height + 50,
);
mouse.up();
}
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
function dragElementIntoFrame(
frame: ExcalidrawElement,
element: ExcalidrawElement,
) {
mouse.clickAt(element.x, element.y);
mouse.downAt(element.x + element.width / 2, element.y + element.height / 2);
mouse.moveTo(frame.x + frame.width / 2, frame.y + frame.height / 2);
mouse.up();
}
h.elements = reorderElements(
[
frame,
...convertToExcalidrawElements([
{
type: containerType,
x: 100,
y: 100,
height: 10,
label: { text: "xx" },
},
]),
],
initialOrder,
);
function selectElementAndDuplicate(
element: ExcalidrawElement,
moveTo: [number, number] = [element.x + 25, element.y + 25],
) {
const [x, y] = [
element.x + element.width / 2,
element.y + element.height / 2,
];
assertOrder(h.elements, initialOrder);
expect(h.elements[1].frameId).toBe(null);
expect(h.elements[2].frameId).toBe(null);
const container = h.elements[1];
mouse.clickAt(0, 0);
mouse.downAt(frame.x + frame.width, frame.y + frame.height);
mouse.moveTo(
container.x + container.width + 100,
container.y + container.height + 100,
);
Keyboard.withModifierKeys({ alt: true }, () => {
mouse.downAt(x, y);
mouse.moveTo(moveTo[0], moveTo[1]);
mouse.up();
assertOrder(h.elements, expectedOrder);
});
}
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].frameId).toBe(frame.id);
};
function expectEqualIds(expected: ExcalidrawElement[]) {
expect(h.elements.map((x) => x.id)).toEqual(expected.map((x) => x.id));
}
it("resizing over text containers / labelled arrows", async () => {
await testElements(
let frame: ExcalidrawElement;
let rect1: ExcalidrawElement;
let rect2: ExcalidrawElement;
let rect3: ExcalidrawElement;
let rect4: ExcalidrawElement;
let text: ExcalidrawElement;
let arrow: ExcalidrawElement;
beforeEach(async () => {
await render(<Excalidraw />);
frame = API.createElement({ id: "id0", type: "frame", x: 0, width: 150 });
rect1 = API.createElement({
id: "id1",
type: "rectangle",
x: -1000,
});
rect2 = API.createElement({
id: "id2",
type: "rectangle",
x: 200,
width: 50,
});
rect3 = API.createElement({
id: "id3",
type: "rectangle",
x: 400,
width: 50,
});
rect4 = API.createElement({
id: "id4",
type: "rectangle",
x: 1000,
width: 50,
});
text = API.createElement({
id: "id5",
type: "text",
x: 100,
});
arrow = API.createElement({
id: "id6",
type: "arrow",
x: 100,
boundElements: [{ id: text.id, type: "text" }],
});
});
const commonTestCases = async (
func: typeof resizeFrameOverElement | typeof dragElementIntoFrame,
) => {
describe("when frame is in a layer below", async () => {
it("should add an element", async () => {
h.elements = [frame, rect2];
func(frame, rect2);
expect(h.elements[0].frameId).toBe(frame.id);
expectEqualIds([rect2, frame]);
});
it("should add elements", async () => {
h.elements = [frame, rect2, rect3];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect2, rect3, frame]);
});
it("should add elements when there are other other elements in between", async () => {
h.elements = [frame, rect1, rect2, rect4, rect3];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect2, rect3, frame, rect1, rect4]);
});
it("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [frame, rect3, rect4, rect2, rect1];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect2, rect3, frame, rect4, rect1]);
});
});
describe("when frame is in a layer above", async () => {
it("should add an element", async () => {
h.elements = [rect2, frame];
func(frame, rect2);
expect(h.elements[0].frameId).toBe(frame.id);
expectEqualIds([rect2, frame]);
});
it("should add elements", async () => {
h.elements = [rect2, rect3, frame];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect3, rect2, frame]);
});
it("should add elements when there are other other elements in between", async () => {
h.elements = [rect1, rect2, rect4, rect3, frame];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect1, rect4, rect3, rect2, frame]);
});
it("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, rect2, rect1, frame];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect4, rect1, rect3, rect2, frame]);
});
});
describe("when frame is in an inner layer", async () => {
it("should add elements", async () => {
h.elements = [rect2, frame, rect3];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect2, rect3, frame]);
});
it("should add elements when there are other other elements in between", async () => {
h.elements = [rect2, rect1, frame, rect4, rect3];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
});
it("should add elements when there are other elements in between and the order is reversed", async () => {
h.elements = [rect3, rect4, frame, rect2, rect1];
func(frame, rect2);
func(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect4, rect3, rect2, frame, rect1]);
});
});
};
const resizingTest = async (
containerType: "arrow" | "rectangle",
initialOrder: ElementType[],
expectedOrder: ElementType[],
) => {
await render(<Excalidraw />);
const frame = API.createElement({ type: "frame", x: 0, y: 0 });
h.elements = reorderElements(
[
frame,
...convertToExcalidrawElements([
{
type: containerType,
x: 100,
y: 100,
height: 10,
label: { text: "xx" },
},
]),
],
initialOrder,
);
assertOrder(h.elements, initialOrder);
expect(h.elements[1].frameId).toBe(null);
expect(h.elements[2].frameId).toBe(null);
const container = h.elements[1];
resizeFrameOverElement(frame, container);
assertOrder(h.elements, expectedOrder);
expect(h.elements[0].frameId).toBe(frame.id);
expect(h.elements[1].frameId).toBe(frame.id);
};
describe("resizing frame over elements", async () => {
await commonTestCases(resizeFrameOverElement);
it("resizing over text containers and labelled arrows", async () => {
await resizingTest(
"rectangle",
["frame", "rectangle", "text"],
["rectangle", "text", "frame"],
);
await testElements(
await resizingTest(
"rectangle",
["frame", "text", "rectangle"],
["rectangle", "text", "frame"],
);
await testElements(
await resizingTest(
"rectangle",
["rectangle", "text", "frame"],
["rectangle", "text", "frame"],
);
await testElements(
await resizingTest(
"rectangle",
["text", "rectangle", "frame"],
["text", "rectangle", "frame"],
["rectangle", "text", "frame"],
);
await testElements(
await resizingTest(
"arrow",
["frame", "arrow", "text"],
["arrow", "text", "frame"],
);
await testElements(
await resizingTest(
"arrow",
["text", "arrow", "frame"],
["text", "arrow", "frame"],
["arrow", "text", "frame"],
);
await resizingTest(
"arrow",
["frame", "arrow", "text"],
["arrow", "text", "frame"],
);
// FIXME failing in tests (it fails to add elements to frame for some
@@ -118,11 +337,104 @@ describe("adding elements to frames", () => {
// ["arrow", "text", "frame"],
// ["arrow", "text", "frame"],
// );
// await testElements(
// "arrow",
// ["frame", "text", "arrow"],
// ["text", "arrow", "frame"],
// );
});
it("should add arrow bound with text when frame is in a layer below", async () => {
h.elements = [frame, arrow, text];
resizeFrameOverElement(frame, arrow);
expect(arrow.frameId).toBe(frame.id);
expect(text.frameId).toBe(frame.id);
expectEqualIds([arrow, text, frame]);
});
it("should add arrow bound with text when frame is in a layer above", async () => {
h.elements = [arrow, text, frame];
resizeFrameOverElement(frame, arrow);
expect(arrow.frameId).toBe(frame.id);
expect(text.frameId).toBe(frame.id);
expectEqualIds([arrow, text, frame]);
});
it("should add arrow bound with text when frame is in an inner layer", async () => {
h.elements = [arrow, frame, text];
resizeFrameOverElement(frame, arrow);
expect(arrow.frameId).toBe(frame.id);
expect(text.frameId).toBe(frame.id);
expectEqualIds([arrow, text, frame]);
});
});
describe("resizing frame over elements but downwards", async () => {
it("should add elements when frame is in a layer below", async () => {
h.elements = [frame, rect1, rect2, rect3, rect4];
resizeFrameOverElement(frame, rect4);
resizeFrameOverElement(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect2, rect3, frame, rect4, rect1]);
});
it("should add elements when frame is in a layer above", async () => {
h.elements = [rect1, rect2, rect3, rect4, frame];
resizeFrameOverElement(frame, rect4);
resizeFrameOverElement(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
});
it("should add elements when frame is in an inner layer", async () => {
h.elements = [rect1, rect2, frame, rect3, rect4];
resizeFrameOverElement(frame, rect4);
resizeFrameOverElement(frame, rect3);
expect(rect2.frameId).toBe(frame.id);
expect(rect3.frameId).toBe(frame.id);
expectEqualIds([rect1, rect2, rect3, frame, rect4]);
});
});
describe("dragging elements into the frame", async () => {
await commonTestCases(dragElementIntoFrame);
it("should drag element inside, duplicate it and keep it in frame", () => {
h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
selectElementAndDuplicate(rect2);
expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(frame.id);
expectEqualIds([rect2_copy, rect2, frame]);
});
it("should drag element inside, duplicate it and remove it from frame", () => {
h.elements = [frame, rect2];
dragElementIntoFrame(frame, rect2);
const rect2_copy = { ...rect2, id: `${rect2.id}_copy` };
// move the rect2 outside the frame
selectElementAndDuplicate(rect2, [-1000, -1000]);
expect(rect2_copy.frameId).toBe(frame.id);
expect(rect2.frameId).toBe(null);
expectEqualIds([rect2_copy, frame, rect2]);
});
});
});

View File

@@ -468,14 +468,39 @@ export const addElementsToFrame = (
}
}
let nextElements = allElements.slice();
const allElementsIndex = allElements.reduce(
(acc: Record<string, number>, element, index) => {
acc[element.id] = index;
return acc;
},
{},
);
const frameIndex = allElementsIndex[frame.id];
// need to be calculated before the mutation below occurs
const leftFrameBoundaryIndex = findIndex(
allElements,
(e) => e.frameId === frame.id,
);
const existingFrameChildren = allElements.filter(
(element) => element.frameId === frame.id,
);
const addedFrameChildren_left: ExcalidrawElement[] = [];
const addedFrameChildren_right: ExcalidrawElement[] = [];
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
for (const element of omitGroupsContainingFrames(
allElements,
_elementsToAdd,
)) {
if (element.frameId !== frame.id && !isFrameElement(element)) {
if (allElementsIndex[element.id] > frameIndex) {
addedFrameChildren_right.push(element);
} else {
addedFrameChildren_left.push(element);
}
mutateElement(
element,
{
@@ -483,28 +508,35 @@ export const addElementsToFrame = (
},
false,
);
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
if (elementIndex < frameBoundary) {
nextElements = [
...nextElements.slice(0, elementIndex),
...nextElements.slice(elementIndex + 1, frameBoundary),
element,
...nextElements.slice(frameBoundary),
];
} else if (elementIndex > frameIndex) {
nextElements = [
...nextElements.slice(0, frameIndex),
element,
...nextElements.slice(frameIndex, elementIndex),
...nextElements.slice(elementIndex + 1),
];
}
}
}
const frameElement = allElements[frameIndex];
const nextFrameChildren = addedFrameChildren_left
.concat(existingFrameChildren)
.concat(addedFrameChildren_right);
const nextFrameChildrenMap = nextFrameChildren.reduce(
(acc: Record<string, boolean>, element) => {
acc[element.id] = true;
return acc;
},
{},
);
const nextOtherElements_left = allElements
.slice(0, leftFrameBoundaryIndex >= 0 ? leftFrameBoundaryIndex : frameIndex)
.filter((element) => !nextFrameChildrenMap[element.id]);
const nextOtherElement_right = allElements
.slice(frameIndex + 1)
.filter((element) => !nextFrameChildrenMap[element.id]);
const nextElements = nextOtherElements_left
.concat(nextFrameChildren)
.concat([frameElement])
.concat(nextOtherElement_right);
return nextElements;
};
@@ -518,6 +550,7 @@ export const removeElementsFromFrame = (
for (const element of elementsToRemove) {
if (element.frameId) {
_elementsToRemove.push(element);
const boundTextElement = getBoundTextElement(element);
if (boundTextElement) {
_elementsToRemove.push(boundTextElement);
@@ -566,7 +599,7 @@ export const replaceAllElementsInFrame = (
);
};
/** does not mutate elements, but return new ones */
/** does not mutate elements, but returns new ones */
export const updateFrameMembershipOfSelectedElements = (
allElements: ExcalidrawElementsIncludingDeleted,
appState: AppState,