Compare commits

...
Author SHA1 Message Date
Marcel Mraz d872adf593 Order based on fractional index in history action 2023-12-11 12:15:38 +01:00
Marcel Mraz 260706c42f First step towards delta based history
Introducing independent change detection for appState and elements

Generalizing object change, cleanup, refactoring, comments, solving typing issues

Shaping increment, change, delta hierarchy

Structural clone of elements

Introducing store and incremental API

Disabling buttons for canvas actions, smaller store and changes improvements

Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands

Solving concurrency issues, solving (partly) linear element issues,  introducing commitToStore breaking change

Fixing existing tests, updating snapshots

Trying to be smarter on the appstate change detection

Extending collab test, refactoring action / updateScene params, bugfixes

Resetting snapshots

Resetting snapshots

UI / API tests for history - WIP

Changing actions related to the observed appstate to at least update the store snapshot - WIP

Adding skipping of snapshot update flag for most no-breaking changes compatible solution

Ignoring uncomitted elements from local async actions, updating store directly in updateScene

Bound element issues - WIP
2023-12-11 11:59:24 +01:00
Ryan Di 5e98047267 test the fixing of fractional indices 2023-12-08 18:06:09 +08:00
Ryan Di 741380bd43 one more case for restoring test 2023-12-08 10:43:34 +08:00
Ryan Di 5ed82cb646 not using jittered keys in tests (for snapshot matching) 2023-12-07 23:40:56 +08:00
Ryan Di dddb07cf57 restore test 2023-12-07 23:33:31 +08:00
Ryan Di d6a6c40051 jitter when restoring as well 2023-12-06 23:39:34 +08:00
Ryan Di bf53d90c68 indices with jitter 2023-12-06 23:25:11 +08:00
Ryan Di b734f7cba8 fix shift to end z-index error 2023-12-06 16:56:55 +08:00
Ryan Di 4f218856c3 fix fractional indices on duplication 2023-12-06 10:39:13 +08:00
Ryan Di 7dfba985f9 fix fractional indices on adding new elements 2023-12-05 23:04:17 +08:00
Ryan Di 5bc23d6dee merge branch 'fractional-indexing' of github.com:excalidraw/excalidraw into fractional-indexing 2023-12-05 13:09:35 +08:00
Ryan Di 093e684d9e generate real fractional index after z-index actions 2023-12-05 13:07:07 +08:00
Ryan Di 84c1de7a03 generate real fractional index after z-index actions 2023-12-05 13:06:00 +08:00
Ryan Di d1a9c593cc refactor code 2023-12-01 17:43:01 +08:00
Ryan Di a7154227cf reconciliate order based on fractional index 2023-12-01 15:59:36 +08:00
Ryan Di 1e132e33ae normalize before replacing 2023-12-01 15:58:49 +08:00
Ryan Di 00ffa08e28 use string as fractional index value 2023-11-30 19:02:14 +08:00
Ryan Di 5c1787bdf4 update data restore to keep fractional index 2023-11-29 18:18:44 +08:00
Ryan Di de32256466 simplify 2023-11-29 18:13:51 +08:00
Ryan Di 02dc00a47e fractionalIndex as a byproduct or zIndex 2023-11-29 17:41:04 +08:00
David LuzarandGitHub c7ee46e7f8 feat: wireframe-to-code (#7334) 2023-11-23 23:07:53 +01:00
DanielJGeigerandGitHub d1e4421823 feat: Expose ActionManager.registerAction through ExcalidrawImperativeAPI (#6995)
* feat: Expose `ActionManager` through `ExcalidrawImperativeAPI`

* Only expose `registerAction` instead of `ActionManager`
2023-11-22 15:22:49 -06:00
119 changed files with 6610 additions and 2320 deletions
@@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage | | API | Signature | Usage |
| --- | --- | --- | | --- | --- | --- |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData | | [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData | | [updateLibrary](#updatelibrary) | `function` | updates the library |
| [addFiles](#addfiles) | `function` | add files data to the appState | | [addFiles](#addfiles) | `function` | add files data to the appState |
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene | | [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
@@ -65,7 +65,8 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L38) | The `elements` to be updated in the scene | | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L39) | The `appState` to be updated in the scene. | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/src/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. | | `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | | `commitToStore` | `boolean` | Implies if the `store` should update it's snapshot, capture the update and calculates the diff. Captured changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
| `skipSnapshotUpdate` | `boolean` | Implies whether the `store` should skip update of its snapshot, which is necessary for correct diff calculation. Relevant only when `elements` or `appState` are passed in. When `true`, `commitToStore` value will be ignored. Defaults to `false`. |
```jsx live ```jsx live
function App() { function App() {
+3 -18
View File
@@ -302,7 +302,6 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: false,
}); });
} }
}; };
@@ -449,14 +448,12 @@ class Collab extends PureComponent<Props, CollabState> {
} }
return element; return element;
}); });
// remove deleted elements from elements array & history to ensure we don't // remove deleted elements from elements array to ensure we don't
// expose potentially sensitive user data in case user manually deletes // expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted // existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room. // to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: true,
}); });
this.saveCollabRoomToFirebase(getSyncableElements(elements)); this.saveCollabRoomToFirebase(getSyncableElements(elements));
@@ -491,9 +488,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.initializeRoom({ fetchScene: false }); this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements; const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements); const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, { this.handleRemoteSceneUpdate(reconciledElements);
init: true,
});
// noop if already resolved via init from firebase // noop if already resolved via init from firebase
scenePromise.resolve({ scenePromise.resolve({
elements: reconciledElements, elements: reconciledElements,
@@ -649,21 +644,11 @@ class Collab extends PureComponent<Props, CollabState> {
}); });
}, LOAD_IMAGES_TIMEOUT); }, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = ( private handleRemoteSceneUpdate = (elements: ReconciledElements) => {
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
) => {
this.excalidrawAPI.updateScene({ this.excalidrawAPI.updateScene({
elements, elements,
commitToHistory: !!init,
}); });
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles(); this.loadImageFiles();
}; };
+1 -6
View File
@@ -18,7 +18,6 @@ import throttle from "lodash.throttle";
import { newElementWith } from "../../src/element/mutateElement"; import { newElementWith } from "../../src/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation"; import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../src/data/encryption"; import { encryptData } from "../../src/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
class Portal { class Portal {
collab: TCollabClass; collab: TCollabClass;
@@ -150,11 +149,7 @@ class Portal {
this.broadcastedElementVersions.get(element.id)!) && this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element) isSyncableElement(element)
) { ) {
acc.push({ acc.push(element);
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
} }
return acc; return acc;
}, },
+30 -105
View File
@@ -1,15 +1,13 @@
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { AppState } from "../../src/types"; import { AppState } from "../../src/types";
import { arrayToMapWithIndex } from "../../src/utils"; import { arrayToMap } from "../../src/utils";
import { orderByFractionalIndex } from "../../src/fractionalIndex";
export type ReconciledElements = readonly ExcalidrawElement[] & { export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements"; _brand: "reconciledElements";
}; };
export type BroadcastedExcalidrawElement = ExcalidrawElement & { export type BroadcastedExcalidrawElement = ExcalidrawElement;
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = ( const shouldDiscardRemoteElement = (
localAppState: AppState, localAppState: AppState,
@@ -21,7 +19,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited // local element is being edited
(local.id === localAppState.editingElement?.id || (local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id || local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id || local.id === localAppState.draggingElement?.id || // Is this still valid? As draggingElement is selection element, which is never part of the elements array
// local element is newer // local element is newer
local.version > remote.version || local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with // resolve conflicting edits deterministically by taking the one with
@@ -39,116 +37,43 @@ export const reconcileElements = (
remoteElements: readonly BroadcastedExcalidrawElement[], remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState, localAppState: AppState,
): ReconciledElements => { ): ReconciledElements => {
const localElementsData = const localElementsData = arrayToMap(localElements);
arrayToMapWithIndex<ExcalidrawElement>(localElements); const reconciledElements: ExcalidrawElement[] = [];
const added = new Set<string>();
const reconciledElements: ExcalidrawElement[] = localElements.slice(); // process remote elements
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) { for (const remoteElement of remoteElements) {
remoteElementIdx++; if (localElementsData.has(remoteElement.id)) {
const localElement = localElementsData.get(remoteElement.id);
const local = localElementsData.get(remoteElement.id); if (
localElement &&
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { shouldDiscardRemoteElement(localAppState, localElement, remoteElement)
if (remoteElement[PRECEDING_ELEMENT_KEY]) { ) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
}
continue;
}
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) {
// Unless the remote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue; continue;
}
duplicates.set(local[0], true);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement[PRECEDING_ELEMENT_KEY] ||
remoteElements[remoteElementIdx - 1]?.id ||
null;
if (parent != null) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
}
} else { } else {
let idx = localElementsData.has(parent) if (!added.has(remoteElement.id)) {
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
} else {
reconciledElements.push(remoteElement); reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [ added.add(remoteElement.id);
remoteElement,
reconciledElements.length - 1 - offset,
]);
} }
} }
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else { } else {
reconciledElements.push(remoteElement); if (!added.has(remoteElement.id)) {
localElementsData.set(remoteElement.id, [ reconciledElements.push(remoteElement);
remoteElement, added.add(remoteElement.id);
reconciledElements.length - 1 - offset, }
]);
} }
} }
const ret: readonly ExcalidrawElement[] = reconciledElements.filter( // process local elements
(element) => !duplicates.has(element), for (const localElement of localElements) {
); if (!added.has(localElement.id)) {
reconciledElements.push(localElement);
added.add(localElement.id);
}
}
return ret as ReconciledElements; return orderByFractionalIndex(
reconciledElements,
) as readonly ExcalidrawElement[] as ReconciledElements;
}; };
+1 -1
View File
@@ -278,7 +278,7 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async // in the scene database/localStorage, and instead fetch them async
// from a different database // from a different database
files: data.files, files: data.files,
commitToHistory: false, commitToStore: false,
}; };
}; };
+2 -1
View File
@@ -409,7 +409,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
...data.scene, ...data.scene,
...restore(data.scene, null, null, { repairBindings: true }), ...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true, commitToStore: true,
}); });
} }
}); });
@@ -590,6 +590,7 @@ const ExcalidrawWrapper = () => {
if (didChange) { if (didChange) {
excalidrawAPI.updateScene({ excalidrawAPI.updateScene({
elements, elements,
skipSnapshotUpdate: true,
}); });
} }
} }
+21 -4
View File
@@ -64,7 +64,7 @@ vi.mock("socket.io-client", () => {
}); });
describe("collaboration", () => { describe("collaboration", () => {
it("creating room should reset deleted elements", async () => { it("creating room should reset deleted elements while keeping store snapshot in sync", async () => {
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
// To update the scene with deleted elements before starting collab // To update the scene with deleted elements before starting collab
updateSceneData({ updateSceneData({
@@ -76,26 +76,43 @@ describe("collaboration", () => {
isDeleted: true, isDeleted: true,
}), }),
], ],
commitToStore: true,
}); });
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([ expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }), expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }), expect.objectContaining({ id: "B", isDeleted: true }),
]); ]);
expect(API.getStateHistory().length).toBe(1); expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
}); });
window.collab.startCollaboration(null); window.collab.startCollaboration(null);
await waitFor(() => { await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1); // We never delete from the local store as it is used for correct diff calculation
expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
}); });
const undoAction = createUndoAction(h.history); const undoAction = createUndoAction(h.history);
// noop // noop
h.app.actionManager.executeAction(undoAction); h.app.actionManager.executeAction(undoAction);
// As it was introduced #2270, undo is a noop here, but we might want to re-enable it,
// since inability to undo your own deletions could be a bigger upsetting factor here
await waitFor(() => { await waitFor(() => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1); expect(Array.from(h.store.snapshot.elements.values())).toEqual([
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
}); });
}); });
}); });
+3 -16
View File
@@ -1,5 +1,4 @@
import { expect } from "chai"; import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types"; import { ExcalidrawElement } from "../../src/element/types";
import { import {
BroadcastedExcalidrawElement, BroadcastedExcalidrawElement,
@@ -15,7 +14,6 @@ type ElementLike = {
id: string; id: string;
version: number; version: number;
versionNonce: number; versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
}; };
type Cache = Record<string, ExcalidrawElement | undefined>; type Cache = Record<string, ExcalidrawElement | undefined>;
@@ -44,7 +42,6 @@ const createElement = (opts: { uid: string } | ElementLike) => {
id, id,
version, version,
versionNonce: versionNonce || randomInteger(), versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
}; };
}; };
@@ -53,20 +50,15 @@ const idsToElements = (
cache: Cache = {}, cache: Cache = {},
): readonly ExcalidrawElement[] => { ): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => { return ids.reduce((acc, _uid, idx) => {
const { const { uid, id, version, versionNonce } = createElement(
uid, typeof _uid === "string" ? { uid: _uid } : _uid,
id, );
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const cached = cache[uid]; const cached = cache[uid];
const elem = { const elem = {
id, id,
version: version ?? 0, version: version ?? 0,
versionNonce, versionNonce,
...cached, ...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement; } as BroadcastedExcalidrawElement;
// @ts-ignore // @ts-ignore
cache[uid] = elem; cache[uid] = elem;
@@ -77,7 +69,6 @@ const idsToElements = (
const addParents = (elements: BroadcastedExcalidrawElement[]) => { const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => { return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el; return el;
}); });
}; };
@@ -389,13 +380,11 @@ describe("elements reconciliation", () => {
id: "A", id: "A",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}, },
{ {
id: "B", id: "B",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}, },
]; ];
@@ -408,13 +397,11 @@ describe("elements reconciliation", () => {
id: "A", id: "A",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}; };
const el2 = { const el2 = {
id: "B", id: "B",
version: 1, version: 1,
versionNonce: 1, versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
}; };
testIdentical([el1, el2], [el2, el1], ["A", "B"]); testIdentical([el1, el2], [el2, el1], ["A", "B"]);
}); });
+1
View File
@@ -37,6 +37,7 @@
"eslint-plugin-react": "7.32.2", "eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7", "fake-indexeddb": "3.1.7",
"firebase": "8.3.3", "firebase": "8.3.3",
"fractional-indexing-jittered": "0.9.0",
"i18next-browser-languagedetector": "6.1.4", "i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3", "idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1", "image-blob-reduce": "3.0.1",
+4 -3
View File
@@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random"; import { randomId } from "../random";
import { t } from "../i18n"; import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants"; import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "./types";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
@@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) { for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) { if (selectedElements.some((element) => element.type === type)) {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`), errorMessage: t(`errors.libraryElementTypeError.${type}`),
@@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
}) })
.then(() => { .then(() => {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
toast: { message: t("toast.addedToLibrary") }, toast: { message: t("toast.addedToLibrary") },
@@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
}) })
.catch((error) => { .catch((error) => {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
+9 -7
View File
@@ -9,6 +9,7 @@ import {
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -17,6 +18,7 @@ import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
const alignActionsPredicate = ( const alignActionsPredicate = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -28,7 +30,7 @@ const alignActionsPredicate = (
return ( return (
selectedElements.length > 1 && selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly // TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame") !selectedElements.some((el) => isFrameLikeElement(el))
); );
}; };
@@ -62,7 +64,7 @@ export const actionAlignTop = register({
position: "start", position: "start",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@@ -93,7 +95,7 @@ export const actionAlignBottom = register({
position: "end", position: "end",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@@ -124,7 +126,7 @@ export const actionAlignLeft = register({
position: "start", position: "start",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@@ -155,7 +157,7 @@ export const actionAlignRight = register({
position: "end", position: "end",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@@ -186,7 +188,7 @@ export const actionAlignVerticallyCentered = register({
position: "center", position: "center",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -213,7 +215,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center", position: "center",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, app }) => ( PanelComponent: ({ elements, appState, updateData, app }) => (
+4 -3
View File
@@ -33,6 +33,7 @@ import { AppState } from "../types";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
import { getFontString } from "../utils"; import { getFontString } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
export const actionUnbindText = register({ export const actionUnbindText = register({
name: "unbindText", name: "unbindText",
@@ -80,7 +81,7 @@ export const actionUnbindText = register({
return { return {
elements, elements,
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
}); });
@@ -149,7 +150,7 @@ export const actionBindText = register({
return { return {
elements: pushTextAboveContainer(elements, container, textElement), elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } }, appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
}); });
@@ -299,7 +300,7 @@ export const actionWrapTextInContainer = register({
...appState, ...appState,
selectedElementIds: containerIds, selectedElementIds: containerIds,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
}); });
+25 -14
View File
@@ -1,7 +1,13 @@
import { ColorPicker } from "../components/ColorPicker/ColorPicker"; import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons"; import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element"; import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -22,6 +28,7 @@ import {
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds"; import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor"; import { setCursor } from "../cursor";
import { StoreAction } from "./types";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
@@ -35,7 +42,9 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => { perform: (_, appState, value) => {
return { return {
appState: { ...appState, ...value }, appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor, storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => { PanelComponent: ({ elements, appState, updateData, appProps }) => {
@@ -88,7 +97,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" } ? { ...appState.activeTool, type: "selection" }
: appState.activeTool, : appState.activeTool,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
}); });
@@ -110,16 +119,17 @@ export const actionZoomIn = register({
appState, appState,
), ),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData, appState }) => (
<ToolButton <ToolButton
type="button" type="button"
className="zoom-in-button zoom-button" className="zoom-in-button zoom-button"
icon={ZoomInIcon} icon={ZoomInIcon}
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`} title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")} aria-label={t("buttons.zoomIn")}
disabled={appState.zoom.value >= MAX_ZOOM}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
@@ -147,16 +157,17 @@ export const actionZoomOut = register({
appState, appState,
), ),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData, appState }) => (
<ToolButton <ToolButton
type="button" type="button"
className="zoom-out-button zoom-button" className="zoom-out-button zoom-button"
icon={ZoomOutIcon} icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`} title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")} aria-label={t("buttons.zoomOut")}
disabled={appState.zoom.value <= MIN_ZOOM}
onClick={() => { onClick={() => {
updateData(null); updateData(null);
}} }}
@@ -184,7 +195,7 @@ export const actionResetZoom = register({
appState, appState,
), ),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, appState }) => ( PanelComponent: ({ updateData, appState }) => (
@@ -261,8 +272,8 @@ export const zoomToFit = ({
// Apply clamping to newZoomValue to be between 10% and 3000% // Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min( newZoomValue = Math.min(
Math.max(newZoomValue, 0.1), Math.max(newZoomValue, MIN_ZOOM),
30.0, MAX_ZOOM,
) as NormalizedZoomValue; ) as NormalizedZoomValue;
let appStateWidth = appState.width; let appStateWidth = appState.width;
@@ -307,7 +318,7 @@ export const zoomToFit = ({
scrollY, scrollY,
zoom: { value: newZoomValue }, zoom: { value: newZoomValue },
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}; };
@@ -377,7 +388,7 @@ export const actionToggleTheme = register({
theme: theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@@ -414,7 +425,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null, activeEmbeddable: null,
activeTool, activeTool,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => event.key === KEYS.E, keyTest: (event) => event.key === KEYS.E,
@@ -449,7 +460,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null, activeEmbeddable: null,
activeTool, activeTool,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
+14 -13
View File
@@ -13,6 +13,7 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element"; import { isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants"; import { isFirefox } from "../constants";
import { StoreAction } from "./types";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
@@ -28,7 +29,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event); await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) { } catch (error: any) {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
@@ -37,7 +38,7 @@ export const actionCopy = register({
} }
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
@@ -63,7 +64,7 @@ export const actionPaste = register({
if (isFirefox) { if (isFirefox) {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("hints.firefox_clipboard_write"), errorMessage: t("hints.firefox_clipboard_write"),
@@ -72,7 +73,7 @@ export const actionPaste = register({
} }
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"), errorMessage: t("errors.asyncPasteFailedOnRead"),
@@ -85,7 +86,7 @@ export const actionPaste = register({
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"), errorMessage: t("errors.asyncPasteFailedOnParse"),
@@ -94,7 +95,7 @@ export const actionPaste = register({
} }
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
@@ -119,7 +120,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} }
@@ -141,7 +142,7 @@ export const actionCopyAsSvg = register({
}, },
); );
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -150,7 +151,7 @@ export const actionCopyAsSvg = register({
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} }
}, },
@@ -166,7 +167,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => { perform: async (elements, appState, _data, app) => {
if (!app.canvas) { if (!app.canvas) {
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} }
const selectedElements = app.scene.getSelectedElements({ const selectedElements = app.scene.getSelectedElements({
@@ -199,7 +200,7 @@ export const actionCopyAsPng = register({
}), }),
}, },
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
@@ -208,7 +209,7 @@ export const actionCopyAsPng = register({
...appState, ...appState,
errorMessage: error.message, errorMessage: error.message,
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} }
}, },
@@ -238,7 +239,7 @@ export const copyText = register({
.join("\n\n"); .join("\n\n");
copyTextToSystemClipboard(text); copyTextToSystemClipboard(text);
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
predicate: (elements, appState, _, app) => { predicate: (elements, appState, _, app) => {
+9 -6
View File
@@ -10,9 +10,10 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
import { StoreAction } from "./types";
const deleteSelectedElements = ( const deleteSelectedElements = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@@ -20,7 +21,7 @@ const deleteSelectedElements = (
) => { ) => {
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
elements.filter((el) => el.type === "frame"), elements.filter((el) => isFrameLikeElement(el)),
appState, appState,
).map((el) => el.id), ).map((el) => el.id),
); );
@@ -109,7 +110,7 @@ export const actionDeleteSelected = register({
...nextAppState, ...nextAppState,
editingLinearElement: null, editingLinearElement: null,
}, },
commitToHistory: false, storeAction: StoreAction.UPDATE,
}; };
} }
@@ -141,7 +142,7 @@ export const actionDeleteSelected = register({
: [0], : [0],
}, },
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
} }
let { elements: nextElements, appState: nextAppState } = let { elements: nextElements, appState: nextAppState } =
@@ -161,10 +162,12 @@ export const actionDeleteSelected = register({
multiElement: null, multiElement: null,
activeEmbeddable: null, activeEmbeddable: null,
}, },
commitToHistory: isSomeElementSelected( storeAction: isSomeElementSelected(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
), )
? StoreAction.CAPTURE
: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.delete", contextItemLabel: "labels.delete",
+5 -3
View File
@@ -5,6 +5,7 @@ import {
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -13,13 +14,14 @@ import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types"; import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => { const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
return ( return (
selectedElements.length > 1 && selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly // TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame") !selectedElements.some((el) => isFrameLikeElement(el))
); );
}; };
@@ -52,7 +54,7 @@ export const distributeHorizontally = register({
space: "between", space: "between",
axis: "x", axis: "x",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
@@ -82,7 +84,7 @@ export const distributeVertically = register({
space: "between", space: "between",
axis: "y", axis: "y",
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
+15 -9
View File
@@ -14,13 +14,13 @@ import {
} from "../groups"; } from "../groups";
import { AppState } from "../types"; import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding"; import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types"; import { ActionResult, StoreAction } from "./types";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { import {
@@ -31,6 +31,7 @@ import {
excludeElementsInFramesFromSelection, excludeElementsInFramesFromSelection,
getSelectedElements, getSelectedElements,
} from "../scene/selection"; } from "../scene/selection";
import { fixFractionalIndices } from "../fractionalIndex";
export const actionDuplicateSelection = register({ export const actionDuplicateSelection = register({
name: "duplicateSelection", name: "duplicateSelection",
@@ -47,13 +48,13 @@ export const actionDuplicateSelection = register({
return { return {
elements, elements,
appState: ret.appState, appState: ret.appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
} }
return { return {
...duplicateElements(elements, appState), ...duplicateElements(elements, appState),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.duplicateSelection", contextItemLabel: "labels.duplicateSelection",
@@ -85,6 +86,7 @@ const duplicateElements = (
const newElements: ExcalidrawElement[] = []; const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = []; const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map(); const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => { const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement( const newElement = duplicateElement(
@@ -96,6 +98,7 @@ const duplicateElements = (
y: element.y + GRID_SIZE / 2, y: element.y + GRID_SIZE / 2,
}, },
); );
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id); oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element); oldElements.push(element);
newElements.push(newElement); newElements.push(newElement);
@@ -140,11 +143,11 @@ const duplicateElements = (
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
const isElementAFrame = isFrameElement(element); const isElementAFrameLike = isFrameLikeElement(element);
if (idsOfElementsToDuplicate.get(element.id)) { if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically // if a group or a container/bound-text or frame, duplicate atomically
if (element.groupIds.length || boundTextElement || isElementAFrame) { if (element.groupIds.length || boundTextElement || isElementAFrameLike) {
const groupId = getSelectedGroupForElement(appState, element); const groupId = getSelectedGroupForElement(appState, element);
if (groupId) { if (groupId) {
// TODO: // TODO:
@@ -154,7 +157,7 @@ const duplicateElements = (
sortedElements, sortedElements,
groupId, groupId,
).flatMap((element) => ).flatMap((element) =>
isFrameElement(element) isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element] ? [...getFrameChildren(elements, element.id), element]
: [element], : [element],
); );
@@ -180,7 +183,7 @@ const duplicateElements = (
); );
continue; continue;
} }
if (isElementAFrame) { if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id); const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push( elementsWithClones.push(
@@ -234,7 +237,10 @@ const duplicateElements = (
// step (3) // step (3)
const finalElements = finalElementsReversed.reverse(); const finalElements = fixFractionalIndices(
finalElementsReversed.reverse(),
duplicatedElementsMap,
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+5 -3
View File
@@ -1,8 +1,10 @@
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
const shouldLock = (elements: readonly ExcalidrawElement[]) => const shouldLock = (elements: readonly ExcalidrawElement[]) =>
elements.every((el) => !el.locked); elements.every((el) => !el.locked);
@@ -43,7 +45,7 @@ export const actionToggleElementLock = register({
? null ? null
: appState.selectedLinearElement, : appState.selectedLinearElement,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: (elements, appState, app) => { contextItemLabel: (elements, appState, app) => {
@@ -51,7 +53,7 @@ export const actionToggleElementLock = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
if (selected.length === 1 && selected[0].type !== "frame") { if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked return selected[0].locked
? "labels.elementLock.unlock" ? "labels.elementLock.unlock"
: "labels.elementLock.lock"; : "labels.elementLock.lock";
@@ -97,7 +99,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]), lockedElements.map((el) => [el.id, true]),
), ),
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.elementLock.unlockAll", contextItemLabel: "labels.elementLock.unlockAll",
+15 -11
View File
@@ -19,12 +19,16 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types"; import { Theme } from "../element/types";
import "../components/ToolIcon.scss"; import "../components/ToolIcon.scss";
import { StoreAction } from "./types";
export const actionChangeProjectName = register({ export const actionChangeProjectName = register({
name: "changeProjectName", name: "changeProjectName",
trackEvent: false, trackEvent: false,
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { appState: { ...appState, name: value }, commitToHistory: false }; return {
appState: { ...appState, name: value },
storeAction: StoreAction.UPDATE,
};
}, },
PanelComponent: ({ appState, updateData, appProps, data }) => ( PanelComponent: ({ appState, updateData, appProps, data }) => (
<ProjectName <ProjectName
@@ -45,7 +49,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportScale: value }, appState: { ...appState, exportScale: value },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ elements: allElements, appState, updateData }) => { PanelComponent: ({ elements: allElements, appState, updateData }) => {
@@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportBackground: value }, appState: { ...appState, exportBackground: value },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
@@ -113,7 +117,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportEmbedScene: value }, appState: { ...appState, exportEmbedScene: value },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
@@ -148,7 +152,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files); : await saveAsJSON(elements, appState, app.files);
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
fileHandle, fileHandle,
@@ -170,7 +174,7 @@ export const actionSaveToActiveFile = register({
} else { } else {
console.warn(error); console.warn(error);
} }
return { commitToHistory: false }; return { storeAction: StoreAction.NONE };
} }
}, },
keyTest: (event) => keyTest: (event) =>
@@ -192,7 +196,7 @@ export const actionSaveFileToDisk = register({
app.files, app.files,
); );
return { return {
commitToHistory: false, storeAction: StoreAction.NONE,
appState: { appState: {
...appState, ...appState,
openDialog: null, openDialog: null,
@@ -206,7 +210,7 @@ export const actionSaveFileToDisk = register({
} else { } else {
console.warn(error); console.warn(error);
} }
return { commitToHistory: false }; return { storeAction: StoreAction.NONE };
} }
}, },
keyTest: (event) => keyTest: (event) =>
@@ -244,7 +248,7 @@ export const actionLoadScene = register({
elements: loadedElements, elements: loadedElements,
appState: loadedAppState, appState: loadedAppState,
files, files,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
} catch (error: any) { } catch (error: any) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
@@ -255,7 +259,7 @@ export const actionLoadScene = register({
elements, elements,
appState: { ...appState, errorMessage: error.message }, appState: { ...appState, errorMessage: error.message },
files: app.files, files: app.files,
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
} }
}, },
@@ -268,7 +272,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
return { return {
appState: { ...appState, exportWithDarkMode: value }, appState: { ...appState, exportWithDarkMode: value },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
+6 -2
View File
@@ -16,6 +16,7 @@ import {
import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types"; import { AppState } from "../types";
import { resetCursor } from "../cursor"; import { resetCursor } from "../cursor";
import { StoreAction } from "./types";
export const actionFinalize = register({ export const actionFinalize = register({
name: "finalize", name: "finalize",
@@ -49,7 +50,7 @@ export const actionFinalize = register({
cursorButton: "up", cursorButton: "up",
editingLinearElement: null, editingLinearElement: null,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
} }
} }
@@ -190,7 +191,10 @@ export const actionFinalize = register({
: appState.selectedLinearElement, : appState.selectedLinearElement,
pendingImageElementId: null, pendingImageElementId: null,
}, },
commitToHistory: appState.activeTool.type === "freedraw", storeAction:
appState.activeTool.type === "freedraw"
? StoreAction.CAPTURE
: StoreAction.UPDATE,
}; };
}, },
keyTest: (event, appState) => keyTest: (event, appState) =>
+3 -2
View File
@@ -13,6 +13,7 @@ import {
unbindLinearElements, unbindLinearElements,
} from "../element/binding"; } from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { StoreAction } from "./types";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -25,7 +26,7 @@ export const actionFlipHorizontal = register({
app, app,
), ),
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => event.shiftKey && event.code === CODES.H, keyTest: (event) => event.shiftKey && event.code === CODES.H,
@@ -43,7 +44,7 @@ export const actionFlipVertical = register({
app, app,
), ),
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
+24 -14
View File
@@ -7,23 +7,28 @@ import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { register } from "./register"; import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { StoreAction } from "./types";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState); const selectedElements = app.scene.getSelectedElements(appState);
return selectedElements.length === 1 && selectedElements[0].type === "frame"; return (
selectedElements.length === 1 && isFrameLikeElement(selectedElements[0])
);
}; };
export const actionSelectAllElementsInFrame = register({ export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame", name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0]; const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
if (selectedFrame && selectedFrame.type === "frame") { if (isFrameLikeElement(selectedElement)) {
const elementsInFrame = getFrameChildren( const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements), getNonDeletedElements(elements),
selectedFrame.id, selectedElement.id,
).filter((element) => !(element.type === "text" && element.containerId)); ).filter((element) => !(element.type === "text" && element.containerId));
return { return {
@@ -35,14 +40,14 @@ export const actionSelectAllElementsInFrame = register({
return acc; return acc;
}, {} as Record<ExcalidrawElement["id"], true>), }, {} as Record<ExcalidrawElement["id"], true>),
}, },
commitToHistory: false, storeAction: StoreAction.CAPTURE,
}; };
} }
return { return {
elements, elements,
appState, appState,
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.selectAllElementsInFrame", contextItemLabel: "labels.selectAllElementsInFrame",
@@ -54,25 +59,30 @@ export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame", name: "removeAllElementsFromFrame",
trackEvent: { category: "history" }, trackEvent: { category: "history" },
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const selectedFrame = app.scene.getSelectedElements(appState)[0]; const selectedElement =
app.scene.getSelectedElements(appState).at(0) || null;
if (selectedFrame && selectedFrame.type === "frame") { if (isFrameLikeElement(selectedElement)) {
return { return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState), elements: removeAllElementsFromFrame(
elements,
selectedElement,
appState,
),
appState: { appState: {
...appState, ...appState,
selectedElementIds: { selectedElementIds: {
[selectedFrame.id]: true, [selectedElement.id]: true,
}, },
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
} }
return { return {
elements, elements,
appState, appState,
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.removeAllElementsFromFrame", contextItemLabel: "labels.removeAllElementsFromFrame",
@@ -94,7 +104,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled, enabled: !appState.frameRendering.enabled,
}, },
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.updateFrameRendering", contextItemLabel: "labels.updateFrameRendering",
@@ -122,7 +132,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame", type: "frame",
}), }),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
+10 -9
View File
@@ -22,11 +22,12 @@ import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
getElementsInResizingFrame, getElementsInResizingFrame,
getFrameElements, getFrameLikeElements,
groupByFrames, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "../frame"; } from "../frame";
import { StoreAction } from "./types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) { if (elements.length >= 2) {
@@ -69,7 +70,7 @@ export const actionGroup = register({
}); });
if (selectedElements.length < 2) { if (selectedElements.length < 2) {
// nothing to group // nothing to group
return { appState, elements, commitToHistory: false }; return { appState, elements, storeAction: StoreAction.NONE };
} }
// if everything is already grouped into 1 group, there is nothing to do // if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState); const selectedGroupIds = getSelectedGroupIds(appState);
@@ -89,7 +90,7 @@ export const actionGroup = register({
]); ]);
if (combinedSet.size === elementIdsInGroup.size) { if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids // no incremental ids in the selected ids
return { appState, elements, commitToHistory: false }; return { appState, elements, storeAction: StoreAction.NONE };
} }
} }
@@ -102,7 +103,7 @@ export const actionGroup = register({
// when it happens, we want to remove elements that are in the frame // when it happens, we want to remove elements that are in the frame
// and are going to be grouped from the frame (mouthful, I know) // and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) { if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrames(selectedElements); const frameElementsMap = groupByFrameLikes(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => { frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame( nextElements = removeElementsFromFrame(
@@ -155,7 +156,7 @@ export const actionGroup = register({
), ),
}, },
elements: nextElements, elements: nextElements,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.group", contextItemLabel: "labels.group",
@@ -182,7 +183,7 @@ export const actionUngroup = register({
perform: (elements, appState, _, app) => { perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState); const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) { if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false }; return { appState, elements, storeAction: StoreAction.NONE, };
} }
let nextElements = [...elements]; let nextElements = [...elements];
@@ -219,7 +220,7 @@ export const actionUngroup = register({
.map((element) => element.frameId!), .map((element) => element.frameId!),
); );
const targetFrames = getFrameElements(elements).filter((frame) => const targetFrames = getFrameLikeElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id), selectedElementFrameIds.has(frame.id),
); );
@@ -250,7 +251,7 @@ export const actionUngroup = register({
return { return {
appState: { ...appState, ...updateAppState }, appState: { ...appState, ...updateAppState },
elements: nextElements, elements: nextElements,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
keyTest: (event) => keyTest: (event) =>
+29 -40
View File
@@ -1,62 +1,51 @@
import { Action, ActionResult } from "./types"; import { Action, ActionResult, StoreAction } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons"; import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n"; import { t } from "../i18n";
import History, { HistoryEntry } from "../history"; import { History } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { isWindows } from "../constants"; import { isWindows } from "../constants";
import { ExcalidrawElement } from "../element/types";
import { fixBindingsAfterDeletion } from "../element/binding";
import { orderByFractionalIndex } from "../fractionalIndex";
const writeData = ( const writeData = (
prevElements: readonly ExcalidrawElement[], appState: Readonly<AppState>,
appState: AppState, updater: () => [Map<string, ExcalidrawElement>, AppState] | void,
updater: () => HistoryEntry | null,
): ActionResult => { ): ActionResult => {
const commitToHistory = false;
if ( if (
!appState.multiElement && !appState.multiElement &&
!appState.resizingElement && !appState.resizingElement &&
!appState.editingElement && !appState.editingElement &&
!appState.draggingElement !appState.draggingElement
) { ) {
const data = updater(); const result = updater();
if (data === null) {
return { commitToHistory }; if (!result) {
return { storeAction: StoreAction.NONE };
} }
const prevElementMap = arrayToMap(prevElements); // TODO_UNDO: worth detecting z-index deltas or do we just order based on fractional indices?
const nextElements = data.elements; const [nextElementsMap, nextAppState] = result;
const nextElementMap = arrayToMap(nextElements); const nextElements = orderByFractionalIndex(
Array.from(nextElementsMap.values()),
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
); );
const elements = nextElements
.map((nextElement) => // TODO_UNDO: these are all deleted elements, but ideally we should get just those that were delted at this moment
newElementWith( const deletedElements = nextElements.filter((element) => element.isDeleted);
prevElementMap.get(nextElement.id) || nextElement, // TODO_UNDO: this doesn't really work for bound text
nextElement, fixBindingsAfterDeletion(nextElements, deletedElements);
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
return { return {
elements, appState: nextAppState,
appState: { ...appState, ...data.appState }, elements: nextElements,
commitToHistory, storeAction: StoreAction.UPDATE,
syncHistory: true,
}; };
} }
return { commitToHistory };
return { storeAction: StoreAction.NONE };
}; };
type ActionCreator = (history: History) => Action; type ActionCreator = (history: History) => Action;
@@ -65,7 +54,7 @@ export const createUndoAction: ActionCreator = (history) => ({
name: "undo", name: "undo",
trackEvent: { category: "history" }, trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()), writeData(appState, () => history.undo(arrayToMap(elements), appState)),
keyTest: (event) => keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z && event.key.toLowerCase() === KEYS.Z &&
@@ -77,16 +66,16 @@ export const createUndoAction: ActionCreator = (history) => ({
aria-label={t("buttons.undo")} aria-label={t("buttons.undo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={history.isUndoStackEmpty}
/> />
), ),
commitToHistory: () => false,
}); });
export const createRedoAction: ActionCreator = (history) => ({ export const createRedoAction: ActionCreator = (history) => ({
name: "redo", name: "redo",
trackEvent: { category: "history" }, trackEvent: { category: "history" },
perform: (elements, appState) => perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()), writeData(appState, () => history.redo(arrayToMap(elements), appState)),
keyTest: (event) => keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] && (event[KEYS.CTRL_OR_CMD] &&
event.shiftKey && event.shiftKey &&
@@ -99,7 +88,7 @@ export const createRedoAction: ActionCreator = (history) => ({
aria-label={t("buttons.redo")} aria-label={t("buttons.redo")}
onClick={updateData} onClick={updateData}
size={data?.size || "medium"} size={data?.size || "medium"}
disabled={history.isRedoStackEmpty}
/> />
), ),
commitToHistory: () => false,
}); });
+2 -1
View File
@@ -2,6 +2,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types"; import { ExcalidrawLinearElement } from "../element/types";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleLinearEditor = register({ export const actionToggleLinearEditor = register({
name: "toggleLinearEditor", name: "toggleLinearEditor",
@@ -30,7 +31,7 @@ export const actionToggleLinearEditor = register({
...appState, ...appState,
editingLinearElement, editingLinearElement,
}, },
commitToHistory: false, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: (elements, appState, app) => { contextItemLabel: (elements, appState, app) => {
+4 -3
View File
@@ -4,6 +4,7 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register"; import { register } from "./register";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleCanvasMenu = register({ export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu", name: "toggleCanvasMenu",
@@ -13,7 +14,7 @@ export const actionToggleCanvasMenu = register({
...appState, ...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas", openMenu: appState.openMenu === "canvas" ? null : "canvas",
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}), }),
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<ToolButton <ToolButton
@@ -34,7 +35,7 @@ export const actionToggleEditMenu = register({
...appState, ...appState,
openMenu: appState.openMenu === "shape" ? null : "shape", openMenu: appState.openMenu === "shape" ? null : "shape",
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}), }),
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton <ToolButton
@@ -64,7 +65,7 @@ export const actionShortcuts = register({
...appState, ...appState,
openDialog: appState.openDialog === "help" ? null : "help", openDialog: appState.openDialog === "help" ? null : "help",
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
keyTest: (event) => event.key === KEYS.QUESTION_MARK, keyTest: (event) => event.key === KEYS.QUESTION_MARK,
+3 -2
View File
@@ -3,6 +3,7 @@ import { Avatar } from "../components/Avatar";
import { centerScrollOn } from "../scene/scroll"; import { centerScrollOn } from "../scene/scroll";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
@@ -11,7 +12,7 @@ export const actionGoToCollaborator = register({
perform: (_elements, appState, value) => { perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"]; const point = value as Collaborator["pointer"];
if (!point) { if (!point) {
return { appState, commitToHistory: false }; return { appState, storeAction: StoreAction.NONE };
} }
return { return {
@@ -28,7 +29,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu // Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ updateData, data }) => { PanelComponent: ({ updateData, data }) => {
+18 -13
View File
@@ -92,6 +92,7 @@ import {
import { hasStrokeColor } from "../scene/comparisons"; import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils"; import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -222,7 +223,7 @@ const changeFontSize = (
? [...newFontSizes][0] ? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize, : fallbackValue ?? appState.currentItemFontSize,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}; };
@@ -251,7 +252,9 @@ export const actionChangeStrokeColor = register({
...appState, ...appState,
...value, ...value,
}, },
commitToHistory: !!value.currentItemStrokeColor, storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => ( PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -294,7 +297,9 @@ export const actionChangeBackgroundColor = register({
...appState, ...appState,
...value, ...value,
}, },
commitToHistory: !!value.currentItemBackgroundColor, storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData, appProps }) => ( PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -337,7 +342,7 @@ export const actionChangeFillStyle = register({
}), }),
), ),
appState: { ...appState, currentItemFillStyle: value }, appState: { ...appState, currentItemFillStyle: value },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@@ -409,7 +414,7 @@ export const actionChangeStrokeWidth = register({
}), }),
), ),
appState: { ...appState, currentItemStrokeWidth: value }, appState: { ...appState, currentItemStrokeWidth: value },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -463,7 +468,7 @@ export const actionChangeSloppiness = register({
}), }),
), ),
appState: { ...appState, currentItemRoughness: value }, appState: { ...appState, currentItemRoughness: value },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -513,7 +518,7 @@ export const actionChangeStrokeStyle = register({
}), }),
), ),
appState: { ...appState, currentItemStrokeStyle: value }, appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -567,7 +572,7 @@ export const actionChangeOpacity = register({
true, true,
), ),
appState: { ...appState, currentItemOpacity: value }, appState: { ...appState, currentItemOpacity: value },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
@@ -725,7 +730,7 @@ export const actionChangeFontFamily = register({
...appState, ...appState,
currentItemFontFamily: value, currentItemFontFamily: value,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@@ -814,7 +819,7 @@ export const actionChangeTextAlign = register({
...appState, ...appState,
currentItemTextAlign: value, currentItemTextAlign: value,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@@ -894,7 +899,7 @@ export const actionChangeVerticalAlign = register({
appState: { appState: {
...appState, ...appState,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@@ -967,7 +972,7 @@ export const actionChangeRoundness = register({
...appState, ...appState,
currentItemRoundness: value, currentItemRoundness: value,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
@@ -1047,7 +1052,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead" ? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type, : "currentItemEndArrowhead"]: value.type,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => { PanelComponent: ({ elements, appState, updateData }) => {
+2 -1
View File
@@ -6,6 +6,7 @@ import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks"; import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection"; import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { StoreAction } from "./types";
export const actionSelectAll = register({ export const actionSelectAll = register({
name: "selectAll", name: "selectAll",
@@ -46,7 +47,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0], app.scene) ? new LinearElementEditor(elements[0], app.scene)
: null, : null,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.selectAll", contextItemLabel: "labels.selectAll",
+6 -5
View File
@@ -20,11 +20,12 @@ import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement, getDefaultRoundnessTypeForElement,
isFrameElement, isFrameLikeElement,
isArrowElement, isArrowElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types"; import { ExcalidrawTextElement } from "../element/types";
import { StoreAction } from "./types";
// `copiedStyles` is exported only for tests. // `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}"; export let copiedStyles: string = "{}";
@@ -48,7 +49,7 @@ export const actionCopyStyles = register({
...appState, ...appState,
toast: { message: t("toast.copyStyles") }, toast: { message: t("toast.copyStyles") },
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
contextItemLabel: "labels.copyStyles", contextItemLabel: "labels.copyStyles",
@@ -64,7 +65,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0]; const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1]; const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) { if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false }; return { elements, storeAction: StoreAction.NONE };
} }
const selectedElements = getSelectedElements(elements, appState, { const selectedElements = getSelectedElements(elements, appState, {
@@ -138,7 +139,7 @@ export const actionPasteStyles = register({
}); });
} }
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
newElement = newElementWith(newElement, { newElement = newElementWith(newElement, {
roundness: null, roundness: null,
backgroundColor: "transparent", backgroundColor: "transparent",
@@ -149,7 +150,7 @@ export const actionPasteStyles = register({
} }
return element; return element;
}), }),
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.pasteStyles", contextItemLabel: "labels.pasteStyles",
+2 -1
View File
@@ -2,6 +2,7 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { GRID_SIZE } from "../constants"; import { GRID_SIZE } from "../constants";
import { AppState } from "../types"; import { AppState } from "../types";
import { StoreAction } from "./types";
export const actionToggleGridMode = register({ export const actionToggleGridMode = register({
name: "gridMode", name: "gridMode",
@@ -17,7 +18,7 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE, gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false, objectsSnapModeEnabled: false,
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState: AppState) => appState.gridSize !== null, checked: (appState: AppState) => appState.gridSize !== null,
+2 -1
View File
@@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleObjectsSnapMode = register({ export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode", name: "objectsSnapMode",
@@ -15,7 +16,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState), objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null, gridSize: null,
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState) => appState.objectsSnapModeEnabled, checked: (appState) => appState.objectsSnapModeEnabled,
+2 -1
View File
@@ -1,5 +1,6 @@
import { register } from "./register"; import { register } from "./register";
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleStats = register({ export const actionToggleStats = register({
name: "stats", name: "stats",
@@ -11,7 +12,7 @@ export const actionToggleStats = register({
...appState, ...appState,
showStats: !this.checked!(appState), showStats: !this.checked!(appState),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState) => appState.showStats, checked: (appState) => appState.showStats,
+2 -1
View File
@@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleViewMode = register({ export const actionToggleViewMode = register({
name: "viewMode", name: "viewMode",
@@ -14,7 +15,7 @@ export const actionToggleViewMode = register({
...appState, ...appState,
viewModeEnabled: !this.checked!(appState), viewModeEnabled: !this.checked!(appState),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState) => appState.viewModeEnabled, checked: (appState) => appState.viewModeEnabled,
+2 -1
View File
@@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys"; import { CODES, KEYS } from "../keys";
import { register } from "./register"; import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleZenMode = register({ export const actionToggleZenMode = register({
name: "zenMode", name: "zenMode",
@@ -14,7 +15,7 @@ export const actionToggleZenMode = register({
...appState, ...appState,
zenModeEnabled: !this.checked!(appState), zenModeEnabled: !this.checked!(appState),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
checked: (appState) => appState.zenModeEnabled, checked: (appState) => appState.zenModeEnabled,
+5 -5
View File
@@ -1,4 +1,3 @@
import React from "react";
import { import {
moveOneLeft, moveOneLeft,
moveOneRight, moveOneRight,
@@ -16,6 +15,7 @@ import {
SendToBackIcon, SendToBackIcon,
} from "../components/icons"; } from "../components/icons";
import { isDarwin } from "../constants"; import { isDarwin } from "../constants";
import { StoreAction } from "./types";
export const actionSendBackward = register({ export const actionSendBackward = register({
name: "sendBackward", name: "sendBackward",
@@ -24,7 +24,7 @@ export const actionSendBackward = register({
return { return {
elements: moveOneLeft(elements, appState), elements: moveOneLeft(elements, appState),
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.sendBackward", contextItemLabel: "labels.sendBackward",
@@ -52,7 +52,7 @@ export const actionBringForward = register({
return { return {
elements: moveOneRight(elements, appState), elements: moveOneRight(elements, appState),
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.bringForward", contextItemLabel: "labels.bringForward",
@@ -80,7 +80,7 @@ export const actionSendToBack = register({
return { return {
elements: moveAllLeft(elements, appState), elements: moveAllLeft(elements, appState),
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.sendToBack", contextItemLabel: "labels.sendToBack",
@@ -116,7 +116,7 @@ export const actionBringToFront = register({
return { return {
elements: moveAllRight(elements, appState), elements: moveAllRight(elements, appState),
appState, appState,
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
contextItemLabel: "labels.bringToFront", contextItemLabel: "labels.bringToFront",
+7 -2
View File
@@ -10,6 +10,12 @@ import { MarkOptional } from "../utility-types";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api"; export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
export enum StoreAction {
NONE = "none",
UPDATE = "update", // TODO_UNDO: think about better naming as this one is confusing
CAPTURE = "capture",
}
/** if false, the action should be prevented */ /** if false, the action should be prevented */
export type ActionResult = export type ActionResult =
| { | {
@@ -19,8 +25,7 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height"
> | null; > | null;
files?: BinaryFiles | null; files?: BinaryFiles | null;
commitToHistory: boolean; storeAction: StoreAction;
syncHistory?: boolean;
replaceFiles?: boolean; replaceFiles?: boolean;
} }
| false; | false;
+567
View File
@@ -0,0 +1,567 @@
import { newElementWith } from "./element/mutateElement";
import { ExcalidrawElement } from "./element/types";
import {
AppState,
ObservedAppState,
ObservedElementsAppState,
ObservedStandaloneAppState,
} from "./types";
import { SubtypeOf } from "./utility-types";
import { isShallowEqual } from "./utils";
/**
* Represents the difference between two `T` objects.
*
* Keeping it as pure object (without transient state, side-effects, etc.), so we don't have to instantiate it on load.
*/
class Delta<T> {
private constructor(
public readonly from: Partial<T>,
public readonly to: Partial<T>,
) {}
public static create<T>(
from: Partial<T>,
to: Partial<T>,
modifier?: (delta: Partial<T>) => Partial<T>,
modifierOptions?: "from" | "to",
) {
const modifiedFrom =
modifier && modifierOptions !== "to" ? modifier(from) : from;
const modifiedTo =
modifier && modifierOptions !== "from" ? modifier(to) : to;
return new Delta(modifiedFrom, modifiedTo);
}
/**
* Calculates the delta between two objects.
*
* @param prevObject - The previous state of the object.
* @param nextObject - The next state of the object.
*
* @returns new Delta instance.
*/
public static calculate<T extends Object>(
prevObject: T,
nextObject: T,
modifier?: (delta: Partial<T>) => Partial<T>,
): Delta<T> {
if (prevObject === nextObject) {
return Delta.empty();
}
const from = {} as Partial<T>;
const to = {} as Partial<T>;
const unionOfKeys = new Set([
...Object.keys(prevObject),
...Object.keys(nextObject),
]);
for (const key of unionOfKeys) {
const prevValue = prevObject[key as keyof T];
const nextValue = nextObject[key as keyof T];
if (prevValue !== nextValue) {
from[key as keyof T] = prevValue;
to[key as keyof T] = nextValue;
}
}
return Delta.create(from, to, modifier);
}
public static empty() {
return new Delta({}, {});
}
public static isEmpty<T>(delta: Delta<T>): boolean {
return !Object.keys(delta.from).length && !Object.keys(delta.to).length;
}
/**
* Compares if the delta contains any different values compared to the object.
*
* WARN: it's based on shallow compare performed only on the first level, won't work for objects with deeper props.
*/
public static containsDifference<T>(delta: Partial<T>, object: T): boolean {
const anyDistinctKey = this.distinctKeysIterator(delta, object).next()
.value;
return !!anyDistinctKey;
}
/**
* Returns all the keys that have distinct values.
*
* WARN: it's based on shallow compare performed only on the first level, won't work for objects with deeper props.
*/
public static gatherDifferences<T>(delta: Partial<T>, object: T) {
const distinctKeys = new Set<string>();
for (const key of this.distinctKeysIterator(delta, object)) {
distinctKeys.add(key);
}
return Array.from(distinctKeys);
}
private static *distinctKeysIterator<T>(delta: Partial<T>, object: T) {
for (const [key, deltaValue] of Object.entries(delta)) {
const objectValue = object[key as keyof T];
if (deltaValue !== objectValue) {
// TODO_UNDO: staticly fail (typecheck) on deeper objects?
if (
typeof deltaValue === "object" &&
typeof objectValue === "object" &&
deltaValue !== null &&
objectValue !== null &&
isShallowEqual(
deltaValue as Record<string, any>,
objectValue as Record<string, any>,
)
) {
continue;
}
yield key;
}
}
}
}
/**
* Encapsulates the modifications captured as `Delta`/s.
*/
interface Change<T> {
/**
* Inverses the `Delta`s inside while creating a new `Change`.
*/
inverse(): Change<T>;
/**
* Applies the `Change` to the previous object.
*
* @returns new object instance and boolean, indicating if there was any visible change made.
*/
applyTo(previous: Readonly<T>, ...options: unknown[]): [T, boolean];
/**
* Checks whether there are actually `Delta`s.
*/
isEmpty(): boolean;
}
export class AppStateChange implements Change<AppState> {
private constructor(private readonly delta: Delta<ObservedAppState>) {}
public static calculate<T extends Partial<ObservedAppState>>(
prevAppState: T,
nextAppState: T,
): AppStateChange {
const delta = Delta.calculate(prevAppState, nextAppState);
return new AppStateChange(delta);
}
public static empty() {
return new AppStateChange(Delta.create({}, {}));
}
public inverse(): AppStateChange {
const inversedDelta = Delta.create(this.delta.to, this.delta.from);
return new AppStateChange(inversedDelta);
}
public applyTo(
appState: Readonly<AppState>,
elements: Readonly<Map<string, ExcalidrawElement>>,
): [AppState, boolean] {
const constainsVisibleChanges = this.checkForVisibleChanges(
appState,
elements,
);
const newAppState = {
...appState,
...this.delta.to, // TODO_UNDO: probably shouldn't apply element related changes
};
return [newAppState, constainsVisibleChanges];
}
public isEmpty(): boolean {
return Delta.isEmpty(this.delta);
}
private checkForVisibleChanges(
appState: ObservedAppState,
elements: Map<string, ExcalidrawElement>,
): boolean {
const containsStandaloneDifference = Delta.containsDifference(
AppStateChange.stripElementsProps(this.delta.to),
appState,
);
if (containsStandaloneDifference) {
// We detected a a difference which is unrelated to the elements
return true;
}
const containsElementsDifference = Delta.containsDifference(
AppStateChange.stripStandaloneProps(this.delta.to),
appState,
);
if (!containsStandaloneDifference && !containsElementsDifference) {
// There is no difference detected at all
return false;
}
// We need to handle elements differences separately,
// as they could be related to deleted elements and/or they could on their own result in no visible action
const changedDeltaKeys = Delta.gatherDifferences(
AppStateChange.stripStandaloneProps(this.delta.to),
appState,
) as Array<keyof ObservedElementsAppState>;
// Check whether delta properties are related to the existing non-deleted elements
for (const key of changedDeltaKeys) {
switch (key) {
case "selectedElementIds":
if (
AppStateChange.checkForSelectedElementsDifferences(
this.delta.to[key],
appState,
elements,
)
) {
return true;
}
break;
case "selectedLinearElement":
case "editingLinearElement":
if (
AppStateChange.checkForLinearElementDifferences(
this.delta.to[key],
elements,
)
) {
return true;
}
break;
case "editingGroupId":
case "selectedGroupIds":
return AppStateChange.checkForGroupsDifferences();
default: {
// WARN: this exhaustive check in the switch statement is here to catch unexpected future changes
// TODO_UNDO: use assertNever
const exhaustiveCheck: never = key;
throw new Error(
`Unknown ObservedElementsAppState key '${exhaustiveCheck}'.`,
);
}
}
}
return false;
}
private static checkForSelectedElementsDifferences(
deltaIds: ObservedElementsAppState["selectedElementIds"] | undefined,
appState: Pick<AppState, "selectedElementIds">,
elements: Map<string, ExcalidrawElement>,
) {
if (!deltaIds) {
// There are no selectedElementIds in the delta
return;
}
// TODO_UNDO: it could have been visible before (and now it's not)
// TODO_UNDO: it could have been selected
for (const id of Object.keys(deltaIds)) {
const element = elements.get(id);
if (element && !element.isDeleted) {
// // TODO_UNDO: breaks multi selection
// if (appState.selectedElementIds[id]) {
// // Element is already selected
// return;
// }
// Found related visible element!
return true;
}
}
}
private static checkForLinearElementDifferences(
linearElement:
| ObservedElementsAppState["editingLinearElement"]
| ObservedAppState["selectedLinearElement"]
| undefined,
elements: Map<string, ExcalidrawElement>,
) {
if (!linearElement) {
return;
}
const element = elements.get(linearElement.elementId);
if (element && !element.isDeleted) {
// Found related visible element!
return true;
}
}
// Currently we don't have an index of elements by groupIds, which means
// the calculation for getting the visible elements based on the groupIds stored in delta
// is not worth performing - due to perf. and dev. complexity.
//
// Therefore we are accepting in these cases empty undos / redos, which should be pretty rare:
// - only when one of these (or both) are in delta and the are no non deleted elements containing these group ids
private static checkForGroupsDifferences() {
return true;
}
private static stripElementsProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedStandaloneAppState> {
// WARN: Do not remove the type-casts as they here for exhaustive type checks
const {
editingGroupId,
selectedGroupIds,
selectedElementIds,
editingLinearElement,
selectedLinearElement,
...standaloneProps
} = delta as ObservedAppState;
return standaloneProps as SubtypeOf<
typeof standaloneProps,
ObservedStandaloneAppState
>;
}
private static stripStandaloneProps(
delta: Partial<ObservedAppState>,
): Partial<ObservedElementsAppState> {
// WARN: Do not remove the type-casts as they here for exhaustive type checks
const { name, viewBackgroundColor, ...elementsProps } =
delta as ObservedAppState;
return elementsProps as SubtypeOf<
typeof elementsProps,
ObservedElementsAppState
>;
}
}
/**
* Elements change is a low level primitive to capture a change between two sets of elements.
* It does so by encapsulating forward and backward `Delta`s, which allow to travel in both directions.
*
* We could be smarter about the change in the future, ideas for improvements are:
* - for memory, share the same delta instances between different deltas (flyweight-like)
* - for serialization, compress the deltas into a tree-like structures with custom pointers or let one delta instance contain multiple element ids
* - for performance, emit the changes directly by the user actions, then apply them in from store into the state (no diffing!)
* - for performance, add operations in addition to deltas, which increment (decrement) properties by given value (could be used i.e. for presence-like move)
*/
export class ElementsChange implements Change<Map<string, ExcalidrawElement>> {
private constructor(
// TODO_UNDO: re-think the possible need for added/ remove/ updated deltas (possibly for handling edge cases with deletion, fixing bindings for deletion, showing changes added/modified/updated for version end etc.)
private readonly deltas: Map<string, Delta<ExcalidrawElement>>,
) {}
public static create(deltas: Map<string, Delta<ExcalidrawElement>>) {
return new ElementsChange(deltas);
}
/**
* Calculates the `Delta`s between the previous and next set of elements.
*
* @param prevElements - Map representing the previous state of elements.
* @param nextElements - Map representing the next state of elements.
*
* @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements.
*/
public static calculate<T extends ExcalidrawElement>(
prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, ExcalidrawElement>,
): ElementsChange {
if (prevElements === nextElements) {
return ElementsChange.empty();
}
const deltas = new Map<string, Delta<T>>();
// This might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed
for (const prevElement of prevElements.values()) {
const nextElement = nextElements.get(prevElement.id);
// Element got removed
if (!nextElement) {
const from = { ...prevElement, isDeleted: false } as T;
const to = { isDeleted: true } as T;
const delta = Delta.create(
from,
to,
ElementsChange.stripIrrelevantProps,
);
deltas.set(prevElement.id, delta as Delta<T>);
}
}
for (const nextElement of nextElements.values()) {
const prevElement = prevElements.get(nextElement.id);
// Element got added
if (!prevElement) {
if (nextElement.isDeleted) {
// Special case when an element is added as deleted (i.e. through the API).
// Creating a delta for it wouldn't make sense, as it would go from isDeleted `true` into `true` again.
// We are going to skip it for now, later we could be have separate `added` & `removed` entries in the elements change,
// so that we would distinguish between actual addition, removal and "soft" (un)deletion.
continue;
}
const from = { isDeleted: true } as T;
const to = { ...nextElement, isDeleted: false } as T;
const delta = Delta.create(
from,
to,
ElementsChange.stripIrrelevantProps,
);
deltas.set(nextElement.id, delta as Delta<T>);
continue;
}
// Element got updated
if (prevElement.versionNonce !== nextElement.versionNonce) {
// O(n^2) here, but it's not as bad as it looks:
// - we do this only on history recordings, not on every frame
// - we do this only on changed elements
// - # of element's properties is reasonably small
// - otherwise we would have to emit deltas on user actions & apply them on every frame
const delta = Delta.calculate<ExcalidrawElement>(
prevElement,
nextElement,
ElementsChange.stripIrrelevantProps,
);
// Make sure there are at least some changes (except changes to irrelevant data)
if (!Delta.isEmpty(delta)) {
deltas.set(nextElement.id, delta as Delta<T>);
}
}
}
return new ElementsChange(deltas);
}
public static empty() {
return new ElementsChange(new Map());
}
public inverse(): ElementsChange {
const deltas = new Map<string, Delta<ExcalidrawElement>>();
for (const [id, delta] of this.deltas.entries()) {
deltas.set(id, Delta.create(delta.to, delta.from));
}
return new ElementsChange(deltas);
}
public applyTo(
elements: Readonly<Map<string, ExcalidrawElement>>,
): [Map<string, ExcalidrawElement>, boolean] {
let containsVisibleDifference = false;
for (const [id, delta] of this.deltas.entries()) {
const existingElement = elements.get(id);
if (existingElement) {
// Check if there was actually any visible change before applying
if (!containsVisibleDifference) {
// Special case, when delta deletes element, it results in a visible change
if (existingElement.isDeleted && delta.to.isDeleted === false) {
containsVisibleDifference = true;
} else if (!existingElement.isDeleted) {
// Check for any difference on a visible element
containsVisibleDifference = Delta.containsDifference(
delta.to,
existingElement,
);
}
}
elements.set(id, newElementWith(existingElement, delta.to, true));
}
}
return [elements, containsVisibleDifference];
}
public isEmpty(): boolean {
// TODO_UNDO: might need to go through all deltas and check for emptiness
return this.deltas.size === 0;
}
/**
* Update the delta/s based on the existing elements.
*
* @param elements current elements
* @param modifierOptions defines which of the delta (`from` or `to`) will be updated
* @returns new instance with modified delta/s
*/
public applyLatestChanges(
elements: Map<string, ExcalidrawElement>,
modifierOptions: "from" | "to",
): ElementsChange {
const modifier =
(element: ExcalidrawElement) => (partial: Partial<ExcalidrawElement>) => {
const modifiedPartial: { [key: string]: unknown } = {};
for (const key of Object.keys(partial)) {
modifiedPartial[key] = element[key as keyof ExcalidrawElement];
}
return modifiedPartial;
};
const deltas = new Map<string, Delta<ExcalidrawElement>>();
for (const [id, delta] of this.deltas.entries()) {
const existingElement = elements.get(id);
if (existingElement) {
const modifiedDelta = Delta.create(
delta.from,
delta.to,
modifier(existingElement),
modifierOptions,
);
deltas.set(id, modifiedDelta);
} else {
// Keep whatever we had
deltas.set(id, delta);
}
}
return ElementsChange.create(deltas);
}
private static stripIrrelevantProps(delta: Partial<ExcalidrawElement>) {
// TODO_UNDO: is seed correctly stripped?
const { id, updated, version, versionNonce, seed, ...strippedDelta } =
delta;
return strippedDelta;
}
}
+5 -2
View File
@@ -9,7 +9,10 @@ import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
MIME_TYPES, MIME_TYPES,
} from "./constants"; } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import {
isFrameLikeElement,
isInitializedImageElement,
} from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement"; import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
@@ -124,7 +127,7 @@ export const serializeAsClipboardJSON = ({
files: BinaryFiles | null; files: BinaryFiles | null;
}) => { }) => {
const framesToCopy = new Set( const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"), elements.filter((element) => isFrameLikeElement(element)),
); );
let foundFile = false; let foundFile = false;
+1
View File
@@ -12,6 +12,7 @@
font-size: 0.875rem !important; font-size: 0.875rem !important;
width: var(--lg-button-size); width: var(--lg-button-size);
height: var(--lg-button-size); height: var(--lg-button-size);
svg { svg {
width: var(--lg-icon-size) !important; width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important; height: var(--lg-icon-size) !important;
+29 -3
View File
@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { import {
@@ -36,6 +36,8 @@ import {
frameToolIcon, frameToolIcon,
mermaidLogoIcon, mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@@ -79,7 +81,8 @@ export const SelectedShapeActions = ({
const showLinkIcon = const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer; targetElements.length === 1 || isSingleElementBoundContainer;
let commonSelectedType: string | null = targetElements[0]?.type || null; let commonSelectedType: ExcalidrawElementType | null =
targetElements[0]?.type || null;
for (const element of targetElements) { for (const element of targetElements) {
if (element.type !== commonSelectedType) { if (element.type !== commonSelectedType) {
@@ -94,7 +97,8 @@ export const SelectedShapeActions = ({
{((hasStrokeColor(appState.activeTool.type) && {((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" && appState.activeTool.type !== "image" &&
commonSelectedType !== "image" && commonSelectedType !== "image" &&
commonSelectedType !== "frame") || commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
</div> </div>
@@ -331,6 +335,9 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")} onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon} icon={mermaidLogoIcon}
@@ -338,6 +345,25 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.mermaidToExcalidraw")} {t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicButtonSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("magicSettings")}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</> </>
+796 -143
View File
File diff suppressed because it is too large Load Diff
+33 -3
View File
@@ -1,7 +1,12 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import {
CLASSES,
DEFAULT_SIDEBAR,
LIBRARY_SIDEBAR_WIDTH,
TOOL_TYPE,
} from "../constants";
import { showSelectedShapeActions } from "../element"; import { showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
@@ -56,6 +61,7 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton"; import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@@ -77,6 +83,10 @@ interface LayerUIProps {
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean; isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@@ -133,6 +143,10 @@ const LayerUI = ({
children, children,
app, app,
isCollaborating, isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@@ -295,9 +309,11 @@ const LayerUI = ({
> >
<LaserPointerButton <LaserPointerButton
title={t("toolBar.laser")} title={t("toolBar.laser")}
checked={appState.activeTool.type === "laser"} checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() => onChange={() =>
app.setActiveTool({ type: "laser" }) app.setActiveTool({ type: TOOL_TYPE.laser })
} }
isMobile isMobile
/> />
@@ -439,6 +455,20 @@ const LayerUI = ({
}} }}
/> />
)} )}
{appState.openDialog === "magicSettings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
setAppState({ openDialog: null });
onMagicSettingsConfirm(apiKey, shouldPersist);
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog /> <ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out /> <tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()} {renderImageExportDialog()}
+38
View File
@@ -0,0 +1,38 @@
import "./ToolIcon.scss";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
const DEFAULT_SIZE: ToolButtonSize = "small";
export const ElementCanvasButton = (props: {
title?: string;
icon: JSX.Element;
name?: string;
checked: boolean;
onChange?(): void;
isMobile?: boolean;
}) => {
return (
<label
className={clsx(
"ToolIcon ToolIcon__MagicButton",
`ToolIcon_size_${DEFAULT_SIZE}`,
{
"is-mobile": props.isMobile,
},
)}
title={`${props.title}`}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
name={props.name}
onChange={props.onChange}
checked={props.checked}
aria-label={props.title}
/>
<div className="ToolIcon__icon">{props.icon}</div>
</label>
);
};
+9
View File
@@ -0,0 +1,9 @@
.excalidraw {
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
}
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from "react";
import { Dialog } from "./Dialog";
import { TextField } from "./TextField";
import { MagicIcon, OpenAIIcon } from "./icons";
import "./MagicSettings.scss";
import { FilledButton } from "./FilledButton";
import { CheckboxItem } from "./CheckboxItem";
import { KEYS } from "../keys";
import { useUIAppState } from "../context/ui-appState";
const InlineButton = ({ icon }: { icon: JSX.Element }) => {
return (
<span
style={{
width: "1em",
margin: "0 0.5ex 0 0.5ex",
display: "inline-block",
lineHeight: 0,
verticalAlign: "middle",
}}
>
{icon}
</span>
);
};
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const { theme } = useUIAppState();
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Diagram to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: theme === "light" ? "#FFCCCC" : "#703333",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
<p
style={{
display: "inline-flex",
alignItems: "center",
marginBottom: 0,
}}
>
For the diagram-to-code feature we use{" "}
<InlineButton icon={OpenAIIcon} />
OpenAI.
</p>
<p>
While the OpenAI API is in beta, its use is strictly limited — as such
we require you use your own API key. You can create an{" "}
<a
href="https://platform.openai.com/login?launch"
rel="noopener noreferrer"
target="_blank"
>
OpenAI account
</a>
, add a small credit (5 USD minimum), and{" "}
<a
href="https://platform.openai.com/api-keys"
rel="noopener noreferrer"
target="_blank"
>
generate your own API key
</a>
.
</p>
<p>
Your OpenAI key does not leave the browser, and you can also set your
own limit in your OpenAI account dashboard if needed.
</p>
<TextField
isPassword
value={keyInputValue}
placeholder="Paste your API key here"
label="OpenAI API key"
onChange={(value) => {
setKeyInputValue(value);
props.onChange(value.trim(), shouldPersist);
}}
selectOnRender
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()}
/>
<p>
By default, your API token is not persisted anywhere so you'll need to
insert it again after reload. But, you can persist locally in your
browser below.
</p>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<p>
Once API key is set, you can use the <InlineButton icon={MagicIcon} />{" "}
tool to wrap your elements in a frame that will then allow you to turn
it into code. This dialog can be accessed using the <b>AI Settings</b>{" "}
<InlineButton icon={OpenAIIcon} />.
</p>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</Dialog>
);
};
+1 -1
View File
@@ -94,7 +94,7 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => { const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements); onInsertElements(elements);
trackEvent("magic", "chart", chartType); trackEvent("paste", "chart", chartType);
setAppState({ setAppState({
currentChartType: chartType, currentChartType: chartType,
pasteDialog: { pasteDialog: {
+7 -31
View File
@@ -8,6 +8,7 @@ import Trans from "./Trans";
import { LibraryItems, LibraryItem, UIAppState } from "../types"; import { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils"; import { exportToCanvas, exportToSvg } from "../packages/utils";
import { import {
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
MIME_TYPES, MIME_TYPES,
@@ -19,6 +20,7 @@ import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss"; import "./PublishLibrary.scss";
@@ -31,34 +33,6 @@ interface PublishLibraryDataParams {
website: string; website: string;
} }
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
try {
localStorage.setItem(
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
JSON.stringify(data),
);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importPublishLibDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
if (data) {
return JSON.parse(data);
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const generatePreviewImage = async (libraryItems: LibraryItems) => { const generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6; const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128; const BOX_SIZE = 128;
@@ -255,7 +229,9 @@ const PublishLibrary = ({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
const data = importPublishLibDataFromStorage(); const data = EditorLocalStorage.get<PublishLibraryDataParams>(
EDITOR_LS_KEYS.PUBLISH_LIBRARY,
);
if (data) { if (data) {
setLibraryData(data); setLibraryData(data);
} }
@@ -328,7 +304,7 @@ const PublishLibrary = ({
if (response.ok) { if (response.ok) {
return response.json().then(({ url }) => { return response.json().then(({ url }) => {
// flush data from local storage // flush data from local storage
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY); EditorLocalStorage.delete(EDITOR_LS_KEYS.PUBLISH_LIBRARY);
onSuccess({ onSuccess({
url, url,
authorName: libraryData.authorName, authorName: libraryData.authorName,
@@ -384,7 +360,7 @@ const PublishLibrary = ({
const onDialogClose = useCallback(() => { const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems); updateItemsInStorage(clonedLibItems);
savePublishLibDataToStorage(libraryData); EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, libraryData);
onClose(); onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]); }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
+17 -2
View File
@@ -4,12 +4,15 @@ import {
useImperativeHandle, useImperativeHandle,
KeyboardEvent, KeyboardEvent,
useLayoutEffect, useLayoutEffect,
useState,
} from "react"; } from "react";
import clsx from "clsx"; import clsx from "clsx";
import "./TextField.scss"; import "./TextField.scss";
import { Button } from "./Button";
import { eyeIcon, eyeClosedIcon } from "./icons";
export type TextFieldProps = { type TextFieldProps = {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -22,6 +25,7 @@ export type TextFieldProps = {
label?: string; label?: string;
placeholder?: string; placeholder?: string;
isPassword?: boolean;
}; };
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly, readonly,
selectOnRender, selectOnRender,
onKeyDown, onKeyDown,
isPassword = false,
}, },
ref, ref,
) => { ) => {
@@ -48,6 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
} }
}, [selectOnRender]); }, [selectOnRender]);
const [isVisible, setIsVisible] = useState<boolean>(true);
return ( return (
<div <div
className={clsx("ExcTextField", { className={clsx("ExcTextField", {
@@ -64,14 +71,22 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})} })}
> >
<input <input
type={isPassword && isVisible ? "password" : undefined}
readOnly={readonly} readOnly={readonly}
type="text"
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
ref={innerRef} ref={innerRef}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{isPassword && (
<Button
onSelect={() => setIsVisible(!isVisible)}
style={{ border: 0 }}
>
{isVisible ? eyeIcon : eyeClosedIcon}
</Button>
)}
</div> </div>
</div> </div>
); );
+7 -2
View File
@@ -24,6 +24,7 @@ type ToolButtonBaseProps = {
hidden?: boolean; hidden?: boolean;
visible?: boolean; visible?: boolean;
selected?: boolean; selected?: boolean;
disabled?: boolean;
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
isLoading?: boolean; isLoading?: boolean;
@@ -123,10 +124,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type} type={type}
onClick={onClick} onClick={onClick}
ref={innerRef} ref={innerRef}
disabled={isLoading || props.isLoading} disabled={isLoading || props.isLoading || !!props.disabled}
> >
{(props.icon || props.label) && ( {(props.icon || props.label) && (
<div className="ToolIcon__icon" aria-hidden="true"> <div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
{props.icon || props.label} {props.icon || props.label}
{props.keyBindingLabel && ( {props.keyBindingLabel && (
<span className="ToolIcon__keybinding"> <span className="ToolIcon__keybinding">
+20 -3
View File
@@ -77,8 +77,8 @@
} }
.ToolIcon_type_button, .ToolIcon_type_button,
.Modal .ToolIcon_type_button, .Modal .ToolIcon_type_button
.ToolIcon_type_button { {
padding: 0; padding: 0;
border: none; border: none;
margin: 0; margin: 0;
@@ -101,6 +101,22 @@
background-color: var(--button-gray-3); background-color: var(--button-gray-3);
} }
&:disabled {
cursor: default;
&:active,
&:focus-visible,
&:hover {
background-color: initial;
border: none;
box-shadow: none;
}
svg {
color: var(--color-disabled);
}
}
&--show { &--show {
visibility: visible; visibility: visible;
} }
@@ -175,7 +191,8 @@
} }
} }
.ToolIcon__LaserPointer .ToolIcon__icon { .ToolIcon__LaserPointer .ToolIcon__icon,
.ToolIcon__MagicButton .ToolIcon__icon {
width: var(--default-button-size); width: var(--default-button-size);
height: var(--default-button-size); height: var(--default-button-size);
} }
@@ -189,8 +189,6 @@ const getRelevantAppStateProps = (
suggestedBindings: appState.suggestedBindings, suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating, isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight, elementsToHighlight: appState.elementsToHighlight,
openSidebar: appState.openSidebar,
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable, activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines, snapLines: appState.snapLines,
+54
View File
@@ -1688,3 +1688,57 @@ export const laserPointerToolIcon = createIcon(
20, 20,
); );
export const MagicIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M6 21l15 -15l-3 -3l-15 15l3 3" />
<path d="M15 6l3 3" />
<path d="M9 3a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
<path d="M19 13a2 2 0 0 0 2 2a2 2 0 0 0 -2 2a2 2 0 0 0 -2 -2a2 2 0 0 0 2 -2" />
</g>,
tablerIconProps,
);
export const OpenAIIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M11.217 19.384a3.501 3.501 0 0 0 6.783 -1.217v-5.167l-6 -3.35" />
<path d="M5.214 15.014a3.501 3.501 0 0 0 4.446 5.266l4.34 -2.534v-6.946" />
<path d="M6 7.63c-1.391 -.236 -2.787 .395 -3.534 1.689a3.474 3.474 0 0 0 1.271 4.745l4.263 2.514l6 -3.348" />
<path d="M12.783 4.616a3.501 3.501 0 0 0 -6.783 1.217v5.067l6 3.45" />
<path d="M18.786 8.986a3.501 3.501 0 0 0 -4.446 -5.266l-4.34 2.534v6.946" />
<path d="M18 16.302c1.391 .236 2.787 -.395 3.534 -1.689a3.474 3.474 0 0 0 -1.271 -4.745l-4.308 -2.514l-5.955 3.42" />
</g>,
tablerIconProps,
);
export const fullscreenIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 8v-2a2 2 0 0 1 2 -2h2" />
<path d="M4 16v2a2 2 0 0 0 2 2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M16 20h2a2 2 0 0 0 2 -2v-2" />
</g>,
tablerIconProps,
);
export const eyeIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
</g>,
tablerIconProps,
);
export const eyeClosedIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" />
<path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" />
<path d="M3 3l18 18" />
</g>,
tablerIconProps,
);
+32 -5
View File
@@ -80,6 +80,7 @@ export enum EVENT {
EXCALIDRAW_LINK = "excalidraw-link", EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect", MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message", MESSAGE = "message",
FULLSCREENCHANGE = "fullscreenchange",
} }
export const YOUTUBE_STATES = { export const YOUTUBE_STATES = {
@@ -195,6 +196,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1; export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0;
export const HYPERLINK_TOOLTIP_DELAY = 300; export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -301,10 +303,6 @@ export const ROUNDNESS = {
ADAPTIVE_RADIUS: 3, ADAPTIVE_RADIUS: 3,
} as const; } as const;
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const ROUGHNESS = { export const ROUGHNESS = {
architect: 0, architect: 0,
artist: 1, artist: 1,
@@ -344,4 +342,33 @@ export const DEFAULT_SIDEBAR = {
defaultTab: LIBRARY_SIDEBAR_TAB, defaultTab: LIBRARY_SIDEBAR_TAB,
} as const; } as const;
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const); export const LIBRARY_DISABLED_TYPES = new Set([
"iframe",
"embeddable",
"image",
] as const);
// use these constants to easily identify reference sites
export const TOOL_TYPE = {
selection: "selection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
image: "image",
eraser: "eraser",
hand: "hand",
frame: "frame",
magicframe: "magicframe",
embeddable: "embeddable",
laser: "laser",
} as const;
export const EDITOR_LS_KEYS = {
OAI_API_KEY: "excalidraw-oai-api-key",
// legacy naming (non)scheme
PUBLISH_LIBRARY: "publish-library-data",
} as const;
+2
View File
@@ -5,9 +5,11 @@
--zIndex-canvas: 1; --zIndex-canvas: 1;
--zIndex-interactiveCanvas: 2; --zIndex-interactiveCanvas: 2;
--zIndex-wysiwyg: 3; --zIndex-wysiwyg: 3;
--zIndex-canvasButtons: 3;
--zIndex-layerUI: 4; --zIndex-layerUI: 4;
--zIndex-eyeDropperBackdrop: 5; --zIndex-eyeDropperBackdrop: 5;
--zIndex-eyeDropperPreview: 6; --zIndex-eyeDropperPreview: 6;
--zIndex-hyperlinkContainer: 7;
--zIndex-modal: 1000; --zIndex-modal: 1000;
--zIndex-popup: 1001; --zIndex-popup: 1001;
+2
View File
@@ -97,6 +97,8 @@
--color-gray-90: #1e1e1e; --color-gray-90: #1e1e1e;
--color-gray-100: #121212; --color-gray-100: #121212;
--color-disabled: var(--color-gray-40);
--color-warning: #fceeca; --color-warning: #fceeca;
--color-warning-dark: #f5c354; --color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c; --color-warning-darker: #f3ab2c;
+9
View File
@@ -50,6 +50,15 @@
color: var(--color-on-primary-container); color: var(--color-on-primary-container);
} }
} }
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
} }
} }
+51
View File
@@ -0,0 +1,51 @@
import { EDITOR_LS_KEYS } from "../constants";
import { JSONValue } from "../types";
export class EditorLocalStorage {
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
try {
return !!window.localStorage.getItem(key);
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return false;
}
}
static get<T extends JSONValue>(
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) {
try {
const value = window.localStorage.getItem(key);
if (value) {
return JSON.parse(value) as T;
}
return null;
} catch (error: any) {
console.warn(`localStorage.getItem error: ${error.message}`);
return null;
}
}
static set = (
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
value: JSONValue,
) => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error: any) {
console.warn(`localStorage.setItem error: ${error.message}`);
return false;
}
};
static delete = (
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
) => {
try {
window.localStorage.removeItem(name);
} catch (error: any) {
console.warn(`localStorage.removeItem error: ${error.message}`);
}
};
}
@@ -15,6 +15,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 300, "height": 300,
@@ -50,6 +51,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -86,6 +88,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"gap": 1, "gap": 1,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 35, "height": 35,
@@ -139,6 +142,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"gap": 3.834326468444573, "gap": 3.834326468444573,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -191,6 +195,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 300, "height": 300,
@@ -230,6 +235,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -274,6 +280,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -320,6 +327,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"gap": 205, "gap": 205,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -371,6 +379,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -417,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"gap": 1, "gap": 1,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -468,6 +478,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -508,6 +519,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -543,6 +555,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -584,6 +597,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"gap": 1, "gap": 1,
}, },
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -635,6 +649,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -679,6 +694,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -723,6 +739,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -758,6 +775,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 200, "height": 200,
@@ -790,6 +808,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -835,6 +854,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"endArrowhead": "triangle", "endArrowhead": "triangle",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -880,6 +900,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -925,6 +946,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -968,6 +990,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -998,6 +1021,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -1028,6 +1052,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -1058,6 +1083,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"backgroundColor": "#c0eb75", "backgroundColor": "#c0eb75",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -1088,6 +1114,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"backgroundColor": "#ffc9c9", "backgroundColor": "#ffc9c9",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -1118,6 +1145,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"backgroundColor": "#a5d8ff", "backgroundColor": "#a5d8ff",
"boundElements": null, "boundElements": null,
"fillStyle": "cross-hatch", "fillStyle": "cross-hatch",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 100, "height": 100,
@@ -1152,6 +1180,7 @@ exports[`Test Transform > should transform text element 1`] = `
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -1191,6 +1220,7 @@ exports[`Test Transform > should transform text element 2`] = `
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -1233,6 +1263,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -1283,6 +1314,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -1333,6 +1365,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -1383,6 +1416,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 0, "height": 0,
@@ -1430,6 +1464,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -1469,6 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -1508,6 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -1548,6 +1585,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -1589,6 +1627,7 @@ exports[`Test Transform > should transform to text containers when label provide
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 35, "height": 35,
@@ -1624,6 +1663,7 @@ exports[`Test Transform > should transform to text containers when label provide
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 85, "height": 85,
@@ -1659,6 +1699,7 @@ exports[`Test Transform > should transform to text containers when label provide
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 170, "height": 170,
@@ -1694,6 +1735,7 @@ exports[`Test Transform > should transform to text containers when label provide
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 120, "height": 120,
@@ -1729,6 +1771,7 @@ exports[`Test Transform > should transform to text containers when label provide
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 85, "height": 85,
@@ -1764,6 +1807,7 @@ exports[`Test Transform > should transform to text containers when label provide
}, },
], ],
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 120, "height": 120,
@@ -1798,6 +1842,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 25, "height": 25,
@@ -1837,6 +1882,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -1877,6 +1923,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 75, "height": 75,
@@ -1919,6 +1966,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -1959,6 +2007,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 75, "height": 75,
@@ -2000,6 +2049,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid", "fillStyle": "solid",
"fontFamily": 1, "fontFamily": 1,
"fontSize": 20, "fontSize": 20,
"fractionalIndex": null,
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 75, "height": 75,
+300
View File
@@ -0,0 +1,300 @@
export namespace OpenAIInput {
type ChatCompletionContentPart =
| ChatCompletionContentPartText
| ChatCompletionContentPartImage;
interface ChatCompletionContentPartImage {
image_url: ChatCompletionContentPartImage.ImageURL;
/**
* The type of the content part.
*/
type: "image_url";
}
namespace ChatCompletionContentPartImage {
export interface ImageURL {
/**
* Either a URL of the image or the base64 encoded image data.
*/
url: string;
/**
* Specifies the detail level of the image.
*/
detail?: "auto" | "low" | "high";
}
}
interface ChatCompletionContentPartText {
/**
* The text content.
*/
text: string;
/**
* The type of the content part.
*/
type: "text";
}
interface ChatCompletionUserMessageParam {
/**
* The contents of the user message.
*/
content: string | Array<ChatCompletionContentPart> | null;
/**
* The role of the messages author, in this case `user`.
*/
role: "user";
}
interface ChatCompletionSystemMessageParam {
/**
* The contents of the system message.
*/
content: string | null;
/**
* The role of the messages author, in this case `system`.
*/
role: "system";
}
export interface ChatCompletionCreateParamsBase {
/**
* A list of messages comprising the conversation so far.
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
*/
messages: Array<
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
>;
/**
* ID of the model to use. See the
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
* table for details on which models work with the Chat API.
*/
model:
| (string & {})
| "gpt-4-1106-preview"
| "gpt-4-vision-preview"
| "gpt-4"
| "gpt-4-0314"
| "gpt-4-0613"
| "gpt-4-32k"
| "gpt-4-32k-0314"
| "gpt-4-32k-0613"
| "gpt-3.5-turbo"
| "gpt-3.5-turbo-16k"
| "gpt-3.5-turbo-0301"
| "gpt-3.5-turbo-0613"
| "gpt-3.5-turbo-16k-0613";
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
* existing frequency in the text so far, decreasing the model's likelihood to
* repeat the same line verbatim.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
frequency_penalty?: number | null;
/**
* Modify the likelihood of specified tokens appearing in the completion.
*
* Accepts a JSON object that maps tokens (specified by their token ID in the
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
* bias is added to the logits generated by the model prior to sampling. The exact
* effect will vary per model, but values between -1 and 1 should decrease or
* increase likelihood of selection; values like -100 or 100 should result in a ban
* or exclusive selection of the relevant token.
*/
logit_bias?: Record<string, number> | null;
/**
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
*
* The total length of input tokens and generated tokens is limited by the model's
* context length.
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
* for counting tokens.
*/
max_tokens?: number | null;
/**
* How many chat completion choices to generate for each input message.
*/
n?: number | null;
/**
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
* whether they appear in the text so far, increasing the model's likelihood to
* talk about new topics.
*
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
*/
presence_penalty?: number | null;
/**
* This feature is in Beta. If specified, our system will make a best effort to
* sample deterministically, such that repeated requests with the same `seed` and
* parameters should return the same result. Determinism is not guaranteed, and you
* should refer to the `system_fingerprint` response parameter to monitor changes
* in the backend.
*/
seed?: number | null;
/**
* Up to 4 sequences where the API will stop generating further tokens.
*/
stop?: string | null | Array<string>;
/**
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
* sent as data-only
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
* as they become available, with the stream terminated by a `data: [DONE]`
* message.
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
*/
stream?: boolean | null;
/**
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
* make the output more random, while lower values like 0.2 will make it more
* focused and deterministic.
*
* We generally recommend altering this or `top_p` but not both.
*/
temperature?: number | null;
/**
* An alternative to sampling with temperature, called nucleus sampling, where the
* model considers the results of the tokens with top_p probability mass. So 0.1
* means only the tokens comprising the top 10% probability mass are considered.
*
* We generally recommend altering this or `temperature` but not both.
*/
top_p?: number | null;
/**
* A unique identifier representing your end-user, which can help OpenAI to monitor
* and detect abuse.
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
*/
user?: string;
}
}
export namespace OpenAIOutput {
export interface ChatCompletion {
/**
* A unique identifier for the chat completion.
*/
id: string;
/**
* A list of chat completion choices. Can be more than one if `n` is greater
* than 1.
*/
choices: Array<Choice>;
/**
* The Unix timestamp (in seconds) of when the chat completion was created.
*/
created: number;
/**
* The model used for the chat completion.
*/
model: string;
/**
* The object type, which is always `chat.completion`.
*/
object: "chat.completion";
/**
* This fingerprint represents the backend configuration that the model runs with.
*
* Can be used in conjunction with the `seed` request parameter to understand when
* backend changes have been made that might impact determinism.
*/
system_fingerprint?: string;
/**
* Usage statistics for the completion request.
*/
usage?: CompletionUsage;
}
export interface Choice {
/**
* The reason the model stopped generating tokens. This will be `stop` if the model
* hit a natural stop point or a provided stop sequence, `length` if the maximum
* number of tokens specified in the request was reached, `content_filter` if
* content was omitted due to a flag from our content filters, `tool_calls` if the
* model called a tool, or `function_call` (deprecated) if the model called a
* function.
*/
finish_reason:
| "stop"
| "length"
| "tool_calls"
| "content_filter"
| "function_call";
/**
* The index of the choice in the list of choices.
*/
index: number;
/**
* A chat completion message generated by the model.
*/
message: ChatCompletionMessage;
}
interface ChatCompletionMessage {
/**
* The contents of the message.
*/
content: string | null;
/**
* The role of the author of this message.
*/
role: "assistant";
}
/**
* Usage statistics for the completion request.
*/
interface CompletionUsage {
/**
* Number of tokens in the generated completion.
*/
completion_tokens: number;
/**
* Number of tokens in the prompt.
*/
prompt_tokens: number;
/**
* Total number of tokens used in the request (prompt + completion).
*/
total_tokens: number;
}
export interface APIError {
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
readonly headers: Headers | undefined;
readonly error: { message: string } | undefined;
readonly code: string | null | undefined;
readonly param: string | null | undefined;
readonly type: string | undefined;
}
}
+9 -5
View File
@@ -3,10 +3,11 @@ import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants"; import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { getNonDeletedElements, isFrameElement } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -38,7 +39,7 @@ export const prepareElementsForExport = (
exportSelectionOnly && exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds }); isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: ExcalidrawFrameElement | null = null; let exportingFrame: ExcalidrawFrameLikeElement | null = null;
let exportedElements = isExportingSelection let exportedElements = isExportingSelection
? getSelectedElements( ? getSelectedElements(
elements, elements,
@@ -50,7 +51,10 @@ export const prepareElementsForExport = (
: elements; : elements;
if (isExportingSelection) { if (isExportingSelection) {
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) { if (
exportedElements.length === 1 &&
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0]; exportingFrame = exportedElements[0];
exportedElements = elementsOverlappingBBox({ exportedElements = elementsOverlappingBBox({
elements, elements,
@@ -93,7 +97,7 @@ export const exportCanvas = async (
viewBackgroundColor: string; viewBackgroundColor: string;
name: string; name: string;
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameElement | null; exportingFrame: ExcalidrawFrameLikeElement | null;
}, },
) => { ) => {
if (elements.length === 0) { if (elements.length === 0) {
+104
View File
@@ -0,0 +1,104 @@
import { Theme } from "../element/types";
import { DataURL } from "../types";
import { OpenAIInput, OpenAIOutput } from "./ai/types";
export type MagicCacheData =
| {
status: "pending";
}
| { status: "done"; html: string }
| {
status: "error";
message?: string;
code: "ERR_GENERATION_INTERRUPTED" | string;
};
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
Your role is to transform low-fidelity wireframes into working front-end HTML code.
YOU MUST FOLLOW FOLLOWING RULES:
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
- Inline JavaScript when needed
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
- Source images from Unsplash or create applicable placeholders
- Interpret annotations as intended vs literal UI
- Fill gaps using your expertise in UX and business logic
- generate primarily for desktop UI, but make it responsive.
- Use grid and flexbox wherever applicable.
- Convert the wireframe in its entirety, don't omit elements if possible.
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
Your goal is a production-ready prototype that brings the wireframes to life.
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
export async function diagramToHTML({
image,
apiKey,
text,
theme = "light",
}: {
image: DataURL;
apiKey: string;
text: string;
theme?: Theme;
}) {
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
model: "gpt-4-vision-preview",
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
max_tokens: 4096,
temperature: 0.1,
messages: [
{
role: "system",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: image,
detail: "high",
},
},
{
type: "text",
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
},
{
type: "text",
text,
},
],
},
],
};
let result:
| ({ ok: true } & OpenAIOutput.ChatCompletion)
| ({ ok: false } & OpenAIOutput.APIError);
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
if (resp.ok) {
const json: OpenAIOutput.ChatCompletion = await resp.json();
result = { ...json, ok: true };
} else {
const json: OpenAIOutput.APIError = await resp.json();
result = { ...json, ok: false };
}
return result;
}
+14 -17
View File
@@ -1,5 +1,6 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
@@ -25,7 +26,6 @@ import {
DEFAULT_FONT_FAMILY, DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN, DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN, DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY, FONT_FAMILY,
ROUNDNESS, ROUNDNESS,
DEFAULT_SIDEBAR, DEFAULT_SIDEBAR,
@@ -43,6 +43,7 @@ import {
measureBaseline, measureBaseline,
} from "../element/textElement"; } from "../element/textElement";
import { normalizeLink } from "./url"; import { normalizeLink } from "./url";
import { restoreFractionalIndicies } from "../fractionalIndex";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
@@ -68,6 +69,7 @@ export const AllowedExcalidrawActiveTools: Record<
embeddable: true, embeddable: true,
hand: true, hand: true,
laser: false, laser: false,
magicframe: false,
}; };
export type RestoredDataState = { export type RestoredDataState = {
@@ -99,8 +101,6 @@ const restoreElementWithProperties = <
boundElementIds?: readonly ExcalidrawElement["id"][]; boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */ /** @deprecated */
strokeSharpness?: StrokeRoundness; strokeSharpness?: StrokeRoundness;
/** metadata that may be present in elements during collaboration */
[PRECEDING_ELEMENT_KEY]?: string;
}, },
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>, K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>( >(
@@ -111,16 +111,16 @@ const restoreElementWithProperties = <
// @ts-ignore TS complains here but type checks the call sites fine. // @ts-ignore TS complains here but type checks the call sites fine.
keyof K keyof K
> & > &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>, Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => { ): T => {
const base: Pick<T, keyof ExcalidrawElement> & { const base: Pick<T, keyof ExcalidrawElement> = {
[PRECEDING_ELEMENT_KEY]?: string;
} = {
type: extra.type || element.type, type: extra.type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up // all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements // newly added elements
version: element.version || 1, version: element.version || 1,
versionNonce: element.versionNonce ?? 0, versionNonce: element.versionNonce ?? 0,
// TODO: think about this more
fractionalIndex: element.fractionalIndex ?? null,
isDeleted: element.isDeleted ?? false, isDeleted: element.isDeleted ?? false,
id: element.id || randomId(), id: element.id || randomId(),
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle, fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
@@ -159,12 +159,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("customData" in element) { if ("customData" in element || "customData" in extra) {
base.customData = element.customData; base.customData =
} "customData" in extra ? extra.customData : element.customData;
if (PRECEDING_ELEMENT_KEY in element) {
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
} }
return { return {
@@ -273,7 +270,7 @@ const restoreElement = (
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
type: type:
(element.type as ExcalidrawElement["type"] | "draw") === "draw" (element.type as ExcalidrawElementType | "draw") === "draw"
? "line" ? "line"
: element.type, : element.type,
startBinding: repairBinding(element.startBinding), startBinding: repairBinding(element.startBinding),
@@ -289,15 +286,15 @@ const restoreElement = (
// generic elements // generic elements
case "ellipse": case "ellipse":
return restoreElementWithProperties(element, {});
case "rectangle": case "rectangle":
return restoreElementWithProperties(element, {});
case "diamond": case "diamond":
case "iframe":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "embeddable": case "embeddable":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
validated: null, validated: null,
}); });
case "magicframe":
case "frame": case "frame":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
name: element.name ?? null, name: element.name ?? null,
@@ -464,7 +461,7 @@ export const restoreElements = (
} }
} }
return restoredElements; return restoreFractionalIndicies(restoredElements) as ExcalidrawElement[];
}; };
const coalesceAppStateValue = < const coalesceAppStateValue = <
+44 -8
View File
@@ -15,6 +15,7 @@ import {
ElementConstructorOpts, ElementConstructorOpts,
newFrameElement, newFrameElement,
newImageElement, newImageElement,
newMagicFrameElement,
newTextElement, newTextElement,
} from "../element/newElement"; } from "../element/newElement";
import { import {
@@ -26,12 +27,13 @@ import {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawIframeLikeElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawMagicFrameElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FileId, FileId,
@@ -61,7 +63,12 @@ export type ValidLinearElement = {
| { | {
type: Exclude< type: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
id?: ExcalidrawGenericElement["id"]; id?: ExcalidrawGenericElement["id"];
} }
@@ -69,7 +76,12 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"]; id: ExcalidrawGenericElement["id"];
type?: Exclude< type?: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
} }
) )
@@ -93,7 +105,12 @@ export type ValidLinearElement = {
| { | {
type: Exclude< type: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
id?: ExcalidrawGenericElement["id"]; id?: ExcalidrawGenericElement["id"];
} }
@@ -101,7 +118,12 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"]; id: ExcalidrawGenericElement["id"];
type?: Exclude< type?: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
} }
) )
@@ -137,7 +159,7 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton = export type ExcalidrawElementSkeleton =
| Extract< | Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
> >
| ({ | ({
type: Extract<ExcalidrawLinearElement["type"], "line">; type: Extract<ExcalidrawLinearElement["type"], "line">;
@@ -163,7 +185,12 @@ export type ExcalidrawElementSkeleton =
type: "frame"; type: "frame";
children: readonly ExcalidrawElement["id"][]; children: readonly ExcalidrawElement["id"][];
name?: string; name?: string;
} & Partial<ExcalidrawFrameElement>); } & Partial<ExcalidrawFrameElement>)
| ({
type: "magicframe";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawMagicFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = { const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100, width: 100,
@@ -547,7 +574,16 @@ export const convertToExcalidrawElements = (
}); });
break; break;
} }
case "magicframe": {
excalidrawElement = newMagicFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw": case "freedraw":
case "iframe":
case "embeddable": { case "embeddable": {
excalidrawElement = element; excalidrawElement = element;
break; break;
@@ -656,7 +692,7 @@ export const convertToExcalidrawElements = (
// need to calculate coordinates and dimensions of frame which is possibe after all // need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed. // frame children are processed.
for (const [id, element] of elementsWithIds) { for (const [id, element] of elementsWithIds) {
if (element.type !== "frame") { if (element.type !== "frame" && element.type !== "magicframe") {
continue; continue;
} }
const frame = elementStore.getElement(id); const frame = elementStore.getElement(id);
+14
View File
@@ -0,0 +1,14 @@
.excalidraw {
.excalidraw-canvas-buttons {
position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: var(--zIndex-canvasButtons);
background: var(--island-bg-color);
border-radius: var(--border-radius-lg);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { AppState } from "../types";
import { sceneCoordsToViewportCoords } from "../utils";
import { NonDeletedExcalidrawElement } from "./types";
import { getElementAbsoluteCoords } from ".";
import { useExcalidrawAppState } from "../components/App";
import "./ElementCanvasButtons.scss";
const CONTAINER_PADDING = 5;
const getContainerCoords = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
const [x1, y1] = getElementAbsoluteCoords(element);
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
{ sceneX: x1 + element.width, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft + 10;
const y = viewportY - appState.offsetTop;
return { x, y };
};
export const ElementCanvasButtons = ({
children,
element,
}: {
children: React.ReactNode;
element: NonDeletedExcalidrawElement;
}) => {
const appState = useExcalidrawAppState();
if (
appState.contextMenu ||
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating ||
appState.openMenu ||
appState.viewModeEnabled
) {
return null;
}
const { x, y } = getContainerCoords(element, appState);
return (
<div
className="excalidraw-canvas-buttons"
style={{
top: `${y}px`,
left: `${x}px`,
// width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
>
{children}
</div>
);
};
+1 -1
View File
@@ -6,7 +6,7 @@
justify-content: space-between; justify-content: space-between;
position: absolute; position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%); box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: 100; z-index: var(--zIndex-hyperlinkContainer);
background: var(--island-bg-color); background: var(--island-bg-color);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
box-sizing: border-box; box-sizing: border-box;
+4 -2
View File
@@ -40,6 +40,7 @@ import { trackEvent } from "../analytics";
import { useAppProps, useExcalidrawAppState } from "../components/App"; import { useAppProps, useExcalidrawAppState } from "../components/App";
import { isEmbeddableElement } from "./typeChecks"; import { isEmbeddableElement } from "./typeChecks";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { StoreAction } from "../actions/types";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@@ -121,7 +122,7 @@ export const Hyperlink = ({
setToast({ message: embedLink.warning, closable: true }); setToast({ message: embedLink.warning, closable: true });
} }
const ar = embedLink const ar = embedLink
? embedLink.aspectRatio.w / embedLink.aspectRatio.h ? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
: 1; : 1;
const hasLinkChanged = const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link; embeddableLinkCache.get(element.id) !== element.link;
@@ -210,6 +211,7 @@ export const Hyperlink = ({
}; };
const { x, y } = getCoordsForPopover(element, appState); const { x, y } = getCoordsForPopover(element, appState);
if ( if (
appState.contextMenu ||
appState.draggingElement || appState.draggingElement ||
appState.resizingElement || appState.resizingElement ||
appState.isRotating || appState.isRotating ||
@@ -342,7 +344,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor", showHyperlinkPopup: "editor",
openMenu: null, openMenu: null,
}, },
commitToHistory: true, storeAction: StoreAction.CAPTURE,
}; };
}, },
trackEvent: { category: "hyperlink", action: "click" }, trackEvent: { category: "hyperlink", action: "click" },
+25 -12
View File
@@ -18,7 +18,6 @@ import {
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
ExcalidrawEmbeddableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
@@ -27,7 +26,8 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
} from "./types"; } from "./types";
import { import {
@@ -41,7 +41,8 @@ import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { import {
hasBoundTextElement, hasBoundTextElement,
isEmbeddableElement, isFrameLikeElement,
isIframeLikeElement,
isImageElement, isImageElement,
} from "./typeChecks"; } from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
@@ -64,7 +65,7 @@ const isElementDraggableFromInside = (
const isDraggableFromInside = const isDraggableFromInside =
!isTransparent(element.backgroundColor) || !isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) || hasBoundTextElement(element) ||
isEmbeddableElement(element); isIframeLikeElement(element);
if (element.type === "line") { if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points); return isDraggableFromInside && isPathALoop(element.points);
} }
@@ -186,7 +187,7 @@ export const isPointHittingElementBoundingBox = (
// by its frame, whether it has been selected or not // by its frame, whether it has been selected or not
// this logic here is not ideal // this logic here is not ideal
// TODO: refactor it later... // TODO: refactor it later...
if (element.type === "frame") { if (isFrameLikeElement(element)) {
return hitTestPointAgainstElement({ return hitTestPointAgainstElement({
element, element,
point: [x, y], point: [x, y],
@@ -255,6 +256,7 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) { switch (args.element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "image": case "image":
case "text": case "text":
@@ -282,7 +284,8 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.", "This should not happen, we need to investigate why it does.",
); );
return false; return false;
case "frame": { case "frame":
case "magicframe": {
// check distance to frame element first // check distance to frame element first
if ( if (
args.check( args.check(
@@ -314,8 +317,10 @@ export const distanceToBindableElement = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@@ -346,8 +351,8 @@ const distanceToRectangle = (
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawEmbeddableElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameElement, | ExcalidrawFrameLikeElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -662,8 +667,10 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
ret = c / (hwidth * (nabs + q * mabs)); ret = c / (hwidth * (nabs + q * mabs));
break; break;
case "diamond": case "diamond":
@@ -700,8 +707,10 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@@ -752,8 +761,10 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@@ -788,8 +799,8 @@ const getCorners = (
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawEmbeddableElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameElement, | ExcalidrawFrameLikeElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@@ -798,8 +809,10 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@@ -948,8 +961,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawEmbeddableElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameElement, | ExcalidrawFrameLikeElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,
+2 -2
View File
@@ -11,7 +11,7 @@ import Scene from "../scene/Scene";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement, isFrameLikeElement,
} from "./typeChecks"; } from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
@@ -33,7 +33,7 @@ export const dragSelectedElements = (
selectedElements, selectedElements,
); );
const frames = selectedElements const frames = selectedElements
.filter((e) => isFrameElement(e)) .filter((e) => isFrameLikeElement(e))
.map((f) => f.id); .map((f) => f.id);
if (frames.length > 0) { if (frames.length > 0) {
+59 -39
View File
@@ -6,25 +6,20 @@ import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement"; import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks"; import {
isFrameLikeElement,
isIframeElement,
isIframeLikeElement,
} from "./typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEmbeddableElement, ExcalidrawIframeLikeElement,
IframeData,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
Theme,
} from "./types"; } from "./types";
import { StoreAction } from "../actions/types";
type EmbeddedLink = const embeddedLinkCache = new Map<string, IframeData>();
| ({
aspectRatio: { w: number; h: number };
warning?: string;
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
))
| null;
const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE = const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/; /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
@@ -67,11 +62,13 @@ const ALLOWED_DOMAINS = new Set([
"dddice.com", "dddice.com",
]); ]);
const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`; return `<html><body>${body}</body></html>`;
}; };
export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => { export const getEmbedLink = (
link: string | null | undefined,
): IframeData | null => {
if (!link) { if (!link) {
return null; return null;
} }
@@ -104,8 +101,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
break; break;
} }
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 }; aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
} }
const vimeoLink = link.match(RE_VIMEO); const vimeoLink = link.match(RE_VIMEO);
@@ -119,8 +120,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
aspectRatio = { w: 560, h: 315 }; aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link //warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache //same link next time will be served from cache
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type, warning }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type, warning };
} }
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
@@ -130,27 +135,35 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
link, link,
)}`; )}`;
aspectRatio = { w: 550, h: 550 }; aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
} }
const valLink = link.match(RE_VALTOWN); const valLink = link.match(RE_VALTOWN);
if (valLink) { if (valLink) {
link = link =
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed"); valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
} }
if (RE_TWITTER.test(link)) { if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink; let ret: IframeData;
// assume embed code // assume embed code
if (/<blockquote/.test(link)) { if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link); const srcDoc = createSrcDoc(link);
ret = { ret = {
type: "document", type: "document",
srcdoc: () => srcDoc, srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 }, intrinsicSize: { w: 480, h: 480 },
}; };
// assume regular tweet url // assume regular tweet url
} else { } else {
@@ -160,7 +173,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
createSrcDoc( createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`, `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
), ),
aspectRatio: { w: 480, h: 480 }, intrinsicSize: { w: 480, h: 480 },
}; };
} }
embeddedLinkCache.set(originalLink, ret); embeddedLinkCache.set(originalLink, ret);
@@ -168,14 +181,14 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
} }
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink; let ret: IframeData;
// assume embed code // assume embed code
if (/<script>/.test(link)) { if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link); const srcDoc = createSrcDoc(link);
ret = { ret = {
type: "document", type: "document",
srcdoc: () => srcDoc, srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 }, intrinsicSize: { w: 550, h: 720 },
}; };
// assume regular url // assume regular url
} else { } else {
@@ -190,26 +203,26 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; } .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style> </style>
`), `),
aspectRatio: { w: 550, h: 720 }, intrinsicSize: { w: 550, h: 720 },
}; };
} }
embeddedLinkCache.set(link, ret); embeddedLinkCache.set(link, ret);
return ret; return ret;
} }
embeddedLinkCache.set(link, { link, aspectRatio, type }); embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
return { link, aspectRatio, type }; return { link, intrinsicSize: aspectRatio, type };
}; };
export const isEmbeddableOrLabel = ( export const isIframeLikeOrItsLabel = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
): Boolean => { ): Boolean => {
if (isEmbeddableElement(element)) { if (isIframeLikeElement(element)) {
return true; return true;
} }
if (element.type === "text") { if (element.type === "text") {
const container = getContainerElement(element); const container = getContainerElement(element);
if (container && isEmbeddableElement(container)) { if (container && isFrameLikeElement(container)) {
return true; return true;
} }
} }
@@ -217,10 +230,16 @@ export const isEmbeddableOrLabel = (
}; };
export const createPlaceholderEmbeddableLabel = ( export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawEmbeddableElement, element: ExcalidrawIframeLikeElement,
): ExcalidrawElement => { ): ExcalidrawElement => {
const text = let text: string;
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link; if (isIframeElement(element)) {
text = "IFrame element";
} else {
text =
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
}
const fontSize = Math.max( const fontSize = Math.max(
Math.min(element.width / 2, element.width / text.length), Math.min(element.width / 2, element.width / text.length),
element.width / 30, element.width / 30,
@@ -268,7 +287,8 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable", type: "embeddable",
}), }),
}, },
commitToHistory: false, storeAction: StoreAction.NONE,
}; };
}, },
}); });
+4 -16
View File
@@ -2,7 +2,6 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@@ -50,11 +49,7 @@ export {
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { export { isTextElement, isExcalidrawElement } from "./typeChecks";
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg"; export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
@@ -74,17 +69,10 @@ export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
(el) => !el.isDeleted && !isInvisiblySmallElement(el), (el) => !el.isDeleted && !isInvisiblySmallElement(el),
) as readonly NonDeletedExcalidrawElement[]; ) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) => export const getNonDeletedElements = <T extends ExcalidrawElement>(
elements.filter( elements: readonly T[],
(element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
) => ) =>
frames.filter( elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>( export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T, element: T,
+3 -3
View File
@@ -33,7 +33,6 @@ import {
InteractiveCanvasAppState, InteractiveCanvasAppState,
} from "../types"; } from "../types";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import History from "../history";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { import {
@@ -48,6 +47,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants"; import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import { Store } from "../store";
const editorMidPointsCache: { const editorMidPointsCache: {
version: number | null; version: number | null;
@@ -602,7 +602,7 @@ export class LinearElementEditor {
static handlePointerDown( static handlePointerDown(
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
appState: AppState, appState: AppState,
history: History, store: Store,
scenePointer: { x: number; y: number }, scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor, linearElementEditor: LinearElementEditor,
): { ): {
@@ -654,7 +654,7 @@ export class LinearElementEditor {
}); });
ret.didAddPoint = true; ret.didAddPoint = true;
} }
history.resumeRecording(); store.resumeCapturing();
ret.linearElementEditor = { ret.linearElementEditor = {
...linearElementEditor, ...linearElementEditor,
pointerDownState: { pointerDownState: {
+17 -14
View File
@@ -106,24 +106,27 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>( export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement, element: TElement,
updates: ElementUpdate<TElement>, updates: ElementUpdate<TElement>,
forceUpdate: boolean = false,
): TElement => { ): TElement => {
let didChange = false; if (!forceUpdate) {
for (const key in updates) { let didChange = false;
const value = (updates as any)[key]; for (const key in updates) {
if (typeof value !== "undefined") { const value = (updates as any)[key];
if ( if (typeof value !== "undefined") {
(element as any)[key] === value && if (
// if object, always update because its attrs could have changed (element as any)[key] === value &&
(typeof value !== "object" || value === null) // if object, always update because its attrs could have changed
) { (typeof value !== "object" || value === null)
continue; ) {
continue;
}
didChange = true;
} }
didChange = true;
} }
}
if (!didChange) { if (!didChange) {
return element; return element;
}
} }
return { return {
+33
View File
@@ -14,6 +14,8 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
ExcalidrawMagicFrameElement,
ExcalidrawIframeElement,
} from "../element/types"; } from "../element/types";
import { import {
arrayToMap, arrayToMap,
@@ -53,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional<
| "angle" | "angle"
| "groupIds" | "groupIds"
| "frameId" | "frameId"
| "fractionalIndex"
| "boundElements" | "boundElements"
| "seed" | "seed"
| "version" | "version"
@@ -86,6 +89,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
angle = 0, angle = 0,
groupIds = [], groupIds = [],
frameId = null, frameId = null,
// TODO: think about this more
fractionalIndex = null,
roundness = null, roundness = null,
boundElements = null, boundElements = null,
link = null, link = null,
@@ -111,6 +116,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity, opacity,
groupIds, groupIds,
frameId, frameId,
fractionalIndex,
roundness, roundness,
seed: rest.seed ?? randomInteger(), seed: rest.seed ?? randomInteger(),
version: rest.version || 1, version: rest.version || 1,
@@ -143,6 +149,16 @@ export const newEmbeddableElement = (
}; };
}; };
export const newIframeElement = (
opts: {
type: "iframe";
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawIframeElement> => {
return {
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
};
};
export const newFrameElement = ( export const newFrameElement = (
opts: { opts: {
name?: string; name?: string;
@@ -160,6 +176,23 @@ export const newFrameElement = (
return frameElement; return frameElement;
}; };
export const newMagicFrameElement = (
opts: {
name?: string;
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawMagicFrameElement> => {
const frameElement = newElementWith(
{
..._newElementBase<ExcalidrawMagicFrameElement>("magicframe", opts),
type: "magicframe",
name: opts?.name || null,
},
{},
);
return frameElement;
};
/** computes element x/y offset based on textAlign/verticalAlign */ /** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = ( const getTextElementPositionOffsets = (
opts: { opts: {
+3 -3
View File
@@ -27,7 +27,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement, isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@@ -163,7 +163,7 @@ const rotateSingleElement = (
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle: number; let angle: number;
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
angle = 0; angle = 0;
} else { } else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx); angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
@@ -900,7 +900,7 @@ const rotateMultipleElements = (
} }
elements elements
.filter((element) => element.type !== "frame") .filter((element) => !isFrameLikeElement(element))
.forEach((element) => { .forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
+2 -1
View File
@@ -1,6 +1,7 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils"; import { getFontString, arrayToMap, isTestEnv } from "../utils";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
@@ -867,7 +868,7 @@ const VALID_CONTAINER_TYPES = new Set([
]); ]);
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElement["type"]; type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type); }) => VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
+2 -2
View File
@@ -8,7 +8,7 @@ import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types"; import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isFrameElement, isLinearElement } from "./typeChecks"; import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene"; import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
@@ -257,7 +257,7 @@ export const getTransformHandles = (
} }
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameElement(element)) { } else if (isFrameLikeElement(element)) {
omitSides = { omitSides = {
rotation: true, rotation: true,
}; };
+44 -20
View File
@@ -1,5 +1,5 @@
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { AppState } from "../types"; import { ElementOrToolType } from "../types";
import { MarkNonNullable } from "../utility-types"; import { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils"; import { assertNever } from "../utils";
import { import {
@@ -8,7 +8,6 @@ import {
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@@ -16,21 +15,13 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement, ExcalidrawFrameElement,
RoundnessType, RoundnessType,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
ExcalidrawIframeElement,
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
} from "./types"; } from "./types";
export const isGenericElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawGenericElement => {
return (
element != null &&
(element.type === "selection" ||
element.type === "rectangle" ||
element.type === "diamond" ||
element.type === "ellipse" ||
element.type === "embeddable")
);
};
export const isInitializedImageElement = ( export const isInitializedImageElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => { ): element is InitializedExcalidrawImageElement => {
@@ -49,6 +40,20 @@ export const isEmbeddableElement = (
return !!element && element.type === "embeddable"; return !!element && element.type === "embeddable";
}; };
export const isIframeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawIframeElement => {
return !!element && element.type === "iframe";
};
export const isIframeLikeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawIframeLikeElement => {
return (
!!element && (element.type === "iframe" || element.type === "embeddable")
);
};
export const isTextElement = ( export const isTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => { ): element is ExcalidrawTextElement => {
@@ -61,6 +66,21 @@ export const isFrameElement = (
return element != null && element.type === "frame"; return element != null && element.type === "frame";
}; };
export const isMagicFrameElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawMagicFrameElement => {
return element != null && element.type === "magicframe";
};
export const isFrameLikeElement = (
element: ExcalidrawElement | null,
): element is ExcalidrawFrameLikeElement => {
return (
element != null &&
(element.type === "frame" || element.type === "magicframe")
);
};
export const isFreeDrawElement = ( export const isFreeDrawElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => { ): element is ExcalidrawFreeDrawElement => {
@@ -68,7 +88,7 @@ export const isFreeDrawElement = (
}; };
export const isFreeDrawElementType = ( export const isFreeDrawElementType = (
elementType: ExcalidrawElement["type"], elementType: ExcalidrawElementType,
): boolean => { ): boolean => {
return elementType === "freedraw"; return elementType === "freedraw";
}; };
@@ -86,7 +106,7 @@ export const isArrowElement = (
}; };
export const isLinearElementType = ( export const isLinearElementType = (
elementType: AppState["activeTool"]["type"], elementType: ElementOrToolType,
): boolean => { ): boolean => {
return ( return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw" elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@@ -105,7 +125,7 @@ export const isBindingElement = (
}; };
export const isBindingElementType = ( export const isBindingElementType = (
elementType: AppState["activeTool"]["type"], elementType: ElementOrToolType,
): boolean => { ): boolean => {
return elementType === "arrow"; return elementType === "arrow";
}; };
@@ -121,8 +141,10 @@ export const isBindableElement = (
element.type === "diamond" || element.type === "diamond" ||
element.type === "ellipse" || element.type === "ellipse" ||
element.type === "image" || element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" || element.type === "embeddable" ||
element.type === "frame" || element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId)) (element.type === "text" && !element.containerId))
); );
}; };
@@ -144,7 +166,7 @@ export const isTextBindableContainer = (
export const isExcalidrawElement = ( export const isExcalidrawElement = (
element: any, element: any,
): element is ExcalidrawElement => { ): element is ExcalidrawElement => {
const type: ExcalidrawElement["type"] | undefined = element?.type; const type: ExcalidrawElementType | undefined = element?.type;
if (!type) { if (!type) {
return false; return false;
} }
@@ -152,12 +174,14 @@ export const isExcalidrawElement = (
case "text": case "text":
case "diamond": case "diamond":
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "ellipse": case "ellipse":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
case "line": case "line":
case "frame": case "frame":
case "magicframe":
case "image": case "image":
case "selection": { case "selection": {
return true; return true;
@@ -190,7 +214,7 @@ export const isBoundToContainer = (
}; };
export const isUsingAdaptiveRadius = (type: string) => export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" || type === "embeddable"; type === "rectangle" || type === "embeddable" || type === "iframe";
export const isUsingProportionalRadius = (type: string) => export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond"; type === "line" || type === "arrow" || type === "diamond";
+38 -1
View File
@@ -7,6 +7,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { MarkNonNullable, ValueOf } from "../utility-types"; import { MarkNonNullable, ValueOf } from "../utility-types";
import { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@@ -49,6 +50,7 @@ type _ExcalidrawElementBase = Readonly<{
Used for deterministic reconciliation of updates during collaboration, Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */ in case the versions (see above) are identical. */
versionNonce: number; versionNonce: number;
fractionalIndex: string | null;
isDeleted: boolean; isDeleted: boolean;
/** List of groups the element belongs to. /** List of groups the element belongs to.
Ordered from deepest to shallowest. */ Ordered from deepest to shallowest. */
@@ -97,6 +99,26 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
validated: boolean | null; validated: boolean | null;
}>; }>;
export type ExcalidrawIframeElement = _ExcalidrawElementBase &
Readonly<{
type: "iframe";
// TODO move later to AI-specific frame
customData?: { generationData?: MagicCacheData };
}>;
export type ExcalidrawIframeLikeElement =
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type IframeData =
| {
intrinsicSize: { w: number; h: number };
warning?: string;
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
);
export type ExcalidrawImageElement = _ExcalidrawElementBase & export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "image"; type: "image";
@@ -117,6 +139,15 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
name: string | null; name: string | null;
}; };
export type ExcalidrawMagicFrameElement = _ExcalidrawElementBase & {
type: "magicframe";
name: string | null;
};
export type ExcalidrawFrameLikeElement =
| ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
/** /**
* These are elements that don't have any additional properties. * These are elements that don't have any additional properties.
*/ */
@@ -138,6 +169,8 @@ export type ExcalidrawElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
@@ -170,8 +203,10 @@ export type ExcalidrawBindableElement =
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement | ExcalidrawEmbeddableElement
| ExcalidrawFrameElement; | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
@@ -217,3 +252,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
export type ExcalidrawElementType = ExcalidrawElement["type"];
+227
View File
@@ -0,0 +1,227 @@
import { mutateElement } from "./element/mutateElement";
import { ExcalidrawElement } from "./element/types";
import {
generateKeyBetween,
generateJitteredKeyBetween,
generateNKeysBetween,
generateNJitteredKeysBetween,
} from "fractional-indexing-jittered";
import { ENV } from "./constants";
type FractionalIndex = ExcalidrawElement["fractionalIndex"];
const isValidFractionalIndex = (
index: FractionalIndex,
predecessor: FractionalIndex,
successor: FractionalIndex,
) => {
if (index) {
if (predecessor && successor) {
return predecessor < index && index < successor;
}
if (successor && !predecessor) {
// first element
return index < successor;
}
if (predecessor && !successor) {
// last element
return predecessor < index;
}
if (!predecessor && !successor) {
return index.length > 0;
}
}
return false;
};
const getContiguousMovedIndices = (
elements: readonly ExcalidrawElement[],
movedElementsMap: Map<string, ExcalidrawElement>,
) => {
const result: number[][] = [];
const contiguous: number[] = [];
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (movedElementsMap.has(element.id)) {
if (contiguous.length) {
if (contiguous[contiguous.length - 1] + 1 === i) {
contiguous.push(i);
} else {
result.push(contiguous.slice());
contiguous.length = 0;
contiguous.push(i);
}
} else {
contiguous.push(i);
}
}
}
if (contiguous.length > 0) {
result.push(contiguous.slice());
}
return result;
};
export const fixFractionalIndices = (
elements: readonly ExcalidrawElement[],
movedElementsMap: Map<string, ExcalidrawElement>,
) => {
const contiguousMovedIndices = getContiguousMovedIndices(
elements,
movedElementsMap,
);
const generateFn =
import.meta.env.MODE === ENV.TEST
? generateNKeysBetween
: generateNJitteredKeysBetween;
for (const movedIndices of contiguousMovedIndices) {
try {
const predecessor =
elements[movedIndices[0] - 1]?.fractionalIndex || null;
const successor =
elements[movedIndices[movedIndices.length - 1] + 1]?.fractionalIndex ||
null;
const newKeys = generateFn(predecessor, successor, movedIndices.length);
for (let i = 0; i < movedIndices.length; i++) {
const element = elements[movedIndices[i]];
mutateElement(
element,
{
fractionalIndex: newKeys[i],
},
false,
);
}
} catch (e) {
console.error("error fixing fractional indices", e);
}
}
return elements as ExcalidrawElement[];
};
const compareStrings = (a: string, b: string) => {
return a < b ? -1 : 1;
};
export const orderByFractionalIndex = (allElements: ExcalidrawElement[]) => {
return allElements.sort((a, b) => {
if (a.fractionalIndex && b.fractionalIndex) {
if (a.fractionalIndex < b.fractionalIndex) {
return -1;
} else if (a.fractionalIndex > b.fractionalIndex) {
return 1;
}
return compareStrings(a.id, b.id);
}
return 0;
});
};
const restoreFractionalIndex = (
predecessor: FractionalIndex,
successor: FractionalIndex,
) => {
const generateFn =
import.meta.env.MODE === ENV.TEST
? generateKeyBetween
: generateJitteredKeyBetween;
if (successor && !predecessor) {
// first element in the array
// insert before successor
return generateFn(null, successor);
}
if (predecessor && !successor) {
// last element in the array
// insert after predecessor
return generateFn(predecessor, null);
}
// both predecessor and successor exist (or both do not)
// insert after predecessor
return generateFn(predecessor, null);
};
/**
* restore the fractional indicies of the elements in the given array such that
* every element in the array has a fractional index smaller than its successor's
*
* neighboring indices might be updated as well
*
* only use this function when restoring or as a fallback to guarantee fractional
* indices consistency
*/
export const restoreFractionalIndicies = (
allElements: readonly ExcalidrawElement[],
) => {
let suc = 1;
const normalized: ExcalidrawElement[] = [];
for (const element of allElements) {
const predecessor =
normalized[normalized.length - 1]?.fractionalIndex || null;
const successor = allElements[suc]?.fractionalIndex || null;
if (
!isValidFractionalIndex(element.fractionalIndex, predecessor, successor)
) {
try {
const nextFractionalIndex = restoreFractionalIndex(
predecessor,
successor,
);
normalized.push({
...element,
fractionalIndex: nextFractionalIndex,
});
} catch (e) {
normalized.push(element);
}
} else {
normalized.push(element);
}
suc++;
}
return normalized;
};
export const validateFractionalIndicies = (
elements: readonly ExcalidrawElement[],
) => {
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
const successor = elements[i + 1];
if (element.fractionalIndex) {
if (
successor &&
successor.fractionalIndex &&
element.fractionalIndex >= successor.fractionalIndex
) {
return false;
}
} else {
return false;
}
}
return true;
};
+49 -35
View File
@@ -5,7 +5,7 @@ import {
} from "./element"; } from "./element";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
@@ -18,11 +18,11 @@ import { arrayToMap } from "./utils";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds"; import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect } from "./packages/utils"; import { doLineSegmentsIntersect } from "./packages/utils";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
@@ -58,7 +58,7 @@ export const bindElementsToFramesAfterDuplication = (
export function isElementIntersectingFrame( export function isElementIntersectingFrame(
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) { ) {
const frameLineSegments = getElementLineSegments(frame); const frameLineSegments = getElementLineSegments(frame);
@@ -75,20 +75,20 @@ export function isElementIntersectingFrame(
export const getElementsCompletelyInFrame = ( export const getElementsCompletelyInFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => ) =>
omitGroupsContainingFrames( omitGroupsContainingFrameLikes(
getElementsWithinSelection(elements, frame, false), getElementsWithinSelection(elements, frame, false),
).filter( ).filter(
(element) => (element) =>
(element.type !== "frame" && !element.frameId) || (!isFrameLikeElement(element) && !element.frameId) ||
element.frameId === frame.id, element.frameId === frame.id,
); );
export const isElementContainingFrame = ( export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
return getElementsWithinSelection(elements, element).some( return getElementsWithinSelection(elements, element).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
@@ -97,12 +97,12 @@ export const isElementContainingFrame = (
export const getElementsIntersectingFrame = ( export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => elements.filter((element) => isElementIntersectingFrame(element, frame)); ) => elements.filter((element) => isElementIntersectingFrame(element, frame));
export const elementsAreInFrameBounds = ( export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] = const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(frame); getElementAbsoluteCoords(frame);
@@ -120,7 +120,7 @@ export const elementsAreInFrameBounds = (
export const elementOverlapsWithFrame = ( export const elementOverlapsWithFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
return ( return (
elementsAreInFrameBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
@@ -134,7 +134,7 @@ export const isCursorInFrame = (
x: number; x: number;
y: number; y: number;
}, },
frame: NonDeleted<ExcalidrawFrameElement>, frame: NonDeleted<ExcalidrawFrameLikeElement>,
) => { ) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame); const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
@@ -148,7 +148,7 @@ export const isCursorInFrame = (
export const groupsAreAtLeastIntersectingTheFrame = ( export const groupsAreAtLeastIntersectingTheFrame = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[], groupIds: readonly string[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const elementsInGroup = groupIds.flatMap((groupId) => const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId), getElementsInGroup(elements, groupId),
@@ -168,7 +168,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
export const groupsAreCompletelyOutOfFrame = ( export const groupsAreCompletelyOutOfFrame = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[], groupIds: readonly string[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const elementsInGroup = groupIds.flatMap((groupId) => const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId), getElementsInGroup(elements, groupId),
@@ -192,14 +192,14 @@ export const groupsAreCompletelyOutOfFrame = (
/** /**
* Returns a map of frameId to frame elements. Includes empty frames. * Returns a map of frameId to frame elements. Includes empty frames.
*/ */
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => { export const groupByFrameLikes = (elements: readonly ExcalidrawElement[]) => {
const frameElementsMap = new Map< const frameElementsMap = new Map<
ExcalidrawElement["id"], ExcalidrawElement["id"],
ExcalidrawElement[] ExcalidrawElement[]
>(); >();
for (const element of elements) { for (const element of elements) {
const frameId = isFrameElement(element) ? element.id : element.frameId; const frameId = isFrameLikeElement(element) ? element.id : element.frameId;
if (frameId && !frameElementsMap.has(frameId)) { if (frameId && !frameElementsMap.has(frameId)) {
frameElementsMap.set(frameId, getFrameChildren(elements, frameId)); frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
} }
@@ -213,12 +213,12 @@ export const getFrameChildren = (
frameId: string, frameId: string,
) => allElements.filter((element) => element.frameId === frameId); ) => allElements.filter((element) => element.frameId === frameId);
export const getFrameElements = ( export const getFrameLikeElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
): ExcalidrawFrameElement[] => { ): ExcalidrawFrameLikeElement[] => {
return allElements.filter((element) => return allElements.filter((element): element is ExcalidrawFrameLikeElement =>
isFrameElement(element), isFrameLikeElement(element),
) as ExcalidrawFrameElement[]; );
}; };
/** /**
@@ -232,7 +232,7 @@ export const getFrameElements = (
export const getRootElements = ( export const getRootElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
) => { ) => {
const frameElements = arrayToMap(getFrameElements(allElements)); const frameElements = arrayToMap(getFrameLikeElements(allElements));
return allElements.filter( return allElements.filter(
(element) => (element) =>
frameElements.has(element.id) || frameElements.has(element.id) ||
@@ -243,7 +243,7 @@ export const getRootElements = (
export const getElementsInResizingFrame = ( export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, appState: AppState,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameChildren(allElements, frame.id); const prevElementsInFrame = getFrameChildren(allElements, frame.id);
@@ -336,9 +336,9 @@ export const getElementsInResizingFrame = (
export const getElementsInNewFrame = ( export const getElementsInNewFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
return omitGroupsContainingFrames( return omitGroupsContainingFrameLikes(
allElements, allElements,
getElementsCompletelyInFrame(allElements, frame), getElementsCompletelyInFrame(allElements, frame),
); );
@@ -356,12 +356,12 @@ export const getContainingFrame = (
if (element.frameId) { if (element.frameId) {
if (elementsMap) { if (elementsMap) {
return (elementsMap.get(element.frameId) || return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameElement; null) as null | ExcalidrawFrameLikeElement;
} }
return ( return (
(Scene.getScene(element)?.getElement( (Scene.getScene(element)?.getElement(
element.frameId, element.frameId,
) as ExcalidrawFrameElement) || null ) as ExcalidrawFrameLikeElement) || null
); );
} }
return null; return null;
@@ -377,7 +377,7 @@ export const getContainingFrame = (
export const addElementsToFrame = ( export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const { currTargetFrameChildrenMap } = allElements.reduce( const { currTargetFrameChildrenMap } = allElements.reduce(
(acc, element, index) => { (acc, element, index) => {
@@ -397,7 +397,7 @@ export const addElementsToFrame = (
// - add bound text elements if not already in the array // - add bound text elements if not already in the array
// - filter out elements that are already in the frame // - filter out elements that are already in the frame
for (const element of omitGroupsContainingFrames( for (const element of omitGroupsContainingFrameLikes(
allElements, allElements,
elementsToAdd, elementsToAdd,
)) { )) {
@@ -438,7 +438,7 @@ export const removeElementsFromFrame = (
>(); >();
const toRemoveElementsByFrame = new Map< const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"], ExcalidrawFrameLikeElement["id"],
ExcalidrawElement[] ExcalidrawElement[]
>(); >();
@@ -474,7 +474,7 @@ export const removeElementsFromFrame = (
export const removeAllElementsFromFrame = ( export const removeAllElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, appState: AppState,
) => { ) => {
const elementsInFrame = getFrameChildren(allElements, frame.id); const elementsInFrame = getFrameChildren(allElements, frame.id);
@@ -484,7 +484,7 @@ export const removeAllElementsFromFrame = (
export const replaceAllElementsInFrame = ( export const replaceAllElementsInFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
nextElementsInFrame: ExcalidrawElement[], nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, appState: AppState,
) => { ) => {
return addElementsToFrame( return addElementsToFrame(
@@ -524,7 +524,7 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => { elementsToFilter.forEach((element) => {
if ( if (
element.frameId && element.frameId &&
!isFrameElement(element) && !isFrameLikeElement(element) &&
!isElementInFrame(element, allElements, appState) !isElementInFrame(element, allElements, appState)
) { ) {
elementsToRemove.add(element); elementsToRemove.add(element);
@@ -540,7 +540,7 @@ export const updateFrameMembershipOfSelectedElements = (
* filters out elements that are inside groups that contain a frame element * filters out elements that are inside groups that contain a frame element
* anywhere in the group tree * anywhere in the group tree
*/ */
export const omitGroupsContainingFrames = ( export const omitGroupsContainingFrameLikes = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
/** subset of elements you want to filter. Optional perf optimization so we /** subset of elements you want to filter. Optional perf optimization so we
* don't have to filter all elements unnecessarily * don't have to filter all elements unnecessarily
@@ -558,7 +558,9 @@ export const omitGroupsContainingFrames = (
const rejectedGroupIds = new Set<string>(); const rejectedGroupIds = new Set<string>();
for (const groupId of uniqueGroupIds) { for (const groupId of uniqueGroupIds) {
if ( if (
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el)) getElementsInGroup(allElements, groupId).some((el) =>
isFrameLikeElement(el),
)
) { ) {
rejectedGroupIds.add(groupId); rejectedGroupIds.add(groupId);
} }
@@ -636,7 +638,7 @@ export const isElementInFrame = (
} }
for (const elementInGroup of allElementsInGroup) { for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) { if (isFrameLikeElement(elementInGroup)) {
return false; return false;
} }
} }
@@ -650,3 +652,15 @@ export const isElementInFrame = (
return false; return false;
}; };
export const getFrameLikeTitle = (
element: ExcalidrawFrameLikeElement,
frameIdx: number,
) => {
const existingName = element.name?.trim();
if (existingName) {
return existingName;
}
// TODO name frames AI only is specific to AI frames
return isFrameElement(element) ? `Frame ${frameIdx}` : `AI Frame ${frameIdx}`;
};
+155 -247
View File
@@ -1,265 +1,173 @@
import { AppState } from "./types"; import { AppStateChange, ElementsChange } from "./change";
import { ExcalidrawElement } from "./element/types"; import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks"; import { AppState } from "./types";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
export interface HistoryEntry { // TODO_UNDO: think about limiting the depth of stack
appState: ReturnType<typeof clearAppStatePropertiesForHistory>; export class History {
elements: ExcalidrawElement[]; private readonly undoStack: HistoryEntry[] = [];
} private readonly redoStack: HistoryEntry[] = [];
interface DehydratedExcalidrawElement { public get isUndoStackEmpty() {
id: string; return this.undoStack.length === 0;
versionNonce: number;
}
interface DehydratedHistoryEntry {
appState: string;
elements: DehydratedExcalidrawElement[];
}
const clearAppStatePropertiesForHistory = (appState: AppState) => {
return {
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
viewBackgroundColor: appState.viewBackgroundColor,
editingLinearElement: appState.editingLinearElement,
editingGroupId: appState.editingGroupId,
name: appState.name,
};
};
class History {
private elementCache = new Map<string, Map<number, ExcalidrawElement>>();
private recording: boolean = true;
private stateHistory: DehydratedHistoryEntry[] = [];
private redoStack: DehydratedHistoryEntry[] = [];
private lastEntry: HistoryEntry | null = null;
private hydrateHistoryEntry({
appState,
elements,
}: DehydratedHistoryEntry): HistoryEntry {
return {
appState: JSON.parse(appState),
elements: elements.map((dehydratedExcalidrawElement) => {
const element = this.elementCache
.get(dehydratedExcalidrawElement.id)
?.get(dehydratedExcalidrawElement.versionNonce);
if (!element) {
throw new Error(
`Element not found: ${dehydratedExcalidrawElement.id}:${dehydratedExcalidrawElement.versionNonce}`,
);
}
return element;
}),
};
} }
private dehydrateHistoryEntry({ public get isRedoStackEmpty() {
appState, return this.redoStack.length === 0;
elements,
}: HistoryEntry): DehydratedHistoryEntry {
return {
appState: JSON.stringify(appState),
elements: elements.map((element: ExcalidrawElement) => {
if (!this.elementCache.has(element.id)) {
this.elementCache.set(element.id, new Map());
}
const versions = this.elementCache.get(element.id)!;
if (!versions.has(element.versionNonce)) {
versions.set(element.versionNonce, deepCopyElement(element));
}
return {
id: element.id,
versionNonce: element.versionNonce,
};
}),
};
} }
getSnapshotForTest() { public clear() {
return { this.undoStack.length = 0;
recording: this.recording,
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
};
}
clear() {
this.stateHistory.length = 0;
this.redoStack.length = 0; this.redoStack.length = 0;
this.lastEntry = null;
this.elementCache.clear();
}
private generateEntry = (
appState: AppState,
elements: readonly ExcalidrawElement[],
): DehydratedHistoryEntry =>
this.dehydrateHistoryEntry({
appState: clearAppStatePropertiesForHistory(appState),
elements: elements.reduce((elements, element) => {
if (
isLinearElement(element) &&
appState.multiElement &&
appState.multiElement.id === element.id
) {
// don't store multi-point arrow if still has only one point
if (
appState.multiElement &&
appState.multiElement.id === element.id &&
element.points.length < 2
) {
return elements;
}
elements.push({
...element,
// don't store last point if not committed
points:
element.lastCommittedPoint !==
element.points[element.points.length - 1]
? element.points.slice(0, -1)
: element.points,
});
} else {
elements.push(element);
}
return elements;
}, [] as Mutable<typeof elements>),
});
shouldCreateEntry(nextEntry: HistoryEntry): boolean {
const { lastEntry } = this;
if (!lastEntry) {
return true;
}
if (nextEntry.elements.length !== lastEntry.elements.length) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
for (let i = nextEntry.elements.length - 1; i > -1; i--) {
const prev = nextEntry.elements[i];
const next = lastEntry.elements[i];
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
// note: this is safe because entry's appState is guaranteed no excess props
let key: keyof typeof nextEntry.appState;
for (key in nextEntry.appState) {
if (key === "editingLinearElement") {
if (
nextEntry.appState[key]?.elementId ===
lastEntry.appState[key]?.elementId
) {
continue;
}
}
if (key === "selectedElementIds" || key === "selectedGroupIds") {
continue;
}
if (nextEntry.appState[key] !== lastEntry.appState[key]) {
return true;
}
}
return false;
}
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
const newEntryDehydrated = this.generateEntry(appState, elements);
const newEntry: HistoryEntry = this.hydrateHistoryEntry(newEntryDehydrated);
if (newEntry) {
if (!this.shouldCreateEntry(newEntry)) {
return;
}
this.stateHistory.push(newEntryDehydrated);
this.lastEntry = newEntry;
// As a new entry was pushed, we invalidate the redo stack
this.clearRedoStack();
}
}
clearRedoStack() {
this.redoStack.splice(0, this.redoStack.length);
}
redoOnce(): HistoryEntry | null {
if (this.redoStack.length === 0) {
return null;
}
const entryToRestore = this.redoStack.pop();
if (entryToRestore !== undefined) {
this.stateHistory.push(entryToRestore);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
}
undoOnce(): HistoryEntry | null {
if (this.stateHistory.length === 1) {
return null;
}
const currentEntry = this.stateHistory.pop();
const entryToRestore = this.stateHistory[this.stateHistory.length - 1];
if (currentEntry !== undefined) {
this.redoStack.push(currentEntry);
return this.hydrateHistoryEntry(entryToRestore);
}
return null;
} }
/** /**
* Updates history's `lastEntry` to latest app state. This is necessary * Record a local change which will go into the history
* when doing undo/redo which itself doesn't commit to history, but updates
* app state in a way that would break `shouldCreateEntry` which relies on
* `lastEntry` to reflect last comittable history state.
* We can't update `lastEntry` from within history when calling undo/redo
* because the action potentially mutates appState/elements before storing
* it.
*/ */
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) { public record(
this.lastEntry = this.hydrateHistoryEntry( elementsChange: ElementsChange,
this.generateEntry(appState, elements), appStateChange: AppStateChange,
); ) {
} const entry = HistoryEntry.create(appStateChange, elementsChange);
// Suspicious that this is called so many places. Seems error-prone. if (!entry.isEmpty()) {
resumeRecording() { this.undoStack.push(entry);
this.recording = true;
}
record(state: AppState, elements: readonly ExcalidrawElement[]) { // As a new entry was pushed, we invalidate the redo stack
if (this.recording) { this.redoStack.length = 0;
this.pushEntry(state, elements);
this.recording = false;
} }
} }
public undo(elements: Map<string, ExcalidrawElement>, appState: AppState) {
return this.perform(this.undoOnce.bind(this), elements, appState);
}
public redo(elements: Map<string, ExcalidrawElement>, appState: AppState) {
return this.perform(this.redoOnce.bind(this), elements, appState);
}
private perform(
action: typeof this.undoOnce | typeof this.redoOnce,
elements: Map<string, ExcalidrawElement>,
appState: AppState,
): [Map<string, ExcalidrawElement>, AppState] | void {
let historyEntry = action(elements);
// Nothing to undo / redo
if (historyEntry === null) {
return;
}
let nextElements = elements;
let nextAppState = appState;
let containsVisibleChange = false;
// Iterate through the history entries in case they result in no visible changes
while (historyEntry) {
[nextElements, nextAppState, containsVisibleChange] =
historyEntry.applyTo(nextElements, nextAppState);
// TODO_UNDO: Be very carefuly here, as we could accidentaly iterate through the whole stack
if (containsVisibleChange) {
break;
}
historyEntry = action(elements);
}
return [nextElements, nextAppState];
}
private undoOnce(
elements: Map<string, ExcalidrawElement>,
): HistoryEntry | null {
if (!this.undoStack.length) {
return null;
}
const undoEntry = this.undoStack.pop();
if (undoEntry !== undefined) {
const redoEntry = undoEntry.applyLatestChanges(elements, "to");
this.redoStack.push(redoEntry);
return undoEntry.inverse();
}
return null;
}
private redoOnce(
elements: Map<string, ExcalidrawElement>,
): HistoryEntry | null {
if (!this.redoStack.length) {
return null;
}
const redoEntry = this.redoStack.pop();
if (redoEntry !== undefined) {
const undoEntry = redoEntry.applyLatestChanges(elements, "from");
this.undoStack.push(undoEntry);
return redoEntry;
}
return null;
}
} }
export default History; export class HistoryEntry {
private constructor(
private readonly appStateChange: AppStateChange,
private readonly elementsChange: ElementsChange,
) {}
public static create(
appStateChange: AppStateChange,
elementsChange: ElementsChange,
) {
return new HistoryEntry(appStateChange, elementsChange);
}
public inverse(): HistoryEntry {
return new HistoryEntry(
this.appStateChange.inverse(),
this.elementsChange.inverse(),
);
}
public applyTo(
elements: Map<string, ExcalidrawElement>,
appState: AppState,
): [Map<string, ExcalidrawElement>, AppState, boolean] {
const [nextElements, elementsContainVisibleChange] =
this.elementsChange.applyTo(elements);
const [nextAppState, appStateContainsVisibleChange] =
this.appStateChange.applyTo(appState, nextElements);
const appliedVisibleChanges =
elementsContainVisibleChange || appStateContainsVisibleChange;
return [nextElements, nextAppState, appliedVisibleChanges];
}
/**
* Apply latest (remote) changes to the history entry, creates new instance of `HistoryEntry`.
*/
public applyLatestChanges(
elements: Map<string, ExcalidrawElement>,
modifierOptions: "from" | "to",
): HistoryEntry {
const updatedElementsChange = this.elementsChange.applyLatestChanges(
elements,
modifierOptions,
);
return HistoryEntry.create(this.appStateChange, updatedElementsChange);
}
public isEmpty(): boolean {
return this.appStateChange.isEmpty() && this.elementsChange.isEmpty();
}
}
+6 -1
View File
@@ -11,6 +11,8 @@
"copyAsPng": "Copy to clipboard as PNG", "copyAsPng": "Copy to clipboard as PNG",
"copyAsSvg": "Copy to clipboard as SVG", "copyAsSvg": "Copy to clipboard as SVG",
"copyText": "Copy to clipboard as text", "copyText": "Copy to clipboard as text",
"copySource": "Copy source to clipboard",
"convertToCode": "Convert to code",
"bringForward": "Bring forward", "bringForward": "Bring forward",
"sendToBack": "Send to back", "sendToBack": "Send to back",
"bringToFront": "Bring to front", "bringToFront": "Bring to front",
@@ -218,6 +220,7 @@
}, },
"libraryElementTypeError": { "libraryElementTypeError": {
"embeddable": "Embeddable elements cannot be added to the library.", "embeddable": "Embeddable elements cannot be added to the library.",
"iframe": "IFrame elements cannot be added to the library.",
"image": "Support for adding images to the library coming soon!" "image": "Support for adding images to the library coming soon!"
}, },
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).", "asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
@@ -240,11 +243,13 @@
"link": "Add/ Update link for a selected shape", "link": "Add/ Update link for a selected shape",
"eraser": "Eraser", "eraser": "Eraser",
"frame": "Frame tool", "frame": "Frame tool",
"magicframe": "Wireframe to code",
"embeddable": "Web Embed", "embeddable": "Web Embed",
"laser": "Laser pointer", "laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools", "extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw" "mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings"
}, },
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",
+11
View File
@@ -11,6 +11,17 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## Unreleased
### Features
- Support for multiplayer undo / redo [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
### Breaking Changes
- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348).
- Updates of `elements` or `appState` performed through [`updateScene`](https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx#L282) without `commitToStore` set to `true` require a new parameter `skipSnapshotUpdate` to be set to `true`, if the given update should be locally undo-able with the next user action. In other cases such a parameter shouldn't be needed, i.e. as in during multiplayer collab updates, which shouldn't should not be locally undoable.
## 0.17.0 (2023-11-14) ## 0.17.0 (2023-11-14)
### Features ### Features
+2
View File
@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
children, children,
validateEmbeddable, validateEmbeddable,
renderEmbeddable, renderEmbeddable,
aiEnabled,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@@ -122,6 +123,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onScrollChange={onScrollChange} onScrollChange={onScrollChange}
validateEmbeddable={validateEmbeddable} validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable} renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
> >
{children} {children}
</App> </App>
+2 -2
View File
@@ -6,7 +6,7 @@ import { getDefaultAppState } from "../appState";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
NonDeleted, NonDeleted,
} from "../element/types"; } from "../element/types";
import { restore } from "../data/restore"; import { restore } from "../data/restore";
@@ -26,7 +26,7 @@ type ExportOpts = {
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>; appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null; files: BinaryFiles | null;
maxWidthOrHeight?: number; maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameElement | null; exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: ( getDimensions?: (
width: number, width: number,
height: number, height: number,
+13 -1
View File
@@ -13,6 +13,7 @@ import {
isInitializedImageElement, isInitializedImageElement,
isArrowElement, isArrowElement,
hasBoundTextElement, hasBoundTextElement,
isMagicFrameElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds"; import { getElementAbsoluteCoords } from "../element/bounds";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
@@ -272,6 +273,7 @@ const drawElementOnCanvas = (
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {
@@ -594,6 +596,7 @@ export const renderElement = (
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
switch (element.type) { switch (element.type) {
case "magicframe":
case "frame": { case "frame": {
if (appState.frameRendering.enabled && appState.frameRendering.outline) { if (appState.frameRendering.enabled && appState.frameRendering.outline) {
context.save(); context.save();
@@ -606,6 +609,12 @@ export const renderElement = (
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor; context.strokeStyle = FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames
if (isMagicFrameElement(element)) {
context.strokeStyle =
appState.theme === "light" ? "#7affd7" : "#1d8264";
}
if (FRAME_STYLE.radius && context.roundRect) { if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath(); context.beginPath();
context.roundRect( context.roundRect(
@@ -666,6 +675,7 @@ export const renderElement = (
case "arrow": case "arrow":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": { case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call // TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords) // beforehand because math helpers (such as getElementAbsoluteCoords)
@@ -951,6 +961,7 @@ export const renderElementToSvg = (
addToRoot(g || node, element); addToRoot(g || node, element);
break; break;
} }
case "iframe":
case "embeddable": { case "embeddable": {
// render placeholder rectangle // render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, true); const shape = ShapeCache.generateElementShape(element, true);
@@ -1252,7 +1263,8 @@ export const renderElementToSvg = (
break; break;
} }
// frames are not rendered and only acts as a container // frames are not rendered and only acts as a container
case "frame": { case "frame":
case "magicframe": {
if ( if (
renderConfig.frameRendering.enabled && renderConfig.frameRendering.enabled &&
renderConfig.frameRendering.outline renderConfig.frameRendering.outline
+16 -12
View File
@@ -16,7 +16,7 @@ import {
NonDeleted, NonDeleted,
GroupId, GroupId,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
} from "../element/types"; } from "../element/types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@@ -70,11 +70,12 @@ import {
import { renderSnaps } from "./renderSnaps"; import { renderSnaps } from "./renderSnaps";
import { import {
isEmbeddableElement, isEmbeddableElement,
isFrameElement, isFrameLikeElement,
isIframeLikeElement,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
isEmbeddableOrLabel, isIframeLikeOrItsLabel,
createPlaceholderEmbeddableLabel, createPlaceholderEmbeddableLabel,
} from "../element/embeddable"; } from "../element/embeddable";
import { import {
@@ -362,7 +363,7 @@ const renderLinearElementPointHighlight = (
}; };
const frameClip = ( const frameClip = (
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
@@ -515,7 +516,7 @@ const _renderInteractiveScene = ({
} }
const isFrameSelected = selectedElements.some((element) => const isFrameSelected = selectedElements.some((element) =>
isFrameElement(element), isFrameLikeElement(element),
); );
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
@@ -963,7 +964,7 @@ const _renderStaticScene = ({
// Paint visible elements // Paint visible elements
visibleElements visibleElements
.filter((el) => !isEmbeddableOrLabel(el)) .filter((el) => !isIframeLikeOrItsLabel(el))
.forEach((element) => { .forEach((element) => {
try { try {
const frameId = element.frameId || appState.frameToHighlight?.id; const frameId = element.frameId || appState.frameToHighlight?.id;
@@ -996,15 +997,16 @@ const _renderStaticScene = ({
// render embeddables on top // render embeddables on top
visibleElements visibleElements
.filter((el) => isEmbeddableOrLabel(el)) .filter((el) => isIframeLikeOrItsLabel(el))
.forEach((element) => { .forEach((element) => {
try { try {
const render = () => { const render = () => {
renderElement(element, rc, context, renderConfig, appState); renderElement(element, rc, context, renderConfig, appState);
if ( if (
isEmbeddableElement(element) && isIframeLikeElement(element) &&
(isExporting || !element.validated) && (isExporting ||
(isEmbeddableElement(element) && !element.validated)) &&
element.width && element.width &&
element.height element.height
) { ) {
@@ -1242,8 +1244,10 @@ const renderBindingHighlightForBindableElement = (
case "rectangle": case "rectangle":
case "text": case "text":
case "image": case "image":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
strokeRectWithRotation( strokeRectWithRotation(
context, context,
x1 - padding, x1 - padding,
@@ -1284,7 +1288,7 @@ const renderBindingHighlightForBindableElement = (
const renderFrameHighlight = ( const renderFrameHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
frame: NonDeleted<ExcalidrawFrameElement>, frame: NonDeleted<ExcalidrawFrameLikeElement>,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const width = x2 - x1; const width = x2 - x1;
@@ -1469,7 +1473,7 @@ export const renderSceneToSvg = (
}; };
// render elements // render elements
elements elements
.filter((el) => !isEmbeddableOrLabel(el)) .filter((el) => !isIframeLikeOrItsLabel(el))
.forEach((element) => { .forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {
@@ -1490,7 +1494,7 @@ export const renderSceneToSvg = (
// render embeddables on top // render embeddables on top
elements elements
.filter((el) => isEmbeddableElement(el)) .filter((el) => isIframeLikeElement(el))
.forEach((element) => { .forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {
+45 -23
View File
@@ -2,19 +2,20 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
} from "../element/types"; } from "../element/types";
import { import { getNonDeletedElements, isNonDeletedElement } from "../element";
getNonDeletedElements,
getNonDeletedFrames,
isNonDeletedElement,
} from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { AppState } from "../types"; import { AppState } from "../types";
import { Assert, SameType } from "../utility-types"; import { Assert, SameType } from "../utility-types";
import { randomInteger } from "../random"; import { randomInteger } from "../random";
import {
fixFractionalIndices,
validateFractionalIndicies,
} from "../fractionalIndex";
import { arrayToMap } from "../utils";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"]; type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey; type ElementKey = ExcalidrawElement | ElementIdKey;
@@ -107,8 +108,9 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = []; private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
private frames: readonly ExcalidrawFrameElement[] = []; [];
private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
private selectedElementsCache: { private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null; selectedElementIds: AppState["selectedElementIds"] | null;
@@ -125,6 +127,10 @@ class Scene {
return this.elements; return this.elements;
} }
getElementsMapIncludingDeleted() {
return this.elementsMap;
}
getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] { getNonDeletedElements(): readonly NonDeletedExcalidrawElement[] {
return this.nonDeletedElements; return this.nonDeletedElements;
} }
@@ -179,8 +185,8 @@ class Scene {
return selectedElements; return selectedElements;
} }
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] { getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
return this.nonDeletedFrames; return this.nonDeletedFramesLikes;
} }
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null { getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
@@ -232,21 +238,34 @@ class Scene {
replaceAllElements( replaceAllElements(
nextElements: readonly ExcalidrawElement[], nextElements: readonly ExcalidrawElement[],
mapElementIds = true, mapOfIndicesToFix?: Map<string, ExcalidrawElement>,
) { ) {
this.elements = nextElements; let _nextElements;
const nextFrames: ExcalidrawFrameElement[] = []; if (mapOfIndicesToFix) {
_nextElements = fixFractionalIndices(nextElements, mapOfIndicesToFix);
} else {
_nextElements = nextElements;
}
if (import.meta.env.DEV) {
if (!validateFractionalIndicies(_nextElements)) {
console.error("fractional indices consistency has been compromised");
}
}
this.elements = _nextElements;
const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
this.elementsMap.clear(); this.elementsMap.clear();
nextElements.forEach((element) => { _nextElements.forEach((element) => {
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
nextFrames.push(element); nextFrameLikes.push(element);
} }
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this); Scene.mapElementToScene(element, this);
}); });
this.nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = getNonDeletedElements(this.elements);
this.frames = nextFrames; this.frames = nextFrameLikes;
this.nonDeletedFrames = getNonDeletedFrames(this.frames); this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
this.informMutation(); this.informMutation();
} }
@@ -277,7 +296,7 @@ class Scene {
destroy() { destroy() {
this.nonDeletedElements = []; this.nonDeletedElements = [];
this.elements = []; this.elements = [];
this.nonDeletedFrames = []; this.nonDeletedFramesLikes = [];
this.frames = []; this.frames = [];
this.elementsMap.clear(); this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null; this.selectedElementsCache.selectedElementIds = null;
@@ -306,7 +325,7 @@ class Scene {
element, element,
...this.elements.slice(index), ...this.elements.slice(index),
]; ];
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements, arrayToMap([element]));
} }
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) { insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
@@ -321,14 +340,17 @@ class Scene {
...this.elements.slice(index), ...this.elements.slice(index),
]; ];
this.replaceAllElements(nextElements); this.replaceAllElements(nextElements, arrayToMap(elements));
} }
addNewElement = (element: ExcalidrawElement) => { addNewElement = (element: ExcalidrawElement) => {
if (element.frameId) { if (element.frameId) {
this.insertElementAtIndex(element, this.getElementIndex(element.frameId)); this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
} else { } else {
this.replaceAllElements([...this.elements, element]); this.replaceAllElements(
[...this.elements, element],
arrayToMap([element]),
);
} }
}; };
+24 -6
View File
@@ -14,7 +14,12 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils"; import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants"; import { ROUGHNESS } from "../constants";
import { isLinearElement } from "../element/typeChecks"; import {
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
isLinearElement,
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -78,6 +83,7 @@ export const generateRoughOptions = (
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {
@@ -109,13 +115,13 @@ export const generateRoughOptions = (
} }
}; };
const modifyEmbeddableForRoughOptions = ( const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
isExporting: boolean, isExporting: boolean,
) => { ) => {
if ( if (
element.type === "embeddable" && isIframeLikeElement(element) &&
(isExporting || !element.validated) && (isExporting || (isEmbeddableElement(element) && !element.validated)) &&
isTransparent(element.backgroundColor) && isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor) isTransparent(element.strokeColor)
) { ) {
@@ -125,6 +131,16 @@ const modifyEmbeddableForRoughOptions = (
backgroundColor: "#d3d3d3", backgroundColor: "#d3d3d3",
fillStyle: "solid", fillStyle: "solid",
} as const; } as const;
} else if (isIframeElement(element)) {
return {
...element,
strokeColor: isTransparent(element.strokeColor)
? "#000000"
: element.strokeColor,
backgroundColor: isTransparent(element.backgroundColor)
? "#f4f4f6"
: element.backgroundColor,
};
} }
return element; return element;
}; };
@@ -143,6 +159,7 @@ export const _generateElementShape = (
): Drawable | Drawable[] | null => { ): Drawable | Drawable[] | null => {
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": { case "embeddable": {
let shape: ElementShapes[typeof element.type]; let shape: ElementShapes[typeof element.type];
// this is for rendering the stroke/bg of the embeddable, especially // this is for rendering the stroke/bg of the embeddable, especially
@@ -159,7 +176,7 @@ export const _generateElementShape = (
h - r h - r
} L 0 ${r} Q 0 0, ${r} 0`, } L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions( generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting), modifyIframeLikeForRoughOptions(element, isExporting),
true, true,
), ),
); );
@@ -170,7 +187,7 @@ export const _generateElementShape = (
element.width, element.width,
element.height, element.height,
generateRoughOptions( generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting), modifyIframeLikeForRoughOptions(element, isExporting),
false, false,
), ),
); );
@@ -373,6 +390,7 @@ export const _generateElementShape = (
return shape; return shape;
} }
case "frame": case "frame":
case "magicframe":
case "text": case "text":
case "image": { case "image": {
const shape: ElementShapes[typeof element.type] = null; const shape: ElementShapes[typeof element.type] = null;
+18 -13
View File
@@ -1,22 +1,25 @@
import { isEmbeddableElement } from "../element/typeChecks"; import { isIframeElement } from "../element/typeChecks";
import { import {
ExcalidrawEmbeddableElement, ExcalidrawIframeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { ElementOrToolType } from "../types";
export const hasBackground = (type: string) => export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
type === "line" || type === "line" ||
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: string) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame"; type !== "image" && type !== "frame" && type !== "magicframe";
export const hasStrokeWidth = (type: string) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
@@ -24,22 +27,24 @@ export const hasStrokeWidth = (type: string) =>
type === "arrow" || type === "arrow" ||
type === "line"; type === "line";
export const hasStrokeStyle = (type: string) => export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
type === "arrow" || type === "arrow" ||
type === "line"; type === "line";
export const canChangeRoundness = (type: string) => export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "arrow" || type === "arrow" ||
type === "line" || type === "line" ||
type === "diamond"; type === "diamond";
export const canHaveArrowheads = (type: string) => type === "arrow"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = ( export const getElementAtPosition = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -67,7 +72,7 @@ export const getElementsAtPosition = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => { ) => {
const embeddables: ExcalidrawEmbeddableElement[] = []; const iframeLikes: ExcalidrawIframeElement[] = [];
// The parameter elements comes ordered from lower z-index to higher. // The parameter elements comes ordered from lower z-index to higher.
// We want to preserve that order on the returned array. // We want to preserve that order on the returned array.
// Exception being embeddables which should be on top of everything else in // Exception being embeddables which should be on top of everything else in
@@ -75,13 +80,13 @@ export const getElementsAtPosition = (
const elsAtPos = elements.filter((element) => { const elsAtPos = elements.filter((element) => {
const hit = !element.isDeleted && isAtPositionFn(element); const hit = !element.isDeleted && isAtPositionFn(element);
if (hit) { if (hit) {
if (isEmbeddableElement(element)) { if (isIframeElement(element)) {
embeddables.push(element); iframeLikes.push(element);
return false; return false;
} }
return true; return true;
} }
return false; return false;
}); });
return elsAtPos.concat(embeddables); return elsAtPos.concat(iframeLikes);
}; };
+26 -13
View File
@@ -1,7 +1,7 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
@@ -27,11 +27,16 @@ import {
updateImageCache, updateImageCache,
} from "../element/image"; } from "../element/image";
import { elementsOverlappingBBox } from "../packages/withinBounds"; import { elementsOverlappingBBox } from "../packages/withinBounds";
import { getFrameElements, getRootElements } from "../frame"; import {
import { isFrameElement, newTextElement } from "../element"; getFrameLikeElements,
getFrameLikeTitle,
getRootElements,
} from "../frame";
import { newTextElement } from "../element";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import Scene from "./Scene"; import Scene from "./Scene";
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -54,7 +59,7 @@ const __createSceneForElementsHack__ = (
// ids to Scene instances so that we don't override the editor elements // ids to Scene instances so that we don't override the editor elements
// mapping. // mapping.
// We still need to clone the objects themselves to regen references. // We still need to clone the objects themselves to regen references.
scene.replaceAllElements(cloneJSON(elements), false); scene.replaceAllElements(cloneJSON(elements));
return scene; return scene;
}; };
@@ -100,10 +105,15 @@ const addFrameLabelsAsTextElements = (
opts: Pick<AppState, "exportWithDarkMode">, opts: Pick<AppState, "exportWithDarkMode">,
) => { ) => {
const nextElements: NonDeletedExcalidrawElement[] = []; const nextElements: NonDeletedExcalidrawElement[] = [];
let frameIdx = 0; let frameIndex = 0;
let magicFrameIndex = 0;
for (const element of elements) { for (const element of elements) {
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
frameIdx++; if (isFrameElement(element)) {
frameIndex++;
} else {
magicFrameIndex++;
}
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({ let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
x: element.x, x: element.x,
y: element.y - FRAME_STYLE.nameOffsetY, y: element.y - FRAME_STYLE.nameOffsetY,
@@ -114,7 +124,10 @@ const addFrameLabelsAsTextElements = (
strokeColor: opts.exportWithDarkMode strokeColor: opts.exportWithDarkMode
? FRAME_STYLE.nameColorDarkTheme ? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme, : FRAME_STYLE.nameColorLightTheme,
text: element.name || `Frame ${frameIdx}`, text: getFrameLikeTitle(
element,
isFrameElement(element) ? frameIndex : magicFrameIndex,
),
}); });
textElement.y -= textElement.height; textElement.y -= textElement.height;
@@ -129,7 +142,7 @@ const addFrameLabelsAsTextElements = (
}; };
const getFrameRenderingConfig = ( const getFrameRenderingConfig = (
exportingFrame: ExcalidrawFrameElement | null, exportingFrame: ExcalidrawFrameLikeElement | null,
frameRendering: AppState["frameRendering"] | null, frameRendering: AppState["frameRendering"] | null,
): AppState["frameRendering"] => { ): AppState["frameRendering"] => {
frameRendering = frameRendering || getDefaultAppState().frameRendering; frameRendering = frameRendering || getDefaultAppState().frameRendering;
@@ -148,7 +161,7 @@ const prepareElementsForRender = ({
exportWithDarkMode, exportWithDarkMode,
}: { }: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
exportingFrame: ExcalidrawFrameElement | null | undefined; exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
frameRendering: AppState["frameRendering"]; frameRendering: AppState["frameRendering"];
exportWithDarkMode: AppState["exportWithDarkMode"]; exportWithDarkMode: AppState["exportWithDarkMode"];
}) => { }) => {
@@ -184,7 +197,7 @@ export const exportToCanvas = async (
exportBackground: boolean; exportBackground: boolean;
exportPadding?: number; exportPadding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameElement | null; exportingFrame?: ExcalidrawFrameLikeElement | null;
}, },
createCanvas: ( createCanvas: (
width: number, width: number,
@@ -274,7 +287,7 @@ export const exportToSvg = async (
files: BinaryFiles | null, files: BinaryFiles | null,
opts?: { opts?: {
renderEmbeddables?: boolean; renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameElement | null; exportingFrame?: ExcalidrawFrameLikeElement | null;
}, },
): Promise<SVGSVGElement> => { ): Promise<SVGSVGElement> => {
const tempScene = __createSceneForElementsHack__(elements); const tempScene = __createSceneForElementsHack__(elements);
@@ -360,7 +373,7 @@ export const exportToSvg = async (
const offsetX = -minX + exportPadding; const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding; const offsetY = -minY + exportPadding;
const frameElements = getFrameElements(elements); const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = ""; let exportingFrameClipPath = "";
for (const frame of frameElements) { for (const frame of frameElements) {
+3 -3
View File
@@ -4,7 +4,7 @@ import {
} from "../element/types"; } from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element"; import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState, InteractiveCanvasAppState } from "../types"; import { AppState, InteractiveCanvasAppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
@@ -27,7 +27,7 @@ export const excludeElementsInFramesFromSelection = <
const framesInSelection = new Set<T["id"]>(); const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (element.type === "frame") { if (isFrameLikeElement(element)) {
framesInSelection.add(element.id); framesInSelection.add(element.id);
} }
}); });
@@ -190,7 +190,7 @@ export const getSelectedElements = (
if (opts?.includeElementsInFrames) { if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = []; const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (element.type === "frame") { if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach((e) => getFrameChildren(elements, element.id).forEach((e) =>
elementsToInclude.push(e), elementsToInclude.push(e),
); );
+2
View File
@@ -98,6 +98,7 @@ export type ElementShapes = {
rectangle: Drawable; rectangle: Drawable;
ellipse: Drawable; ellipse: Drawable;
diamond: Drawable; diamond: Drawable;
iframe: Drawable;
embeddable: Drawable; embeddable: Drawable;
freedraw: Drawable | null; freedraw: Drawable | null;
arrow: Drawable[]; arrow: Drawable[];
@@ -105,4 +106,5 @@ export type ElementShapes = {
text: null; text: null;
image: null; image: null;
frame: null; frame: null;
magicframe: null;
}; };
-8
View File
@@ -83,14 +83,6 @@ export const SHAPES = [
numericKey: KEYS["0"], numericKey: KEYS["0"],
fillable: false, fillable: false,
}, },
// TODO: frame, create icon and set up numeric key
// {
// icon: RectangleIcon,
// value: "frame",
// key: KEYS.F,
// numericKey: KEYS.SUBTRACT,
// fillable: false,
// },
] as const; ] as const;
export const findShapeByKey = (key: string) => { export const findShapeByKey = (key: string) => {
+9 -7
View File
@@ -1,3 +1,4 @@
import { TOOL_TYPE } from "./constants";
import { import {
Bounds, Bounds,
getCommonBounds, getCommonBounds,
@@ -5,7 +6,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./element/bounds"; } from "./element/bounds";
import { MaybeTransformHandleType } from "./element/transformHandles"; import { MaybeTransformHandleType } from "./element/transformHandles";
import { isBoundToContainer, isFrameElement } from "./element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@@ -262,7 +263,7 @@ const getReferenceElements = (
appState: AppState, appState: AppState,
) => { ) => {
const selectedFrames = selectedElements const selectedFrames = selectedElements
.filter((element) => isFrameElement(element)) .filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id); .map((frame) => frame.id);
return getVisibleAndNonSelectedElements( return getVisibleAndNonSelectedElements(
@@ -1352,10 +1353,11 @@ export const isActiveToolNonLinearSnappable = (
activeToolType: AppState["activeTool"]["type"], activeToolType: AppState["activeTool"]["type"],
) => { ) => {
return ( return (
activeToolType === "rectangle" || activeToolType === TOOL_TYPE.rectangle ||
activeToolType === "ellipse" || activeToolType === TOOL_TYPE.ellipse ||
activeToolType === "diamond" || activeToolType === TOOL_TYPE.diamond ||
activeToolType === "frame" || activeToolType === TOOL_TYPE.frame ||
activeToolType === "image" activeToolType === TOOL_TYPE.magicframe ||
activeToolType === TOOL_TYPE.image
); );
}; };
+307
View File
@@ -0,0 +1,307 @@
import { getDefaultAppState } from "./appState";
import { AppStateChange, ElementsChange } from "./change";
import { deepCopyElement } from "./element/newElement";
import { ExcalidrawElement } from "./element/types";
import { Emitter } from "./emitter";
import { AppState, ObservedAppState } from "./types";
import { isShallowEqual } from "./utils";
const getObservedAppState = (appState: AppState): ObservedAppState => {
return {
name: appState.name,
editingGroupId: appState.editingGroupId,
viewBackgroundColor: appState.viewBackgroundColor,
selectedElementIds: appState.selectedElementIds,
selectedGroupIds: appState.selectedGroupIds,
editingLinearElement: appState.editingLinearElement,
selectedLinearElement: appState.selectedLinearElement, // TODO_UNDO: Think about these two as one level shallow equal is not enough for them (they have new reference even though they shouldn't, sometimes their id does not correspond to selectedElementId)
};
};
/**
* Store which captures the observed changes and emits them as `StoreIncrementEvent` events.
*
* For the future:
* - Store should coordinate the changes and maintain its increments cohesive between different instances.
* - Store increments should be kept as append-only events log, with additional metadata, such as the logical timestamp for conflict-free resolution of increments.
* - Store flow should be bi-directional, not only listening and capturing changes, but mainly receiving increments as commands and applying them to the state.
*
* @experimental this interface is experimental and subject to change.
*/
export interface IStore {
/**
* Capture changes to the @param elements and @param appState by diff calculation and emitting resulting changes as store increment.
* In case the property `onlyUpdatingSnapshot` is set, it will only update the store snapshot, without calculating diffs.
*
* @emits StoreIncrementEvent
*/
capture(elements: Map<string, ExcalidrawElement>, appState: AppState): void;
/**
* Listens to the store increments, emitted by the capture method.
* Suitable for consuming store increments by various system components, such as History, Collab, Storage and etc.
*
* @listens StoreIncrementEvent
*/
listen(
callback: (
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) => void,
): ReturnType<Emitter<StoreIncrementEvent>["on"]>;
/**
* Clears the store instance.
*/
clear(): void;
}
/**
* Represent an increment to the Store.
*/
type StoreIncrementEvent = [
elementsChange: ElementsChange,
appStateChange: AppStateChange,
];
export class Store implements IStore {
private readonly onStoreIncrementEmitter = new Emitter<StoreIncrementEvent>();
private capturingChanges: boolean = false;
private updatingSnapshot: boolean = false;
public snapshot = Snapshot.empty();
public scheduleSnapshotUpdate() {
this.updatingSnapshot = true;
}
// Suspicious that this is called so many places. Seems error-prone.
public resumeCapturing() {
this.capturingChanges = true;
}
public capture(
elements: Map<string, ExcalidrawElement>,
appState: AppState,
): void {
// Quick exit for irrelevant changes
if (!this.capturingChanges && !this.updatingSnapshot) {
return;
}
try {
const nextSnapshot = this.snapshot.clone(elements, appState);
// Optimisation, don't continue if nothing has changed
if (this.snapshot !== nextSnapshot) {
// Calculate and record the changes based on the previous and next snapshot
if (this.capturingChanges) {
const elementsChange = nextSnapshot.meta.didElementsChange
? ElementsChange.calculate(
this.snapshot.elements,
nextSnapshot.elements,
)
: ElementsChange.empty();
const appStateChange = nextSnapshot.meta.didAppStateChange
? AppStateChange.calculate(
this.snapshot.appState,
nextSnapshot.appState,
)
: AppStateChange.empty();
if (!elementsChange.isEmpty() || !appStateChange.isEmpty()) {
// Notify listeners with the increment
this.onStoreIncrementEmitter.trigger(
elementsChange,
appStateChange,
);
}
}
// Update the snapshot
this.snapshot = nextSnapshot;
}
} finally {
// Reset props
this.updatingSnapshot = false;
this.capturingChanges = false;
}
}
public ignoreUncomittedElements(
prevElements: Map<string, ExcalidrawElement>,
nextElements: Map<string, ExcalidrawElement>,
) {
for (const [id, prevElement] of prevElements.entries()) {
const nextElement = nextElements.get(id);
if (!nextElement) {
// Nothing to care about here, elements were forcefully updated
continue;
}
const elementSnapshot = this.snapshot.elements.get(id);
// Uncomitted element's snapshot doesn't exist, or its snapshot has lower version than the local element
if (
!elementSnapshot ||
(elementSnapshot && elementSnapshot.version < prevElement.version)
) {
if (elementSnapshot) {
nextElements.set(id, elementSnapshot);
} else {
nextElements.delete(id);
}
}
}
return nextElements;
}
public listen(
callback: (
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) => void,
) {
return this.onStoreIncrementEmitter.on(callback);
}
public clear(): void {
this.snapshot = Snapshot.empty();
}
public destroy(): void {
this.clear();
this.onStoreIncrementEmitter.destroy();
}
}
class Snapshot {
private constructor(
public readonly elements: Map<string, ExcalidrawElement>,
public readonly appState: ObservedAppState,
public readonly meta: {
didElementsChange: boolean;
didAppStateChange: boolean;
isEmpty?: boolean;
} = {
didElementsChange: false,
didAppStateChange: false,
isEmpty: false,
},
) {}
public static empty() {
return new Snapshot(
new Map(),
getObservedAppState(getDefaultAppState() as AppState),
{ didElementsChange: false, didAppStateChange: false, isEmpty: true },
);
}
public isEmpty() {
return this.meta.isEmpty;
}
/**
* Efficiently clone the existing snapshot.
*
* @returns same instance if there are no changes detected, new Snapshot instance otherwise.
*/
public clone(elements: Map<string, ExcalidrawElement>, appState: AppState) {
const didElementsChange = this.detectChangedElements(elements);
// Not watching over everything from app state, just the relevant props
const nextAppStateSnapshot = getObservedAppState(appState);
const didAppStateChange = this.detectChangedAppState(nextAppStateSnapshot);
// Nothing has changed, so there is no point of continuing further
if (!didElementsChange && !didAppStateChange) {
return this;
}
// Clone only if there was really a change
let nextElementsSnapshot = this.elements;
if (didElementsChange) {
nextElementsSnapshot = this.createElementsSnapshot(elements);
}
const snapshot = new Snapshot(nextElementsSnapshot, nextAppStateSnapshot, {
didElementsChange,
didAppStateChange,
});
return snapshot;
}
/**
* Detect if there any changed elements.
*
* NOTE: we shouldn't use `sceneVersionNonce` instead, as we need to calls this before the scene updates.
*/
private detectChangedElements(nextElements: Map<string, ExcalidrawElement>) {
if (this.elements === nextElements) {
return false;
}
if (this.elements.size !== nextElements.size) {
return true;
}
// loop from right to left as changes are likelier to happen on new elements
const keys = Array.from(nextElements.keys());
for (let i = keys.length - 1; i >= 0; i--) {
const prev = this.elements.get(keys[i]);
const next = nextElements.get(keys[i]);
if (
!prev ||
!next ||
prev.id !== next.id ||
prev.versionNonce !== next.versionNonce
) {
return true;
}
}
return false;
}
private detectChangedAppState(observedAppState: ObservedAppState) {
// TODO_UNDO: Linear element?
return !isShallowEqual(this.appState, observedAppState, {
selectedElementIds: isShallowEqual,
selectedGroupIds: isShallowEqual,
});
}
/**
* Perform structural clone, cloning only elements that changed.
*/
private createElementsSnapshot(nextElements: Map<string, ExcalidrawElement>) {
const clonedElements = new Map();
for (const [id, prevElement] of this.elements.entries()) {
// clone previous elements, never delete, in case nextElements would be just a subset of previous elements
// i.e. during collab, persist or whenenever isDeleted elements are cleared
clonedElements.set(id, prevElement);
}
for (const [id, nextElement] of nextElements.entries()) {
const prevElement = clonedElements.get(id);
// At this point our elements are reconcilled already, meaning the next element is always newer
if (
!prevElement || // element was added
(prevElement && prevElement.versionNonce !== nextElement.versionNonce) // element was updated
) {
clonedElements.set(id, deepCopyElement(nextElement));
}
}
return clonedElements;
}
}
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"endArrowhead": "arrow", "endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -41,8 +42,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 3, "version": 4,
"versionNonce": 401146281, "versionNonce": 2019559783,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@@ -57,6 +58,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -75,8 +77,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2, "strokeWidth": 2,
"type": "diamond", "type": "diamond",
"updated": 1, "updated": 1,
"version": 2, "version": 3,
"versionNonce": 453191, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@@ -91,6 +93,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -109,8 +112,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2, "strokeWidth": 2,
"type": "ellipse", "type": "ellipse",
"updated": 1, "updated": 1,
"version": 2, "version": 3,
"versionNonce": 453191, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@@ -125,6 +128,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"endArrowhead": null, "endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -156,8 +160,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2, "strokeWidth": 2,
"type": "line", "type": "line",
"updated": 1, "updated": 1,
"version": 3, "version": 4,
"versionNonce": 401146281, "versionNonce": 2019559783,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,
@@ -172,6 +176,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElements": null, "boundElements": null,
"fillStyle": "solid", "fillStyle": "solid",
"fractionalIndex": "a0",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": 50, "height": 50,
@@ -190,8 +195,8 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 3,
"versionNonce": 453191, "versionNonce": 401146281,
"width": 30, "width": 30,
"x": 30, "x": 30,
"y": 20, "y": 20,

Some files were not shown because too many files have changed in this diff Show More