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
130 changed files with 5165 additions and 3992 deletions
-2
View File
@@ -13,8 +13,6 @@ VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!
-2
View File
@@ -9,8 +9,6 @@ VITE_APP_PORTAL_URL=https://portal.excalidraw.com
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL=
@@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage |
| --- | --- | --- |
| [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 |
| [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 |
@@ -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 |
| `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. |
| `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
function App() {
+1 -1
View File
@@ -39,7 +39,7 @@ Since Vite removes env variables by default, you can update the vite config to e
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
"process.env.IS_PREACT": process.env.IS_PREACT,
},
```
@@ -93,7 +93,7 @@ Since Vite removes env variables by default, you can update the vite config to e
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
"process.env.IS_PREACT": process.env.IS_PREACT,
},
```
:::
+3 -18
View File
@@ -302,7 +302,6 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.updateScene({
elements,
commitToHistory: false,
});
}
};
@@ -449,14 +448,12 @@ class Collab extends PureComponent<Props, CollabState> {
}
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
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@@ -491,9 +488,7 @@ class Collab extends PureComponent<Props, CollabState> {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.handleRemoteSceneUpdate(reconciledElements);
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@@ -649,21 +644,11 @@ class Collab extends PureComponent<Props, CollabState> {
});
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
) => {
private handleRemoteSceneUpdate = (elements: ReconciledElements) => {
this.excalidrawAPI.updateScene({
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();
};
+1 -6
View File
@@ -18,7 +18,6 @@ import throttle from "lodash.throttle";
import { newElementWith } from "../../src/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../src/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
class Portal {
collab: TCollabClass;
@@ -150,11 +149,7 @@ class Portal {
this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push({
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
acc.push(element);
}
return acc;
},
+30 -105
View File
@@ -1,15 +1,13 @@
import { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/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[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
[PRECEDING_ELEMENT_KEY]?: string;
};
export type BroadcastedExcalidrawElement = ExcalidrawElement;
const shouldDiscardRemoteElement = (
localAppState: AppState,
@@ -21,7 +19,7 @@ const shouldDiscardRemoteElement = (
// local element is being edited
(local.id === localAppState.editingElement?.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.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
@@ -39,116 +37,43 @@ export const reconcileElements = (
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const localElementsData = arrayToMap(localElements);
const reconciledElements: ExcalidrawElement[] = [];
const added = new Set<string>();
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
// process remote elements
for (const remoteElement of remoteElements) {
remoteElementIdx++;
if (localElementsData.has(remoteElement.id)) {
const localElement = localElementsData.get(remoteElement.id);
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], 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) {
if (
localElement &&
shouldDiscardRemoteElement(localAppState, localElement, remoteElement)
) {
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 {
let idx = localElementsData.has(parent)
? 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 {
if (!added.has(remoteElement.id)) {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
added.add(remoteElement.id);
}
}
// 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 {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
if (!added.has(remoteElement.id)) {
reconciledElements.push(remoteElement);
added.add(remoteElement.id);
}
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
// process local elements
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
// from a different database
files: data.files,
commitToHistory: false,
commitToStore: false,
};
};
+2 -63
View File
@@ -25,8 +25,6 @@ import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
} from "../src/packages/excalidraw/index";
import {
AppState,
@@ -104,7 +102,6 @@ import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
import Trans from "../src/components/Trans";
import { drawingIcon } from "../src/components/icons";
polyfill();
@@ -412,7 +409,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
commitToStore: true,
});
}
});
@@ -593,6 +590,7 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
skipSnapshotUpdate: true,
});
}
}
@@ -776,65 +774,6 @@ const ExcalidrawWrapper = () => {
)}
</OverwriteConfirmDialog>
<AppFooter />
<TTDDialog
onTextSubmit={async (input, type) => {
try {
const response = await fetch(
`${import.meta.env.VITE_APP_AI_BACKEND}/v1/ai/${type}/generate`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt: input }),
},
);
const rateLimit = response.headers.has("X-Ratelimit-Limit")
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
: undefined;
const rateLimitRemaining = response.headers.has(
"X-Ratelimit-Remaining",
)
? parseInt(
response.headers.get("X-Ratelimit-Remaining") || "0",
10,
)
: undefined;
const json = await response.json();
if (!response.ok) {
if (response.status === 429) {
return {
rateLimit,
rateLimitRemaining,
error: new Error(
"Too many requests today, please try again tomorrow!",
),
};
}
throw new Error(json.message || "Generation failed...");
}
const generatedResponse = json.generatedResponse;
if (!generatedResponse) {
throw new Error("Generation failed...");
}
return { generatedResponse, rateLimit, rateLimitRemaining };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
<TTDDialogTrigger tab="text-to-drawing" icon={drawingIcon}>
{t("labels.textToDrawing")}
</TTDDialogTrigger>
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
+21 -4
View File
@@ -64,7 +64,7 @@ vi.mock("socket.io-client", () => {
});
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 />);
// To update the scene with deleted elements before starting collab
updateSceneData({
@@ -76,26 +76,43 @@ describe("collaboration", () => {
isDeleted: true,
}),
],
commitToStore: true,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(h.elements).toEqual([
expect.objectContaining({ id: "A" }),
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);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
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);
// noop
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(() => {
expect(h.history.isUndoStackEmpty).toBeTruthy();
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 { PRECEDING_ELEMENT_KEY } from "../../src/constants";
import { ExcalidrawElement } from "../../src/element/types";
import {
BroadcastedExcalidrawElement,
@@ -15,7 +14,6 @@ type ElementLike = {
id: string;
version: number;
versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>;
@@ -44,7 +42,6 @@ const createElement = (opts: { uid: string } | ElementLike) => {
id,
version,
versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
};
};
@@ -53,20 +50,15 @@ const idsToElements = (
cache: Cache = {},
): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => {
const {
uid,
id,
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const { uid, id, version, versionNonce } = createElement(
typeof _uid === "string" ? { uid: _uid } : _uid,
);
const cached = cache[uid];
const elem = {
id,
version: version ?? 0,
versionNonce,
...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement;
// @ts-ignore
cache[uid] = elem;
@@ -77,7 +69,6 @@ const idsToElements = (
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el;
});
};
@@ -389,13 +380,11 @@ describe("elements reconciliation", () => {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
];
@@ -408,13 +397,11 @@ describe("elements reconciliation", () => {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
const el2 = {
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
});
+1
View File
@@ -37,6 +37,7 @@
"eslint-plugin-react": "7.32.2",
"fake-indexeddb": "3.1.7",
"firebase": "8.3.3",
"fractional-indexing-jittered": "0.9.0",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"image-blob-reduce": "3.0.1",
+4 -3
View File
@@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "./types";
export const actionAddToLibrary = register({
name: "addToLibrary",
@@ -17,7 +18,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
@@ -41,7 +42,7 @@ export const actionAddToLibrary = register({
})
.then(() => {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
@@ -50,7 +51,7 @@ export const actionAddToLibrary = register({
})
.catch((error) => {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,
+7 -6
View File
@@ -18,6 +18,7 @@ import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
@@ -63,7 +64,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@@ -94,7 +95,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@@ -125,7 +126,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@@ -156,7 +157,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@@ -187,7 +188,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -214,7 +215,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
+4 -3
View File
@@ -33,6 +33,7 @@ import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionUnbindText = register({
name: "unbindText",
@@ -80,7 +81,7 @@ export const actionUnbindText = register({
return {
elements,
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@@ -149,7 +150,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@@ -299,7 +300,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
+25 -14
View File
@@ -1,7 +1,13 @@
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
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 { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@@ -22,6 +28,7 @@ import {
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { Bounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "./types";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -35,7 +42,9 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
commitToHistory: !!value.viewBackgroundColor,
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@@ -88,7 +97,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
});
@@ -110,16 +119,17 @@ export const actionZoomIn = register({
appState,
),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={appState.zoom.value >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
@@ -147,16 +157,17 @@ export const actionZoomOut = register({
appState,
),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, appState }) => (
<ToolButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={appState.zoom.value <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
@@ -184,7 +195,7 @@ export const actionResetZoom = register({
appState,
),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, appState }) => (
@@ -261,8 +272,8 @@ export const zoomToFit = ({
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, 0.1),
30.0,
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
@@ -307,7 +318,7 @@ export const zoomToFit = ({
scrollY,
zoom: { value: newZoomValue },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
};
@@ -377,7 +388,7 @@ export const actionToggleTheme = register({
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@@ -414,7 +425,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.key === KEYS.E,
@@ -449,7 +460,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
+14 -13
View File
@@ -13,6 +13,7 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { StoreAction } from "./types";
export const actionCopy = register({
name: "copy",
@@ -28,7 +29,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: error.message,
@@ -37,7 +38,7 @@ export const actionCopy = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.copy",
@@ -63,7 +64,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@@ -72,7 +73,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@@ -85,7 +86,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@@ -94,7 +95,7 @@ export const actionPaste = register({
}
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.paste",
@@ -119,7 +120,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
@@ -141,7 +142,7 @@ export const actionCopyAsSvg = register({
},
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@@ -150,7 +151,7 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@@ -166,7 +167,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
const selectedElements = app.scene.getSelectedElements({
@@ -199,7 +200,7 @@ export const actionCopyAsPng = register({
}),
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
} catch (error: any) {
console.error(error);
@@ -208,7 +209,7 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@@ -238,7 +239,7 @@ export const copyText = register({
.join("\n\n");
copyTextToSystemClipboard(text);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState, _, app) => {
+7 -4
View File
@@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "./types";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@@ -109,7 +110,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
commitToHistory: false,
storeAction: StoreAction.UPDATE,
};
}
@@ -141,7 +142,7 @@ export const actionDeleteSelected = register({
: [0],
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
let { elements: nextElements, appState: nextAppState } =
@@ -161,10 +162,12 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
commitToHistory: isSomeElementSelected(
storeAction: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
),
)
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
contextItemLabel: "labels.delete",
+3 -2
View File
@@ -14,6 +14,7 @@ import { isSomeElementSelected } from "../scene";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
@@ -53,7 +54,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
@@ -83,7 +84,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
+10 -4
View File
@@ -14,7 +14,7 @@ import {
} from "../groups";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import { ActionResult } from "./types";
import { ActionResult, StoreAction } from "./types";
import { GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
@@ -31,6 +31,7 @@ import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { fixFractionalIndices } from "../fractionalIndex";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@@ -47,13 +48,13 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
...duplicateElements(elements, appState),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.duplicateSelection",
@@ -85,6 +86,7 @@ const duplicateElements = (
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
@@ -96,6 +98,7 @@ const duplicateElements = (
y: element.y + GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
@@ -234,7 +237,10 @@ const duplicateElements = (
// step (3)
const finalElements = finalElementsReversed.reverse();
const finalElements = fixFractionalIndices(
finalElementsReversed.reverse(),
duplicatedElementsMap,
);
// ---------------------------------------------------------------------------
+3 -2
View File
@@ -4,6 +4,7 @@ import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { arrayToMap } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const shouldLock = (elements: readonly ExcalidrawElement[]) =>
elements.every((el) => !el.locked);
@@ -44,7 +45,7 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: (elements, appState, app) => {
@@ -98,7 +99,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.elementLock.unlockAll",
+15 -11
View File
@@ -19,12 +19,16 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "./types";
export const actionChangeProjectName = register({
name: "changeProjectName",
trackEvent: false,
perform: (_elements, appState, value) => {
return { appState: { ...appState, name: value }, commitToHistory: false };
return {
appState: { ...appState, name: value },
storeAction: StoreAction.UPDATE,
};
},
PanelComponent: ({ appState, updateData, appProps, data }) => (
<ProjectName
@@ -45,7 +49,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@@ -113,7 +117,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
@@ -148,7 +152,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
fileHandle,
@@ -170,7 +174,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@@ -192,7 +196,7 @@ export const actionSaveFileToDisk = register({
app.files,
);
return {
commitToHistory: false,
storeAction: StoreAction.NONE,
appState: {
...appState,
openDialog: null,
@@ -206,7 +210,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { commitToHistory: false };
return { storeAction: StoreAction.NONE };
}
},
keyTest: (event) =>
@@ -244,7 +248,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@@ -255,7 +259,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
}
},
@@ -268,7 +272,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ appState, updateData }) => (
+6 -2
View File
@@ -16,6 +16,7 @@ import {
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "./types";
export const actionFinalize = register({
name: "finalize",
@@ -49,7 +50,7 @@ export const actionFinalize = register({
cursorButton: "up",
editingLinearElement: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
}
@@ -190,7 +191,10 @@ export const actionFinalize = register({
: appState.selectedLinearElement,
pendingImageElementId: null,
},
commitToHistory: appState.activeTool.type === "freedraw",
storeAction:
appState.activeTool.type === "freedraw"
? StoreAction.CAPTURE
: StoreAction.UPDATE,
};
},
keyTest: (event, appState) =>
+3 -2
View File
@@ -13,6 +13,7 @@ import {
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { StoreAction } from "./types";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -25,7 +26,7 @@ export const actionFlipHorizontal = register({
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
@@ -43,7 +44,7 @@ export const actionFlipVertical = register({
app,
),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
keyTest: (event) =>
+7 -6
View File
@@ -8,6 +8,7 @@ import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { StoreAction } from "./types";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
@@ -39,14 +40,14 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
@@ -74,14 +75,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
}
return {
elements,
appState,
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
@@ -103,7 +104,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.updateFrameRendering",
@@ -131,7 +132,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
keyTest: (event) =>
+6 -5
View File
@@ -27,6 +27,7 @@ import {
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
import { StoreAction } from "./types";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@@ -69,7 +70,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// 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
const selectedGroupIds = getSelectedGroupIds(appState);
@@ -89,7 +90,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE };
}
}
@@ -155,7 +156,7 @@ export const actionGroup = register({
),
},
elements: nextElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.group",
@@ -182,7 +183,7 @@ export const actionUngroup = register({
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
if (groupIds.length === 0) {
return { appState, elements, commitToHistory: false };
return { appState, elements, storeAction: StoreAction.NONE, };
}
let nextElements = [...elements];
@@ -250,7 +251,7 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
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 { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { History } from "../history";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import { ExcalidrawElement } from "../element/types";
import { fixBindingsAfterDeletion } from "../element/binding";
import { orderByFractionalIndex } from "../fractionalIndex";
const writeData = (
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
appState: Readonly<AppState>,
updater: () => [Map<string, ExcalidrawElement>, AppState] | void,
): ActionResult => {
const commitToHistory = false;
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
) {
const data = updater();
if (data === null) {
return { commitToHistory };
const result = updater();
if (!result) {
return { storeAction: StoreAction.NONE };
}
const prevElementMap = arrayToMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
// TODO_UNDO: worth detecting z-index deltas or do we just order based on fractional indices?
const [nextElementsMap, nextAppState] = result;
const nextElements = orderByFractionalIndex(
Array.from(nextElementsMap.values()),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
// TODO_UNDO: these are all deleted elements, but ideally we should get just those that were delted at this moment
const deletedElements = nextElements.filter((element) => element.isDeleted);
// TODO_UNDO: this doesn't really work for bound text
fixBindingsAfterDeletion(nextElements, deletedElements);
return {
elements,
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
appState: nextAppState,
elements: nextElements,
storeAction: StoreAction.UPDATE,
};
}
return { commitToHistory };
return { storeAction: StoreAction.NONE };
};
type ActionCreator = (history: History) => Action;
@@ -65,7 +54,7 @@ export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.undoOnce()),
writeData(appState, () => history.undo(arrayToMap(elements), appState)),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
@@ -77,16 +66,16 @@ export const createUndoAction: ActionCreator = (history) => ({
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={history.isUndoStackEmpty}
/>
),
commitToHistory: () => false,
});
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
trackEvent: { category: "history" },
perform: (elements, appState) =>
writeData(elements, appState, () => history.redoOnce()),
writeData(appState, () => history.redo(arrayToMap(elements), appState)),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
@@ -99,7 +88,7 @@ export const createRedoAction: ActionCreator = (history) => ({
aria-label={t("buttons.redo")}
onClick={updateData}
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 { ExcalidrawLinearElement } from "../element/types";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
@@ -30,7 +31,7 @@ export const actionToggleLinearEditor = register({
...appState,
editingLinearElement,
},
commitToHistory: false,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: (elements, appState, app) => {
+6 -10
View File
@@ -4,6 +4,7 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@@ -13,7 +14,7 @@ export const actionToggleCanvasMenu = register({
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@@ -34,7 +35,7 @@ export const actionToggleEditMenu = register({
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@@ -56,20 +57,15 @@ export const actionShortcuts = register({
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
if (appState.openDialog?.name === "help") {
if (appState.openDialog === "help") {
focusContainer();
}
return {
appState: {
...appState,
openDialog:
appState.openDialog?.name === "help"
? null
: {
name: "help",
},
openDialog: appState.openDialog === "help" ? null : "help",
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
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 { Collaborator } from "../types";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
@@ -11,7 +12,7 @@ export const actionGoToCollaborator = register({
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
if (!point) {
return { appState, commitToHistory: false };
return { appState, storeAction: StoreAction.NONE };
}
return {
@@ -28,7 +29,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
PanelComponent: ({ updateData, data }) => {
+18 -13
View File
@@ -92,6 +92,7 @@ import {
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "./types";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -222,7 +223,7 @@ const changeFontSize = (
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
};
@@ -251,7 +252,9 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemStrokeColor,
storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -294,7 +297,9 @@ export const actionChangeBackgroundColor = register({
...appState,
...value,
},
commitToHistory: !!value.currentItemBackgroundColor,
storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -337,7 +342,7 @@ export const actionChangeFillStyle = register({
}),
),
appState: { ...appState, currentItemFillStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -409,7 +414,7 @@ export const actionChangeStrokeWidth = register({
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -463,7 +468,7 @@ export const actionChangeSloppiness = register({
}),
),
appState: { ...appState, currentItemRoughness: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -513,7 +518,7 @@ export const actionChangeStrokeStyle = register({
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -567,7 +572,7 @@ export const actionChangeOpacity = register({
true,
),
appState: { ...appState, currentItemOpacity: value },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -725,7 +730,7 @@ export const actionChangeFontFamily = register({
...appState,
currentItemFontFamily: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -814,7 +819,7 @@ export const actionChangeTextAlign = register({
...appState,
currentItemTextAlign: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -894,7 +899,7 @@ export const actionChangeVerticalAlign = register({
appState: {
...appState,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -967,7 +972,7 @@ export const actionChangeRoundness = register({
...appState,
currentItemRoundness: value,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -1047,7 +1052,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
+2 -1
View File
@@ -6,6 +6,7 @@ import { ExcalidrawElement } from "../element/types";
import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { StoreAction } from "./types";
export const actionSelectAll = register({
name: "selectAll",
@@ -46,7 +47,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0], app.scene)
: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.selectAll",
+4 -3
View File
@@ -25,6 +25,7 @@ import {
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
import { StoreAction } from "./types";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -48,7 +49,7 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
contextItemLabel: "labels.copyStyles",
@@ -64,7 +65,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, commitToHistory: false };
return { elements, storeAction: StoreAction.NONE };
}
const selectedElements = getSelectedElements(elements, appState, {
@@ -149,7 +150,7 @@ export const actionPasteStyles = register({
}
return element;
}),
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.pasteStyles",
+2 -1
View File
@@ -2,6 +2,7 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { StoreAction } from "./types";
export const actionToggleGridMode = register({
name: "gridMode",
@@ -17,7 +18,7 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
+2 -1
View File
@@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
@@ -15,7 +16,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
+2 -1
View File
@@ -1,5 +1,6 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "./types";
export const actionToggleStats = register({
name: "stats",
@@ -11,7 +12,7 @@ export const actionToggleStats = register({
...appState,
showStats: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.showStats,
+2 -1
View File
@@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleViewMode = register({
name: "viewMode",
@@ -14,7 +15,7 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.viewModeEnabled,
+2 -1
View File
@@ -1,5 +1,6 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { StoreAction } from "./types";
export const actionToggleZenMode = register({
name: "zenMode",
@@ -14,7 +15,7 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
checked: (appState) => appState.zenModeEnabled,
+5 -5
View File
@@ -1,4 +1,3 @@
import React from "react";
import {
moveOneLeft,
moveOneRight,
@@ -16,6 +15,7 @@ import {
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
import { StoreAction } from "./types";
export const actionSendBackward = register({
name: "sendBackward",
@@ -24,7 +24,7 @@ export const actionSendBackward = register({
return {
elements: moveOneLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.sendBackward",
@@ -52,7 +52,7 @@ export const actionBringForward = register({
return {
elements: moveOneRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.bringForward",
@@ -80,7 +80,7 @@ export const actionSendToBack = register({
return {
elements: moveAllLeft(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.sendToBack",
@@ -116,7 +116,7 @@ export const actionBringToFront = register({
return {
elements: moveAllRight(elements, appState),
appState,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
contextItemLabel: "labels.bringToFront",
+7 -2
View File
@@ -10,6 +10,12 @@ import { MarkOptional } from "../utility-types";
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 */
export type ActionResult =
| {
@@ -19,8 +25,7 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
commitToHistory: boolean;
syncHistory?: boolean;
storeAction: StoreAction;
replaceFiles?: boolean;
}
| false;
+7 -15
View File
@@ -1,7 +1,3 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
action: string,
@@ -9,13 +5,13 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
// place here categories that you want to track as events
// KEEP IN MIND THE PRICING
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
// Uncomment the next line to track locally
// console.log("Track Event", { category, action, label, value });
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
return;
}
@@ -23,10 +19,6 @@ export const trackEvent = (
return;
}
if (!import.meta.env.PROD) {
console.info("trackEvent", { category, action, label, value });
}
if (window.sa_event) {
window.sa_event(action, {
category,
+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;
}
}
+1
View File
@@ -12,6 +12,7 @@
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;
+4 -15
View File
@@ -40,7 +40,6 @@ import {
MagicIcon,
} from "./icons";
import { KEYS } from "../keys";
import { useTunnels } from "../context/tunnels";
export const SelectedShapeActions = ({
appState,
@@ -236,8 +235,6 @@ export const ShapesSwitcher = ({
const laserToolSelected = activeTool.type === "laser";
const embeddableToolSelected = activeTool.type === "embeddable";
const { TTDDialogTriggerTunnel } = useTunnels();
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
@@ -341,33 +338,25 @@ export const ShapesSwitcher = ({
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
<DropdownMenu.Item
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon}
data-testid="toolbar-embeddable"
>
{t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicframeToolSelect()}
onSelect={() => app.onMagicButtonSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "open-settings", "d2c");
app.setOpenDialog({
name: "settings",
source: "settings",
tab: "diagram-to-code",
});
}}
onSelect={() => app.setOpenDialog("magicSettings")}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
+188 -162
View File
@@ -40,7 +40,7 @@ import {
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager";
import { actions } from "../actions/register";
import { Action, ActionResult } from "../actions/types";
import { Action, ActionResult, StoreAction } from "../actions/types";
import { trackEvent } from "../analytics";
import {
getDefaultAppState,
@@ -194,7 +194,7 @@ import {
isSelectedViaGroup,
selectGroupsForSelectedElements,
} from "../groups";
import History from "../history";
import { History } from "../history";
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
import {
CODES,
@@ -268,6 +268,8 @@ import {
muteFSAbortError,
isTestEnv,
easeOut,
isShallowEqual,
arrayToMap,
} from "../utils";
import {
createSrcDoc,
@@ -381,6 +383,7 @@ import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import { LaserToolOverlay } from "./LaserTool/LaserTool";
import { LaserPathManager } from "./LaserTool/LaserPathManager";
import {
@@ -397,6 +400,8 @@ import { COLOR_PALETTE } from "../colors";
import { ElementCanvasButton } from "./MagicButton";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import { fixFractionalIndices } from "../fractionalIndex";
import { Store } from "../store";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@@ -511,6 +516,7 @@ class App extends React.Component<AppProps, AppState> {
public library: AppClassProperties["library"];
public libraryItemsFromStorage: LibraryItems | undefined;
public id: string;
private store: Store;
private history: History;
private excalidrawContainerValue: {
container: HTMLDivElement | null;
@@ -594,6 +600,9 @@ class App extends React.Component<AppProps, AppState> {
this.rc = rough.canvas(this.canvas);
this.renderer = new Renderer(this.scene);
this.store = new Store();
this.history = new History();
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
@@ -601,6 +610,10 @@ class App extends React.Component<AppProps, AppState> {
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
store: {
clear: this.store.clear,
listen: this.store.listen,
},
history: {
clear: this.resetHistory,
},
@@ -640,8 +653,14 @@ class App extends React.Component<AppProps, AppState> {
onSceneUpdated: this.onSceneUpdated,
});
this.history = new History();
this.actionManager.registerAll(actions);
this.actionManager = new ActionManager(
this.syncActionResult,
() => this.state,
() => this.scene.getElementsIncludingDeleted(),
this,
);
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
}
@@ -1434,6 +1453,9 @@ class App extends React.Component<AppProps, AppState> {
onMagicSettingsConfirm={this.onMagicSettingsConfirm}
>
{this.props.children}
{this.state.openDialog === "mermaid" && (
<MermaidToExcalidraw />
)}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
@@ -1463,7 +1485,6 @@ class App extends React.Component<AppProps, AppState> {
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
@@ -1694,19 +1715,11 @@ class App extends React.Component<AppProps, AppState> {
return text;
}
private async onMagicFrameGenerate(
magicFrame: ExcalidrawMagicFrameElement,
source: "button" | "upstream",
) {
private async onMagicFrameGenerate(magicFrame: ExcalidrawMagicFrameElement) {
if (!this.OPENAI_KEY) {
this.setState({
openDialog: {
name: "settings",
tab: "diagram-to-code",
source: "generation",
},
openDialog: "magicSettings",
});
trackEvent("ai", "generate (missing key)", "d2c");
return;
}
@@ -1717,12 +1730,7 @@ class App extends React.Component<AppProps, AppState> {
}).filter((el) => !isMagicFrameElement(el));
if (!magicFrameChildren.length) {
if (source === "button") {
this.setState({ errorMessage: "Cannot generate from an empty frame" });
trackEvent("ai", "generate (no-children)", "d2c");
} else {
this.setActiveTool({ type: "magicframe" });
}
this.setState({ errorMessage: "Cannot generate from an empty frame" });
return;
}
@@ -1761,8 +1769,6 @@ class App extends React.Component<AppProps, AppState> {
const textFromFrameChildren = this.getTextFromElements(magicFrameChildren);
trackEvent("ai", "generate (start)", "d2c");
const result = await diagramToHTML({
image: dataURL,
apiKey: this.OPENAI_KEY,
@@ -1771,7 +1777,6 @@ class App extends React.Component<AppProps, AppState> {
});
if (!result.ok) {
trackEvent("ai", "generate (failed)", "d2c");
console.error(result.error);
this.updateMagicGeneration({
frameElement,
@@ -1783,7 +1788,6 @@ class App extends React.Component<AppProps, AppState> {
});
return;
}
trackEvent("ai", "generate (success)", "d2c");
if (result.choices[0].message.content == null) {
this.updateMagicGeneration({
@@ -1827,10 +1831,7 @@ class App extends React.Component<AppProps, AppState> {
private OPENAI_KEY_IS_PERSISTED: boolean =
EditorLocalStorage.has(EDITOR_LS_KEYS.OAI_API_KEY) || false;
private onOpenAIKeyChange = (
openAIKey: string | null,
shouldPersist: boolean,
) => {
private onOpenAIKeyChange = (openAIKey: string, shouldPersist: boolean) => {
this.OPENAI_KEY = openAIKey || null;
if (shouldPersist) {
const didPersist = EditorLocalStorage.set(
@@ -1843,45 +1844,26 @@ class App extends React.Component<AppProps, AppState> {
}
};
private onMagicSettingsConfirm = (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => {
this.OPENAI_KEY = apiKey || null;
this.onOpenAIKeyChange(this.OPENAI_KEY, shouldPersist);
if (source === "settings") {
return;
}
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
});
private onMagicSettingsConfirm = (apiKey: string, shouldPersist: boolean) => {
this.onOpenAIKeyChange(apiKey, shouldPersist);
if (apiKey) {
const selectedElements = this.scene.getSelectedElements({
selectedElementIds: this.state.selectedElementIds,
});
if (selectedElements.length) {
this.onMagicframeToolSelect();
} else {
this.setActiveTool({ type: "magicframe" });
this.onMagicButtonSelect();
}
} else if (!isMagicFrameElement(selectedElements[0])) {
// even if user didn't end up setting api key, let's pick the tool
// so they can draw up a frame and move forward
this.setActiveTool({ type: "magicframe" });
} else {
this.OPENAI_KEY = null;
}
};
public onMagicframeToolSelect = () => {
public onMagicButtonSelect = () => {
if (!this.OPENAI_KEY) {
this.setState({
openDialog: {
name: "settings",
tab: "diagram-to-code",
source: "tool",
},
openDialog: "magicSettings",
});
trackEvent("ai", "tool-select (missing key)", "d2c");
return;
}
@@ -1891,33 +1873,19 @@ class App extends React.Component<AppProps, AppState> {
if (selectedElements.length === 0) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
trackEvent("ai", "tool-select (empty-selection)", "d2c");
} else {
const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
selectedElements.length === 1 &&
isMagicFrameElement(selectedElements[0]) &&
selectedElements[0];
// case: user selected elements containing frame-like(s) or are frame
// members, we don't want to wrap into another magicframe
// (unless the only selected element is a magic frame which we reuse)
if (
!selectedMagicFrame &&
selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
) {
if (selectedElements.some((el) => isFrameLikeElement(el))) {
this.setActiveTool({ type: TOOL_TYPE.magicframe });
return;
}
trackEvent("ai", "tool-select (existing selection)", "d2c");
let frame: ExcalidrawMagicFrameElement;
if (selectedMagicFrame) {
// a single magicframe already selected -> use it
frame = selectedMagicFrame;
let frame: ExcalidrawMagicFrameElement | null = null;
if (
selectedElements.length === 1 &&
isMagicFrameElement(selectedElements[0])
) {
frame = selectedElements[0];
} else {
// selected elements aren't wrapped in magic frame yet -> wrap now
const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
const padding = 50;
@@ -1930,19 +1898,19 @@ class App extends React.Component<AppProps, AppState> {
opacity: 100,
locked: false,
});
this.scene.addNewElement(frame);
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
}
this.setState({
selectedElementIds: { [frame.id]: true },
});
}
this.onMagicFrameGenerate(frame, "upstream");
this.scene.addNewElement(frame);
for (const child of selectedElements) {
mutateElement(child, { frameId: frame.id });
}
this.setState({
selectedElementIds: { [frame.id]: true },
});
this.onMagicFrameGenerate(frame);
}
};
@@ -1963,15 +1931,16 @@ class App extends React.Component<AppProps, AppState> {
if (shouldUpdateStrokeColor) {
this.syncActionResult({
appState: { ...this.state, currentItemStrokeColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else {
this.syncActionResult({
appState: { ...this.state, currentItemBackgroundColor: color },
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
}
} else {
// TODO_UNDO: test if we didn't regress here - shouldn't this commit to store?
this.updateScene({
elements: this.scene.getElementsIncludingDeleted().map((el) => {
if (this.state.selectedElementIds[el.id]) {
@@ -2007,8 +1976,11 @@ class App extends React.Component<AppProps, AppState> {
}
});
this.scene.replaceAllElements(actionResult.elements);
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.scheduleSnapshotUpdate();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.resumeCapturing();
}
}
@@ -2020,8 +1992,10 @@ class App extends React.Component<AppProps, AppState> {
}
if (actionResult.appState || editingElement || this.state.contextMenu) {
if (actionResult.commitToHistory) {
this.history.resumeRecording();
if (actionResult.storeAction === StoreAction.UPDATE) {
this.store.scheduleSnapshotUpdate();
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
this.store.resumeCapturing();
}
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
@@ -2055,34 +2029,24 @@ class App extends React.Component<AppProps, AppState> {
editingElement = null;
}
this.setState(
(state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
},
() => {
if (actionResult.syncHistory) {
this.history.setCurrentState(
this.state,
this.scene.getElementsIncludingDeleted(),
);
}
},
);
this.setState((state) => {
// using Object.assign instead of spread to fool TS 4.2.2+ into
// regarding the resulting type as not containing undefined
// (which the following expression will never contain)
return Object.assign(actionResult.appState || {}, {
// NOTE this will prevent opening context menu using an action
// or programmatically from the host, so it will need to be
// rewritten later
contextMenu: null,
editingElement,
viewModeEnabled,
zenModeEnabled,
gridSize,
theme,
name,
errorMessage,
});
});
}
},
);
@@ -2106,6 +2070,10 @@ class App extends React.Component<AppProps, AppState> {
this.history.clear();
};
private resetStore = () => {
this.store.clear();
};
/**
* Resets scene & history.
* ! Do not use to clear scene user action !
@@ -2118,6 +2086,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: opts?.resetLoadingState ? false : state.isLoading,
theme: this.state.theme,
}));
this.resetStore();
this.resetHistory();
},
);
@@ -2202,10 +2171,11 @@ class App extends React.Component<AppProps, AppState> {
// seems faster even in browsers that do fire the loadingdone event.
this.fonts.loadFontsForElements(scene.elements);
this.resetStore();
this.resetHistory();
this.syncActionResult({
...scene,
commitToHistory: true,
storeAction: StoreAction.UPDATE, // TODO_UNDO: double-check for regression
});
};
@@ -2295,9 +2265,17 @@ class App extends React.Component<AppProps, AppState> {
configurable: true,
value: this.history,
},
store: {
configurable: true,
value: this.store,
},
});
}
this.store.listen((...args) => {
this.history.record(...args);
});
this.scene.addCallback(this.onSceneUpdated);
this.addEventListeners();
@@ -2353,6 +2331,7 @@ class App extends React.Component<AppProps, AppState> {
this.library.destroy();
this.laserPathManager.destroy();
this.onChangeEmitter.destroy();
this.store.destroy();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@@ -2602,7 +2581,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement &&
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
) {
// defer so that the commitToHistory flag isn't reset via current update
// defer so that the storeAction flag isn't reset via current update
setTimeout(() => {
// execute only if the condition still holds when the deferred callback
// executes (it can be scheduled multiple times depending on how
@@ -2646,7 +2625,11 @@ class App extends React.Component<AppProps, AppState> {
),
);
}
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
this.store.capture(
arrayToMap(this.scene.getElementsIncludingDeleted()),
this.state,
);
// Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during
@@ -2971,7 +2954,7 @@ class App extends React.Component<AppProps, AppState> {
...newElements,
];
this.scene.replaceAllElements(nextElements);
this.scene.replaceAllElements(nextElements, arrayToMap(newElements));
newElements.forEach((newElement) => {
if (isTextElement(newElement) && isBoundToContainer(newElement)) {
@@ -2984,7 +2967,7 @@ class App extends React.Component<AppProps, AppState> {
this.files = { ...this.files, ...opts.files };
}
this.history.resumeRecording();
this.store.resumeCapturing();
const nextElementsToSelect =
excludeElementsInFramesFromSelection(newElements);
@@ -3197,10 +3180,10 @@ class App extends React.Component<AppProps, AppState> {
this.scene.getElementIndex(frameId),
);
} else {
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
...textElements,
]);
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), ...textElements],
arrayToMap(textElements),
);
}
this.setState({
@@ -3225,7 +3208,7 @@ class App extends React.Component<AppProps, AppState> {
PLAIN_PASTE_TOAST_SHOWN = true;
}
this.history.resumeRecording();
this.store.resumeCapturing();
}
setAppState: React.Component<any, AppState>["setState"] = (
@@ -3486,10 +3469,33 @@ class App extends React.Component<AppProps, AppState> {
elements?: SceneData["elements"];
appState?: Pick<AppState, K> | null;
collaborators?: SceneData["collaborators"];
commitToHistory?: SceneData["commitToHistory"];
commitToStore?: SceneData["commitToStore"];
skipSnapshotUpdate?: SceneData["skipSnapshotUpdate"];
}) => {
if (sceneData.commitToHistory) {
this.history.resumeRecording();
if (
!sceneData.skipSnapshotUpdate &&
(sceneData.elements || sceneData.appState)
) {
this.store.scheduleSnapshotUpdate();
if (sceneData.commitToStore) {
this.store.resumeCapturing();
}
// We need to filter out yet uncomitted local elements
// Once we will be exchanging just store increments and updating changes this won't be necessary
const localElements = this.scene.getElementsIncludingDeleted();
const nextElements = this.store.ignoreUncomittedElements(
arrayToMap(localElements),
arrayToMap(sceneData.elements || localElements), // Here we expect all next elements
);
const nextAppState: AppState = {
...this.state,
...(sceneData.appState || {}), // Here we expect just partial appState
};
this.store.capture(nextElements, nextAppState);
}
if (sceneData.appState) {
@@ -3601,7 +3607,7 @@ class App extends React.Component<AppProps, AppState> {
if (event.key === KEYS.QUESTION_MARK) {
this.setState({
openDialog: { name: "help" },
openDialog: "help",
});
return;
} else if (
@@ -3610,7 +3616,7 @@ class App extends React.Component<AppProps, AppState> {
event[KEYS.CTRL_OR_CMD]
) {
event.preventDefault();
this.setState({ openDialog: { name: "imageExport" } });
this.setState({ openDialog: "imageExport" });
return;
}
@@ -3698,7 +3704,7 @@ class App extends React.Component<AppProps, AppState> {
this.state.editingLinearElement.elementId !==
selectedElements[0].id
) {
this.history.resumeRecording();
this.store.resumeCapturing();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElement,
@@ -4090,7 +4096,7 @@ class App extends React.Component<AppProps, AppState> {
]);
}
if (!isDeleted || isExistingElement) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
this.setState({
@@ -4382,7 +4388,7 @@ class App extends React.Component<AppProps, AppState> {
(!this.state.editingLinearElement ||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
) {
this.history.resumeRecording();
this.store.resumeCapturing();
this.setState({
editingLinearElement: new LinearElementEditor(
selectedElements[0],
@@ -5202,6 +5208,7 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
skipSnapshotUpdate: true, // TODO_UNDO: test if we didn't regress here
});
return;
}
@@ -5802,7 +5809,7 @@ class App extends React.Component<AppProps, AppState> {
const ret = LinearElementEditor.handlePointerDown(
event,
this.state,
this.history,
this.store,
pointerDownState.origin,
linearElementEditor,
);
@@ -6187,10 +6194,10 @@ class App extends React.Component<AppProps, AppState> {
height,
});
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), element],
arrayToMap([element]),
);
return element;
};
@@ -6242,10 +6249,10 @@ class App extends React.Component<AppProps, AppState> {
validated: null,
});
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
element,
]);
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), element],
arrayToMap([element]),
);
return element;
};
@@ -6523,10 +6530,10 @@ class App extends React.Component<AppProps, AppState> {
? newMagicFrameElement(constructorOpts)
: newFrameElement(constructorOpts);
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
frame,
]);
this.scene.replaceAllElements(
[...this.scene.getElementsIncludingDeleted(), frame],
arrayToMap([frame]),
);
this.setState({
multiElement: null,
@@ -6894,6 +6901,7 @@ class App extends React.Component<AppProps, AppState> {
})
.map((element) => element.id),
);
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const elements = this.scene.getElementsIncludingDeleted();
@@ -6910,6 +6918,10 @@ class App extends React.Component<AppProps, AppState> {
groupIdMap,
element,
);
duplicatedElementsMap.set(
duplicatedElement.id,
duplicatedElement,
);
const origElement = pointerDownState.originalElements.get(
element.id,
)!;
@@ -6932,7 +6944,10 @@ class App extends React.Component<AppProps, AppState> {
nextElements.push(element);
}
}
const nextSceneElements = [...nextElements, ...elementsToAppend];
const nextSceneElements = fixFractionalIndices(
[...nextElements, ...elementsToAppend],
duplicatedElementsMap,
);
bindTextToShapeAfterDuplication(
nextElements,
elementsToAppend,
@@ -7347,7 +7362,7 @@ class App extends React.Component<AppProps, AppState> {
if (isLinearElement(draggingElement)) {
if (draggingElement!.points.length > 1) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
const pointerCoords = viewportCoordsToSceneCoords(
childEvent,
@@ -7582,7 +7597,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (resizingElement) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
@@ -7883,9 +7898,13 @@ class App extends React.Component<AppProps, AppState> {
if (
activeTool.type !== "selection" ||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state)
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
!isShallowEqual(
this.state.previousSelectedElementIds,
this.state.selectedElementIds,
)
) {
this.history.resumeRecording();
this.store.resumeCapturing();
}
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
@@ -7991,7 +8010,7 @@ class App extends React.Component<AppProps, AppState> {
return ele;
});
this.history.resumeRecording();
this.store.resumeCapturing();
this.scene.replaceAllElements(elements);
};
@@ -8539,7 +8558,7 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
return;
} catch (error: any) {
@@ -8625,15 +8644,21 @@ class App extends React.Component<AppProps, AppState> {
fileHandle,
);
if (ret.type === MIME_TYPES.excalidraw) {
// First we need to delete existing elements, so they get recorded in the undo stack
const deletedExistingElements = this.scene
.getNonDeletedElements()
.map((element) => newElementWith(element, { isDeleted: true }));
this.setState({ isLoading: true });
this.syncActionResult({
...ret.data,
elements: deletedExistingElements.concat(ret.data.elements),
appState: {
...(ret.data.appState || this.state),
isLoading: false,
},
replaceFiles: true,
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
});
} else if (ret.type === MIME_TYPES.excalidrawlib) {
await this.library
@@ -9249,6 +9274,7 @@ declare global {
setState: React.Component<any, AppState>["setState"];
app: InstanceType<typeof App>;
history: History;
store: Store;
};
}
}
+1 -5
View File
@@ -2,11 +2,7 @@ import clsx from "clsx";
import { composeEventHandlers } from "../utils";
import "./Button.scss";
interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
type?: "button" | "submit" | "reset";
onSelect: () => any;
/** whether button is in active state */
-15
View File
@@ -1,15 +0,0 @@
export const InlineIcon = ({ 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>
);
};
+1 -1
View File
@@ -117,7 +117,7 @@ export const JSONExportDialog = ({
return (
<>
{appState.openDialog?.name === "jsonExport" && (
{appState.openDialog === "jsonExport" && (
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
<JSONExportModal
elements={elements}
+6 -17
View File
@@ -62,7 +62,6 @@ import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
import { TTDDialog } from "./TTDDialog/TTDDialog";
interface LayerUIProps {
actionManager: ActionManager;
@@ -87,11 +86,7 @@ interface LayerUIProps {
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (
apiKey: string,
shouldPersist: boolean,
source: "tool" | "generation" | "settings",
) => void;
onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void;
}
const DefaultMainMenu: React.FC<{
@@ -182,7 +177,7 @@ const LayerUI = ({
const renderImageExportDialog = () => {
if (
!UIOptions.canvasActions.saveAsImage ||
appState.openDialog?.name !== "imageExport"
appState.openDialog !== "imageExport"
) {
return null;
}
@@ -397,7 +392,6 @@ const LayerUI = ({
{t("toolBar.library")}
</DefaultSidebar.Trigger>
<DefaultOverwriteConfirmDialog />
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
{/* ------------------------------------------------------------------ */}
{appState.isLoading && <LoadingMessage delay={250} />}
@@ -454,26 +448,21 @@ const LayerUI = ({
}}
/>
)}
{appState.openDialog?.name === "help" && (
{appState.openDialog === "help" && (
<HelpDialog
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
{appState.openDialog?.name === "settings" && (
{appState.openDialog === "magicSettings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
const source =
appState.openDialog?.name === "settings"
? appState.openDialog?.source
: "settings";
setAppState({ openDialog: null }, () => {
onMagicSettingsConfirm(apiKey, shouldPersist, source);
});
setAppState({ openDialog: null });
onMagicSettingsConfirm(apiKey, shouldPersist);
}}
onClose={() => {
setAppState({ openDialog: null });
-9
View File
@@ -1,18 +1,9 @@
.excalidraw {
.MagicSettings {
.Island {
height: 100%;
display: flex;
flex-direction: column;
}
}
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
margin-right: auto;
}
}
+83 -98
View File
@@ -2,16 +2,28 @@ 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";
import { InlineIcon } from "./InlineIcon";
import { Paragraph } from "./Paragraph";
import "./MagicSettings.scss";
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs";
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab";
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;
@@ -20,21 +32,16 @@ export const MagicSettings = (props: {
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 appState = useUIAppState();
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
if (appState.openDialog?.name !== "settings") {
return null;
}
return (
<Dialog
onCloseRequest={() => {
@@ -43,7 +50,7 @@ export const MagicSettings = (props: {
}}
title={
<div style={{ display: "flex" }}>
Wireframe to Code (AI){" "}
Diagram to Code (AI){" "}
<div
style={{
display: "flex",
@@ -53,8 +60,7 @@ export const MagicSettings = (props: {
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
color: "#000",
background: "pink",
background: theme === "light" ? "#FFCCCC" : "#703333",
}}
>
Experimental
@@ -64,97 +70,76 @@ export const MagicSettings = (props: {
className="MagicSettings"
autofocus={false}
>
{/* <h2
<p
style={{
margin: 0,
fontSize: "1.25rem",
paddingLeft: "2.5rem",
display: "inline-flex",
alignItems: "center",
marginBottom: 0,
}}
>
AI Settings
</h2> */}
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}>
{/* <TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<InlineIcon icon={brainIcon} /> Text to diagram
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="diagram-to-code">
<InlineIcon icon={MagicIcon} /> Wireframe to code
</TTDDialogTabTrigger>
</TTDDialogTabTriggers> */}
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
TODO
</TTDDialogTab> */}
<TTDDialogTab
// className="ttd-dialog-content"
tab="diagram-to-code"
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"
>
<Paragraph>
For the diagram-to-code feature we use{" "}
<InlineIcon icon={OpenAIIcon} />
OpenAI.
</Paragraph>
<Paragraph>
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>
.
</Paragraph>
<Paragraph>
Your OpenAI key does not leave the browser, and you can also set
your own limit in your OpenAI account dashboard if needed.
</Paragraph>
<TextField
isRedacted
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()}
/>
<Paragraph>
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.
</Paragraph>
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>
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}>
Persist API key in browser storage
</CheckboxItem>
<Paragraph>
Once API key is set, you can use the <InlineIcon 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> <InlineIcon icon={OpenAIIcon} />.
</Paragraph>
<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}
/>
</TTDDialogTab>
</TTDDialogTabs>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</Dialog>
);
};
+221
View File
@@ -0,0 +1,221 @@
@import "../css/variables.module";
$verticalBreakpoint: 860px;
.excalidraw {
.dialog-mermaid {
&-title {
margin-bottom: 5px;
margin-top: 2px;
}
&-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
}
.Modal__content .Island {
box-shadow: none;
}
@at-root .excalidraw:not(.excalidraw--mobile)#{&} {
padding: 1.25rem;
.Modal__content {
height: 100%;
max-height: 750px;
@media screen and (max-width: $verticalBreakpoint) {
height: auto;
// When vertical, we want the height to span whole viewport.
// This is also important for the children not to overflow the
// modal/viewport (for some reason).
max-height: 100%;
}
.Island {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
.Dialog__content {
display: flex;
flex: 1 1 auto;
}
}
}
}
}
.dialog-mermaid-body {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100%;
column-gap: 4rem;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
}
.dialog-mermaid-panels {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: 4rem;
grid-row: 1;
grid-column: 1 / 3;
@media screen and (max-width: $verticalBreakpoint) {
flex-direction: column;
display: flex;
gap: 1rem;
}
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
margin-bottom: 4px;
margin-left: 4px;
@media screen and (max-width: $verticalBreakpoint) {
margin-top: 4px;
}
}
&-text {
display: flex;
flex-direction: column;
textarea {
width: 20rem;
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
&-preview-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
// acts as min-height
height: 200px;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
@media screen and (max-width: $verticalBreakpoint) {
// acts as min-height
height: 400px;
width: auto;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
&-preview-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
&-preview {
display: flex;
flex-direction: column;
}
.mermaid-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
}
.dialog-mermaid-buttons {
grid-column: 2;
.dialog-mermaid-insert {
&.excalidraw-button {
font-family: "Assistant";
font-weight: 600;
height: 2.5rem;
margin-top: 1em;
margin-bottom: 0.3em;
width: 7.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
@media screen and (max-width: $verticalBreakpoint) {
width: 100%;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../types";
import { useApp } from "./App";
import { Button } from "./Button";
import { Dialog } from "./Dialog";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../element/types";
import { canvasToBlob } from "../data/blob";
import { ArrowRightIcon } from "./icons";
import Spinner from "./Spinner";
import "./MermaidToExcalidraw.scss";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { t } from "../i18n";
import Trans from "./Trans";
const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const ErrorComp = ({ error }: { error: string }) => {
return (
<div data-testid="mermaid-error" className="mermaid-error">
Error! <p>{error}</p>
</div>
);
};
const MermaidToExcalidraw = () => {
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] = useState<{
loaded: boolean;
api: {
parseMermaidToExcalidraw: (
defination: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
} | null;
}>({ loaded: false, api: null });
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
const resetPreview = () => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
useEffect(() => {
const loadMermaidToExcalidrawLib = async () => {
const api = await import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
);
setMermaidToExcalidrawLib({ loaded: true, api });
};
loadMermaidToExcalidrawLib();
}, []);
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
const renderExcalidrawPreview = async () => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (
!mermaidToExcalidrawLib.loaded ||
!canvasNode ||
!parent ||
!mermaidToExcalidrawLib.api
) {
return;
}
if (!deferredText) {
resetPreview();
return;
}
try {
const { elements, files } =
await mermaidToExcalidrawLib.api.parseMermaidToExcalidraw(
deferredText,
{
fontSize: DEFAULT_FONT_SIZE,
},
);
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (e: any) {
parent.style.background = "var(--default-bg-color)";
if (deferredText) {
setError(e.message);
}
}
};
renderExcalidrawPreview();
}, [deferredText, mermaidToExcalidrawLib]);
const onClose = () => {
app.setOpenDialog(null);
saveMermaidDataToStorage(text);
};
const onSelect = () => {
const { elements: newElements, files } = data.current;
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
onClose();
};
return (
<Dialog
className="dialog-mermaid"
onCloseRequest={onClose}
size={1200}
title={
<>
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
<span className="dialog-mermaid-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
<br />
</span>
</>
}
>
<div className="dialog-mermaid-body">
<div className="dialog-mermaid-panels">
<div className="dialog-mermaid-panels-text">
<label>{t("mermaid.syntax")}</label>
<textarea
onChange={(event) => setText(event.target.value)}
value={text}
/>
</div>
<div className="dialog-mermaid-panels-preview">
<label>{t("mermaid.preview")}</label>
<div className="dialog-mermaid-panels-preview-wrapper">
{error && <ErrorComp error={error} />}
{mermaidToExcalidrawLib.loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="dialog-mermaid-panels-preview-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
</div>
</div>
<div className="dialog-mermaid-buttons">
<Button className="dialog-mermaid-insert" onSelect={onSelect}>
{t("mermaid.button")}
<span>{ArrowRightIcon}</span>
</Button>
</div>
</div>
</Dialog>
);
};
export default MermaidToExcalidraw;
+1 -4
View File
@@ -18,11 +18,8 @@
overflow: auto;
padding: calc(var(--space-factor) * 10);
display: flex;
flex-direction: column;
.Island {
padding: 2.5rem;
padding: 2.5rem !important;
}
}
@@ -47,7 +47,7 @@ export const ExportToImage = () => {
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
onClick={() => {
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
setAppState({ openDialog: { name: "imageExport" } });
setAppState({ openDialog: "imageExport" });
}}
>
{t("overwriteConfirm.action.exportToImage.description")}
-10
View File
@@ -1,10 +0,0 @@
export const Paragraph = (props: {
children: React.ReactNode;
style?: React.CSSProperties;
}) => {
return (
<p className="excalidraw__paragraph" style={props.style}>
{props.children}
</p>
);
};
@@ -1,10 +0,0 @@
.excalidraw {
.dialog-mermaid {
&-title {
margin-block: 0.25rem;
font-size: 1.25rem;
font-weight: 700;
padding-inline: 2.5rem;
}
}
}
@@ -1,133 +0,0 @@
import { useState, useRef, useEffect, useDeferredValue } from "react";
import { BinaryFiles } from "../../types";
import { useApp } from "../App";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { ArrowRightIcon } from "../icons";
import "./MermaidToExcalidraw.scss";
import { t } from "../../i18n";
import Trans from "../Trans";
import {
LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW,
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
saveMermaidDataToStorage,
} from "./common";
import { TTDDialogPanels } from "./TTDDialogPanels";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
const MERMAID_EXAMPLE =
"flowchart TD\n A[Christmas] -->|Get money| B(Go shopping)\n B --> C{Let me think}\n C -->|One| D[Laptop]\n C -->|Two| E[iPhone]\n C -->|Three| F[Car]";
const importMermaidDataFromStorage = () => {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW);
if (data) {
return data;
}
} catch (error: any) {
// Unable to access localStorage
console.error(error);
}
return null;
};
const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
}: {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
}) => {
const [text, setText] = useState("");
const deferredText = useDeferredValue(text.trim());
const [error, setError] = useState<Error | null>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const app = useApp();
useEffect(() => {
const data = importMermaidDataFromStorage() || MERMAID_EXAMPLE;
setText(data);
}, []);
useEffect(() => {
convertMermaidToExcalidraw({
canvasRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
}).catch(() => {});
}, [deferredText, mermaidToExcalidrawLib]);
const textRef = useRef(text);
// slightly hacky but really quite simple
// essentially, we want to save the text to LS when the component unmounts
useEffect(() => {
textRef.current = text;
}, [text]);
useEffect(() => {
return () => {
if (textRef.current) {
saveMermaidDataToStorage(textRef.current);
}
};
}, []);
return (
<>
<div className="ttd-dialog-desc">
<Trans
i18nKey="mermaid.description"
flowchartLink={(el) => (
<a href="https://mermaid.js.org/syntax/flowchart.html">{el}</a>
)}
sequenceLink={(el) => (
<a href="https://mermaid.js.org/syntax/sequenceDiagram.html">
{el}
</a>
)}
/>
</div>
<TTDDialogPanels>
<TTDDialogPanel label={t("mermaid.syntax")}>
<TTDDialogInput
input={text}
placeholder={"Write Mermaid diagram defintion here..."}
onChange={(event) => setText(event.target.value)}
/>
</TTDDialogPanel>
<TTDDialogPanel
label={t("mermaid.preview")}
panelAction={{
action: () => {
insertToEditor({
app,
data: data.current,
text,
shouldSaveMermaidDataToStorage: true,
});
},
label: t("mermaid.button"),
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={canvasRef}
loaded={mermaidToExcalidrawLib.loaded}
error={error}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
export default MermaidToExcalidraw;
-301
View File
@@ -1,301 +0,0 @@
@import "../../css/variables.module";
$verticalBreakpoint: 861px;
.excalidraw {
.Modal.Dialog.ttd-dialog {
padding: 1.25rem;
&.Dialog--fullscreen {
margin-top: 0;
}
.Island {
padding-inline: 0 !important;
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
box-shadow: none;
}
.Modal__content {
height: auto;
max-height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
max-height: 750px;
height: 100%;
}
}
.Dialog__content {
flex: 1 1 auto;
}
}
.ttd-dialog-desc {
font-size: 15px;
font-style: italic;
font-weight: 500;
margin-bottom: 1.5rem;
}
.ttd-dialog-tabs-root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.ttd-dialog-tab-trigger {
color: var(--color-on-surface);
font-size: 0.875rem;
margin: 0;
padding: 0 1rem;
background-color: transparent;
border: 0;
height: 2.875rem;
font-weight: 600;
font-family: inherit;
letter-spacing: 0.4px;
&[data-state="active"] {
border-bottom: 2px solid var(--color-primary);
}
}
.ttd-dialog-triggers {
border-bottom: 1px solid var(--color-surface-high);
margin-bottom: 1.5rem;
padding-inline: 2.5rem;
}
.ttd-dialog-content {
padding-inline: 2.5rem;
height: 100%;
display: flex;
flex-direction: column;
&[hidden] {
display: none;
}
}
.ttd-dialog-input {
width: auto;
height: 10rem;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
font-family: monospace;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
height: 100%;
}
}
.ttd-dialog-output-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 0.85rem;
box-sizing: border-box;
flex-grow: 1;
position: relative;
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
height: 400px;
width: auto;
@media screen and (min-width: $verticalBreakpoint) {
width: 100%;
// acts as min-height
height: 200px;
}
canvas {
max-width: 100%;
max-height: 100%;
}
}
.ttd-dialog-output-canvas-container {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
flex-grow: 1;
}
.ttd-dialog-output-error {
color: red;
font-weight: 800;
font-size: 30px;
word-break: break-word;
overflow: auto;
max-height: 100%;
height: 100%;
width: 100%;
text-align: center;
position: absolute;
z-index: 10;
p {
font-weight: 500;
font-family: Cascadia;
text-align: left;
white-space: pre-wrap;
font-size: 0.875rem;
padding: 0 10px;
}
}
.ttd-dialog-panels {
height: 100%;
@media screen and (min-width: $verticalBreakpoint) {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
}
.ttd-dialog-panel {
display: flex;
flex-direction: column;
width: 100%;
&__header {
display: flex;
margin: 0px 4px 4px 4px;
align-items: center;
gap: 1rem;
label {
font-size: 14px;
font-style: normal;
font-weight: 600;
}
}
&:first-child {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 4rem;
}
}
@media screen and (min-width: $verticalBreakpoint) {
.ttd-dialog-panel-button-container:not(.invisible) {
margin-bottom: 0.5rem !important;
}
}
textarea {
height: 100%;
resize: none;
border-radius: var(--border-radius-lg);
border: 1px solid var(--dialog-border-color);
white-space: pre-wrap;
padding: 0.85rem;
box-sizing: border-box;
width: 100%;
font-family: monospace;
@media screen and (max-width: $verticalBreakpoint) {
width: auto;
height: 10rem;
}
}
}
.ttd-dialog-panel-button-container {
margin-top: 1rem;
margin-bottom: 0.5rem;
&.invisible {
.ttd-dialog-panel-button {
display: none;
@media screen and (min-width: $verticalBreakpoint) {
display: block;
visibility: hidden;
}
}
}
}
.ttd-dialog-panel-button {
&.excalidraw-button {
font-family: inherit;
font-weight: 600;
height: 2.5rem;
font-size: 12px;
color: $oc-white;
background-color: var(--color-primary);
width: 100%;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: var(--color-primary);
}
}
@media screen and (min-width: $verticalBreakpoint) {
width: auto;
min-width: 7.5rem;
}
@at-root .excalidraw.theme--dark#{&} {
color: var(--color-gray-100);
}
}
position: relative;
div {
display: contents;
&.invisible {
visibility: hidden;
}
&.Spinner {
display: flex !important;
position: absolute;
inset: 0;
--spinner-color: white;
@at-root .excalidraw.theme--dark#{&} {
--spinner-color: var(--color-gray-100);
}
}
span {
padding-left: 0.5rem;
display: flex;
}
}
}
}
-139
View File
@@ -1,139 +0,0 @@
import { Dialog } from "../Dialog";
import { useApp } from "../App";
import MermaidToExcalidraw from "./MermaidToExcalidraw";
import TTDDialogTabs from "./TTDDialogTabs";
import { useEffect, useState } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { withInternalFallback } from "../hoc/withInternalFallback";
import { TTDDialogTabTriggers } from "./TTDDialogTabTriggers";
import { TTDDialogTabTrigger } from "./TTDDialogTabTrigger";
import { TTDDialogTab } from "./TTDDialogTab";
import { t } from "../../i18n";
import { CommonDialogProps, MermaidToExcalidrawLibProps } from "./common";
import "./TTDDialog.scss";
import { TextToDiagram } from "./TextToDiagram";
import { TextToDrawing } from "./TextToDrawing";
export const TTDDialog = (props: CommonDialogProps | { __fallback: true }) => {
const appState = useUIAppState();
if (appState.openDialog?.name !== "ttd") {
return null;
}
return <TTDDialogBase {...props} tab={appState.openDialog.tab} />;
};
/**
* Text to diagram (TTD) dialog
*/
export const TTDDialogBase = withInternalFallback(
"TTDDialogBase",
({
tab,
...rest
}: {
tab: "text-to-diagram" | "mermaid" | "text-to-drawing";
} & (CommonDialogProps | { __fallback: true })) => {
const app = useApp();
const [mermaidToExcalidrawLib, setMermaidToExcalidrawLib] =
useState<MermaidToExcalidrawLibProps>({
loaded: false,
api: import(
/* webpackChunkName:"mermaid-to-excalidraw" */ "@excalidraw/mermaid-to-excalidraw"
),
});
useEffect(() => {
const fn = async () => {
await mermaidToExcalidrawLib.api;
setMermaidToExcalidrawLib((prev) => ({ ...prev, loaded: true }));
};
fn();
}, [mermaidToExcalidrawLib.api]);
return (
<Dialog
className="ttd-dialog"
onCloseRequest={() => {
app.setOpenDialog(null);
}}
size={1200}
title={false}
{...rest}
autofocus={false}
>
<TTDDialogTabs dialog="ttd" tab={tab}>
{"__fallback" in rest && rest.__fallback ? (
<p className="dialog-mermaid-title">{t("mermaid.title")}</p>
) : (
<TTDDialogTabTriggers>
<TTDDialogTabTrigger tab="text-to-diagram">
<div style={{ display: "flex", alignItems: "center" }}>
{t("labels.textToDiagram")}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "pink",
color: "#000",
}}
>
AI Beta
</div>
</div>
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="text-to-drawing">
<div style={{ display: "flex", alignItems: "center" }}>
{t("labels.textToDrawing")}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1px 6px",
marginLeft: "10px",
fontSize: 10,
borderRadius: "12px",
background: "pink",
color: "#000",
}}
>
AI Beta
</div>
</div>
</TTDDialogTabTrigger>
<TTDDialogTabTrigger tab="mermaid">Mermaid</TTDDialogTabTrigger>
</TTDDialogTabTriggers>
)}
<TTDDialogTab className="ttd-dialog-content" tab="mermaid">
<MermaidToExcalidraw
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
/>
</TTDDialogTab>
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram">
<TextToDiagram
onTextSubmit={rest.onTextSubmit}
mermaidToExcalidrawLib={mermaidToExcalidrawLib}
/>
</TTDDialogTab>
)}
{!("__fallback" in rest) && (
<TTDDialogTab className="ttd-dialog-content" tab="text-to-drawing">
<TextToDrawing onTextSubmit={rest.onTextSubmit} />
</TTDDialogTab>
)}
</TTDDialogTabs>
</Dialog>
);
},
);
@@ -1,52 +0,0 @@
import { ChangeEventHandler, useEffect, useRef } from "react";
import { EVENT } from "../../constants";
import { KEYS } from "../../keys";
interface TTDDialogInputProps {
input: string;
placeholder: string;
onChange: ChangeEventHandler<HTMLTextAreaElement>;
onKeyboardSubmit?: () => void;
}
export const TTDDialogInput = ({
input,
placeholder,
onChange,
onKeyboardSubmit,
}: TTDDialogInputProps) => {
const ref = useRef<HTMLTextAreaElement>(null);
const callbackRef = useRef(onKeyboardSubmit);
callbackRef.current = onKeyboardSubmit;
useEffect(() => {
if (!callbackRef.current) {
return;
}
const textarea = ref.current;
if (textarea) {
const handleKeyDown = (event: KeyboardEvent) => {
if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.ENTER) {
event.preventDefault();
callbackRef.current?.();
}
};
textarea.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
textarea.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}
}, []);
return (
<textarea
className="ttd-dialog-input"
onChange={onChange}
value={input}
placeholder={placeholder}
autoFocus
ref={ref}
/>
);
};
@@ -1,39 +0,0 @@
import Spinner from "../Spinner";
const ErrorComp = ({ error }: { error: string }) => {
return (
<div
data-testid="ttd-dialog-output-error"
className="ttd-dialog-output-error"
>
Error! <p>{error}</p>
</div>
);
};
interface TTDDialogOutputProps {
error: Error | null;
canvasRef: React.RefObject<HTMLDivElement>;
loaded: boolean;
}
export const TTDDialogOutput = ({
error,
canvasRef,
loaded,
}: TTDDialogOutputProps) => {
return (
<div className="ttd-dialog-output-wrapper">
{error && <ErrorComp error={error.message} />}
{loaded ? (
<div
ref={canvasRef}
style={{ opacity: error ? "0.15" : 1 }}
className="ttd-dialog-output-canvas-container"
/>
) : (
<Spinner size="2rem" />
)}
</div>
);
};
@@ -1,58 +0,0 @@
import { ReactNode } from "react";
import { Button } from "../Button";
import clsx from "clsx";
import Spinner from "../Spinner";
interface TTDDialogPanelProps {
label: string;
children: ReactNode;
panelAction?: {
label: string;
action: () => void;
icon?: ReactNode;
};
panelActionDisabled?: boolean;
onTextSubmitInProgess?: boolean;
renderTopRight?: () => ReactNode;
renderBottomRight?: () => ReactNode;
}
export const TTDDialogPanel = ({
label,
children,
panelAction,
panelActionDisabled = false,
onTextSubmitInProgess,
renderTopRight,
renderBottomRight,
}: TTDDialogPanelProps) => {
return (
<div className="ttd-dialog-panel">
<div className="ttd-dialog-panel__header">
<label>{label}</label>
{renderTopRight?.()}
</div>
{children}
<div
className={clsx("ttd-dialog-panel-button-container", {
invisible: !panelAction,
})}
style={{ display: "flex", alignItems: "center" }}
>
<Button
className="ttd-dialog-panel-button"
onSelect={panelAction ? panelAction.action : () => {}}
disabled={panelActionDisabled || onTextSubmitInProgess}
>
<div className={clsx({ invisible: onTextSubmitInProgess })}>
{panelAction?.label}
{panelAction?.icon && <span>{panelAction.icon}</span>}
</div>
{onTextSubmitInProgess && <Spinner />}
</Button>
{renderBottomRight?.()}
</div>
</div>
);
};
@@ -1,5 +0,0 @@
import { ReactNode } from "react";
export const TTDDialogPanels = ({ children }: { children: ReactNode }) => {
return <div className="ttd-dialog-panels">{children}</div>;
};
-17
View File
@@ -1,17 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTab = ({
tab,
children,
...rest
}: {
tab: string;
children: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.Content {...rest} value={tab}>
{children}
</RadixTabs.Content>
);
};
TTDDialogTab.displayName = "TTDDialogTab";
@@ -1,21 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTrigger = ({
children,
tab,
onSelect,
...rest
}: {
children: React.ReactNode;
tab: string;
onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
return (
<RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
<button type="button" className="ttd-dialog-tab-trigger" {...rest}>
{children}
</button>
</RadixTabs.Trigger>
);
};
TTDDialogTabTrigger.displayName = "TTDDialogTabTrigger";
@@ -1,13 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
export const TTDDialogTabTriggers = ({
children,
...rest
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
return (
<RadixTabs.List className="ttd-dialog-triggers" {...rest}>
{children}
</RadixTabs.List>
);
};
TTDDialogTabTriggers.displayName = "TTDDialogTabTriggers";
@@ -1,67 +0,0 @@
import * as RadixTabs from "@radix-ui/react-tabs";
import { ReactNode, useRef } from "react";
import { useExcalidrawSetAppState } from "../App";
import { isMemberOf } from "../../utils";
const TTDDialogTabs = (
props: {
children: ReactNode;
} & (
| { dialog: "ttd"; tab: "text-to-diagram" | "mermaid" | "text-to-drawing" }
| {
dialog: "settings";
tab: "text-to-diagram" | "diagram-to-code";
}
),
) => {
const setAppState = useExcalidrawSetAppState();
const rootRef = useRef<HTMLDivElement>(null);
const minHeightRef = useRef<number>(0);
return (
<RadixTabs.Root
ref={rootRef}
className="ttd-dialog-tabs-root"
value={props.tab}
onValueChange={(
// at least in test enviros, `tab` can be `undefined`
tab: string | undefined,
) => {
if (!tab) {
return;
}
const modalContentNode =
rootRef.current?.closest<HTMLElement>(".Modal__content");
if (modalContentNode) {
const currHeight = modalContentNode.offsetHeight || 0;
if (currHeight > minHeightRef.current) {
minHeightRef.current = currHeight;
modalContentNode.style.minHeight = `min(${minHeightRef.current}px, 100%)`;
}
}
if (
props.dialog === "settings" &&
isMemberOf(["text-to-diagram", "diagram-to-code"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab, source: "settings" },
});
} else if (
props.dialog === "ttd" &&
isMemberOf(["text-to-diagram", "mermaid", "text-to-drawing"], tab)
) {
setAppState({
openDialog: { name: props.dialog, tab },
});
}
}}
>
{props.children}
</RadixTabs.Root>
);
};
TTDDialogTabs.displayName = "TTDDialogTabs";
export default TTDDialogTabs;
@@ -1,38 +0,0 @@
import { ReactNode } from "react";
import { useTunnels } from "../../context/tunnels";
import DropdownMenu from "../dropdownMenu/DropdownMenu";
import { useExcalidrawSetAppState } from "../App";
import { brainIcon } from "../icons";
import { t } from "../../i18n";
import { trackEvent } from "../../analytics";
export const TTDDialogTrigger = ({
children,
icon,
tab,
}: {
children?: ReactNode;
icon?: JSX.Element;
tab?: "mermaid" | "text-to-diagram" | "text-to-drawing";
}) => {
const { TTDDialogTriggerTunnel } = useTunnels();
const setAppState = useExcalidrawSetAppState();
return (
<TTDDialogTriggerTunnel.In>
<DropdownMenu.Item
onSelect={() => {
trackEvent("ai", "dialog open", "ttd");
setAppState({
openDialog: { name: "ttd", tab: tab ?? "text-to-diagram" },
});
}}
icon={icon ?? brainIcon}
>
{children ?? t("labels.textToDiagram")}
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
</DropdownMenu.Item>
</TTDDialogTriggerTunnel.In>
);
};
TTDDialogTrigger.displayName = "TTDDialogTrigger";
-228
View File
@@ -1,228 +0,0 @@
import { useAtom } from "jotai";
import { useRef, useState, ChangeEventHandler } from "react";
import { trackEvent } from "../../analytics";
import { t } from "../../i18n";
import { isFiniteNumber } from "../../utils";
import { ArrowRightIcon } from "../icons";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
CommonDialogProps,
MAX_PROMPT_LENGTH,
MIN_PROMPT_LENGTH,
MermaidToExcalidrawLibProps,
convertMermaidToExcalidraw,
insertToEditor,
rateLimitsAtom,
saveMermaidDataToStorage,
} from "./common";
import { useApp } from "../App";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { BinaryFiles } from "../../types";
export type TextToDiagramProps = CommonDialogProps & {
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
};
export const TextToDiagram = ({
onTextSubmit,
mermaidToExcalidrawLib,
}: TextToDiagramProps) => {
const app = useApp();
const someRandomDivRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState("");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
setText(event.target.value);
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const data = useRef<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>({ elements: [], files: null });
const [error, setError] = useState<Error | null>(null);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "ttd");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await onTextSubmit(prompt, "text-to-diagram");
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
try {
await convertMermaidToExcalidraw({
canvasRef: someRandomDivRef,
data,
mermaidToExcalidrawLib,
setError,
mermaidDefinition: generatedResponse,
});
trackEvent("ai", "mermaid parse success", "ttd");
saveMermaidDataToStorage(generatedResponse);
} catch (error: any) {
console.info(
`%cTTD mermaid render errror: ${error.message}`,
"color: red",
);
console.info(
`>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>\nTTD mermaid definition render errror: ${error.message}`,
"color: yellow",
);
trackEvent("ai", "mermaid parse failed", "ttd");
setError(
new Error(
"Generated an invalid diagram :(. You may also try a different prompt.",
),
);
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
return (
<>
<div className="ttd-dialog-desc">
Currently we use Mermaid as a middle step, so you'll get best results if
you describe a diagram, workflow, flow chart, and similar.
</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderBottomRight={() => {
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color: ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
console.info("Panel action clicked");
insertToEditor({ app, data: data.current });
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={someRandomDivRef}
error={error}
loaded={mermaidToExcalidrawLib.loaded}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
-248
View File
@@ -1,248 +0,0 @@
import { useAtom } from "jotai";
import { useRef, useState, ChangeEventHandler } from "react";
import { trackEvent } from "../../analytics";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { isFiniteNumber } from "../../utils";
import { useApp } from "../App";
import { ArrowRightIcon } from "../icons";
import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { TTDDialogPanel } from "./TTDDialogPanel";
import { TTDDialogPanels } from "./TTDDialogPanels";
import {
CommonDialogProps,
MAX_PROMPT_LENGTH,
MIN_PROMPT_LENGTH,
insertToEditor,
rateLimitsAtom,
resetPreview,
} from "./common";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../../packages/excalidraw/index";
import { DEFAULT_EXPORT_PADDING } from "../../constants";
import { canvasToBlob } from "../../data/blob";
export type TextToDrawingProps = CommonDialogProps;
export const TextToDrawing = ({ onTextSubmit }: TextToDrawingProps) => {
const app = useApp();
const containerRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState("");
const prompt = text.trim();
const handleTextChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
setText(event.target.value);
};
const [onTextSubmitInProgess, setOnTextSubmitInProgess] = useState(false);
const [rateLimits, setRateLimits] = useAtom(rateLimitsAtom);
const [data, setData] = useState<
readonly NonDeletedExcalidrawElement[] | null
>(null);
const [error, setError] = useState<Error | null>(null);
const onGenerate = async () => {
if (
prompt.length > MAX_PROMPT_LENGTH ||
prompt.length < MIN_PROMPT_LENGTH ||
onTextSubmitInProgess ||
rateLimits?.rateLimitRemaining === 0
) {
if (prompt.length < MIN_PROMPT_LENGTH) {
setError(
new Error(
`Prompt is too short (min ${MIN_PROMPT_LENGTH} characters)`,
),
);
}
if (prompt.length > MAX_PROMPT_LENGTH) {
setError(
new Error(`Prompt is too long (max ${MAX_PROMPT_LENGTH} characters)`),
);
}
return;
}
try {
setOnTextSubmitInProgess(true);
trackEvent("ai", "generate", "text-to-drawing");
const { generatedResponse, error, rateLimit, rateLimitRemaining } =
await onTextSubmit(prompt, "text-to-drawing");
if (isFiniteNumber(rateLimit) && isFiniteNumber(rateLimitRemaining)) {
setRateLimits({ rateLimit, rateLimitRemaining });
}
if (error) {
setError(error);
return;
}
if (!generatedResponse) {
setError(new Error("Generation failed"));
return;
}
const canvasNode = containerRef.current;
const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) {
return;
}
if (!text) {
resetPreview({ canvasRef: containerRef, setError });
return;
}
if (!Array.isArray(generatedResponse)) {
setError(new Error("Generation failed to return an array!"));
return;
}
try {
const elements = convertToExcalidrawElements(generatedResponse, {
regenerateIds: true,
});
setData(elements);
const canvas = await exportToCanvas({
elements,
files: null,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (err: any) {
console.error(err);
parent.style.background = "var(--default-bg-color)";
if (text) {
setError(err);
}
throw err;
}
} catch (error: any) {
let message: string | undefined = error.message;
if (!message || message === "Failed to fetch") {
message = "Request failed";
}
setError(new Error(message));
} finally {
setOnTextSubmitInProgess(false);
}
};
const refOnGenerate = useRef(onGenerate);
refOnGenerate.current = onGenerate;
return (
<>
<div className="ttd-dialog-desc">This is text to drawing.</div>
<TTDDialogPanels>
<TTDDialogPanel
label={t("labels.prompt")}
panelAction={{
action: onGenerate,
label: "Generate",
icon: ArrowRightIcon,
}}
onTextSubmitInProgess={onTextSubmitInProgess}
panelActionDisabled={
prompt.length > MAX_PROMPT_LENGTH ||
rateLimits?.rateLimitRemaining === 0
}
renderTopRight={() => {
if (!rateLimits) {
return null;
}
return (
<div
className="ttd-dialog-rate-limit"
style={{
fontSize: 12,
marginLeft: "auto",
color:
rateLimits.rateLimitRemaining === 0
? "var(--color-danger)"
: undefined,
}}
>
{rateLimits.rateLimitRemaining} requests left today
</div>
);
}}
renderBottomRight={() => {
const ratio = prompt.length / MAX_PROMPT_LENGTH;
if (ratio > 0.8) {
return (
<div
style={{
marginLeft: "auto",
fontSize: 12,
fontFamily: "monospace",
color: ratio > 1 ? "var(--color-danger)" : undefined,
}}
>
Length: {prompt.length}/{MAX_PROMPT_LENGTH}
</div>
);
}
return null;
}}
>
<TTDDialogInput
onChange={handleTextChange}
input={text}
placeholder={"Describe what you want to see..."}
onKeyboardSubmit={() => {
refOnGenerate.current();
}}
/>
</TTDDialogPanel>
<TTDDialogPanel
label="Preview"
panelAction={{
action: () => {
if (data) {
insertToEditor({
app,
data: {
elements: data,
files: null,
},
});
}
},
label: "Insert",
icon: ArrowRightIcon,
}}
>
<TTDDialogOutput
canvasRef={containerRef}
error={error}
loaded={true}
/>
</TTDDialogPanel>
</TTDDialogPanels>
</>
);
};
-193
View File
@@ -1,193 +0,0 @@
import { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
import { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
import { DEFAULT_EXPORT_PADDING, DEFAULT_FONT_SIZE } from "../../constants";
import {
convertToExcalidrawElements,
exportToCanvas,
} from "../../packages/excalidraw/index";
import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppClassProperties, BinaryFiles } from "../../types";
import { canvasToBlob } from "../../data/blob";
import { atom } from "jotai";
export const resetPreview = ({
canvasRef,
setError,
}: {
canvasRef: React.RefObject<HTMLDivElement>;
setError: (error: Error | null) => void;
}) => {
const canvasNode = canvasRef.current;
if (!canvasNode) {
return;
}
const parent = canvasNode.parentElement;
if (!parent) {
return;
}
parent.style.background = "";
setError(null);
canvasNode.replaceChildren();
};
export type OnTestSubmitRetValue = {
rateLimit?: number | null;
rateLimitRemaining?: number | null;
} & (
| {
generatedResponse: any | string | undefined;
error?: null | undefined;
}
| {
error: Error;
generatedResponse?: null | undefined;
}
);
export interface CommonDialogProps {
onTextSubmit(
value: string,
type: "text-to-diagram" | "text-to-drawing",
): Promise<OnTestSubmitRetValue>;
}
export interface MermaidToExcalidrawLibProps {
loaded: boolean;
api: Promise<{
parseMermaidToExcalidraw: (
definition: string,
options: MermaidOptions,
) => Promise<MermaidToExcalidrawResult>;
}>;
}
interface ConvertMermaidToExcalidrawFormatProps {
canvasRef: React.RefObject<HTMLDivElement>;
mermaidToExcalidrawLib: MermaidToExcalidrawLibProps;
mermaidDefinition: string;
setError: (error: Error | null) => void;
data: React.MutableRefObject<{
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}>;
}
export const convertMermaidToExcalidraw = async ({
canvasRef,
mermaidToExcalidrawLib,
mermaidDefinition,
setError,
data,
}: ConvertMermaidToExcalidrawFormatProps) => {
const canvasNode = canvasRef.current;
const parent = canvasNode?.parentElement;
if (!canvasNode || !parent) {
return;
}
if (!mermaidDefinition) {
resetPreview({ canvasRef, setError });
return;
}
try {
const api = await mermaidToExcalidrawLib.api;
let ret;
try {
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
fontSize: DEFAULT_FONT_SIZE,
});
} catch (err: any) {
ret = await api.parseMermaidToExcalidraw(
mermaidDefinition.replace(/"/g, "'"),
{
fontSize: DEFAULT_FONT_SIZE,
},
);
}
const { elements, files } = ret;
setError(null);
data.current = {
elements: convertToExcalidrawElements(elements, {
regenerateIds: true,
}),
files,
};
const canvas = await exportToCanvas({
elements: data.current.elements,
files: data.current.files,
exportPadding: DEFAULT_EXPORT_PADDING,
maxWidthOrHeight:
Math.max(parent.offsetWidth, parent.offsetHeight) *
window.devicePixelRatio,
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (err: any) {
console.error(err);
parent.style.background = "var(--default-bg-color)";
if (mermaidDefinition) {
setError(err);
}
throw err;
}
};
export const LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW = "mermaid-to-excalidraw";
export const saveMermaidDataToStorage = (data: string) => {
try {
localStorage.setItem(LOCAL_STORAGE_KEY_MERMAID_TO_EXCALIDRAW, data);
} catch (error: any) {
// Unable to access window.localStorage
console.error(error);
}
};
export const insertToEditor = ({
app,
data,
text,
shouldSaveMermaidDataToStorage,
}: {
app: AppClassProperties;
data: {
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
};
text?: string;
shouldSaveMermaidDataToStorage?: boolean;
}) => {
const { elements: newElements, files } = data;
if (!newElements.length) {
return;
}
app.addElementsFromPasteOrLibrary({
elements: newElements,
files,
position: "center",
fitToContent: true,
});
app.setOpenDialog(null);
if (shouldSaveMermaidDataToStorage && text) {
saveMermaidDataToStorage(text);
}
};
export const MIN_PROMPT_LENGTH = 3;
export const MAX_PROMPT_LENGTH = 1000;
export const rateLimitsAtom = atom<{
rateLimit: number;
rateLimitRemaining: number;
} | null>(null);
+8 -13
View File
@@ -25,7 +25,7 @@ type TextFieldProps = {
label?: string;
placeholder?: string;
isRedacted?: boolean;
isPassword?: boolean;
};
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -39,7 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly,
selectOnRender,
onKeyDown,
isRedacted = false,
isPassword = false,
},
ref,
) => {
@@ -53,8 +53,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
}
}, [selectOnRender]);
const [isTemporarilyUnredacted, setIsTemporarilyUnredacted] =
useState<boolean>(false);
const [isVisible, setIsVisible] = useState<boolean>(true);
return (
<div
@@ -72,9 +71,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})}
>
<input
className={clsx({
"is-redacted": value && isRedacted && !isTemporarilyUnredacted,
})}
type={isPassword && isVisible ? "password" : undefined}
readOnly={readonly}
value={value}
placeholder={placeholder}
@@ -82,14 +79,12 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown}
/>
{isRedacted && (
{isPassword && (
<Button
onSelect={() =>
setIsTemporarilyUnredacted(!isTemporarilyUnredacted)
}
style={{ border: 0, userSelect: "none" }}
onSelect={() => setIsVisible(!isVisible)}
style={{ border: 0 }}
>
{isTemporarilyUnredacted ? eyeClosedIcon : eyeIcon}
{isVisible ? eyeIcon : eyeClosedIcon}
</Button>
)}
</div>
+7 -2
View File
@@ -24,6 +24,7 @@ type ToolButtonBaseProps = {
hidden?: boolean;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
@@ -123,10 +124,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading}
disabled={isLoading || props.isLoading || !!props.disabled}
>
{(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.keyBindingLabel && (
<span className="ToolIcon__keybinding">
+18 -2
View File
@@ -77,8 +77,8 @@
}
.ToolIcon_type_button,
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
.Modal .ToolIcon_type_button
{
padding: 0;
border: none;
margin: 0;
@@ -101,6 +101,22 @@
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 {
visibility: visible;
}
@@ -63,13 +63,9 @@
}
&__text {
display: flex;
align-items: center;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
gap: 0.75rem;
}
&__shortcut {
@@ -37,32 +37,6 @@ const DropdownMenuItem = ({
</button>
);
};
DropdownMenuItem.displayName = "DropdownMenuItem";
export const DropDownMenuItemBadge = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "1px 4px",
background: "pink",
borderRadius: 6,
fontSize: 11,
color: "black",
fontFamily: "monospace",
}}
>
{children}
</div>
);
};
DropDownMenuItemBadge.displayName = "DropdownMenuItemBadge";
DropdownMenuItem.Badge = DropDownMenuItemBadge;
export default DropdownMenuItem;
DropdownMenuItem.displayName = "DropdownMenuItem";
-23
View File
@@ -1742,26 +1742,3 @@ export const eyeClosedIcon = createIcon(
</g>,
tablerIconProps,
);
export const brainIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15.5 13a3.5 3.5 0 0 0 -3.5 3.5v1a3.5 3.5 0 0 0 7 0v-1.8" />
<path d="M8.5 13a3.5 3.5 0 0 1 3.5 3.5v1a3.5 3.5 0 0 1 -7 0v-1.8" />
<path d="M17.5 16a3.5 3.5 0 0 0 0 -7h-.5" />
<path d="M19 9.3v-2.8a3.5 3.5 0 0 0 -7 0" />
<path d="M6.5 16a3.5 3.5 0 0 1 0 -7h.5" />
<path d="M5 9.3v-2.8a3.5 3.5 0 0 1 7 0v10" />
</g>,
tablerIconProps,
);
export const drawingIcon = createIcon(
<g stroke="currentColor" fill="none">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M20 17v-12c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v12l2 2l2 -2z" />
<path d="M16 7h4" />
<path d="M18 19h-13a2 2 0 1 1 0 -4h4a2 2 0 1 0 0 -4h-3" />
</g>,
tablerIconProps,
);
+2 -2
View File
@@ -107,7 +107,7 @@ export const SaveAsImage = () => {
<DropdownMenuItem
icon={ExportImageIcon}
data-testid="image-export-button"
onSelect={() => setAppState({ openDialog: { name: "imageExport" } })}
onSelect={() => setAppState({ openDialog: "imageExport" })}
shortcut={getShortcutFromShortcutName("imageExport")}
aria-label={t("buttons.exportImage")}
>
@@ -230,7 +230,7 @@ export const Export = () => {
<DropdownMenuItem
icon={ExportIcon}
onSelect={() => {
setAppState({ openDialog: { name: "jsonExport" } });
setAppState({ openDialog: "jsonExport" });
}}
data-testid="json-export-button"
aria-label={t("buttons.export")}
+1 -4
View File
@@ -196,6 +196,7 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -302,10 +303,6 @@ export const ROUNDNESS = {
ADAPTIVE_RADIUS: 3,
} as const;
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const ROUGHNESS = {
architect: 0,
artist: 1,
-2
View File
@@ -13,7 +13,6 @@ type TunnelsContextValue = {
DefaultSidebarTriggerTunnel: Tunnel;
DefaultSidebarTabTriggersTunnel: Tunnel;
OverwriteConfirmDialogTunnel: Tunnel;
TTDDialogTriggerTunnel: Tunnel;
jotaiScope: symbol;
};
@@ -33,7 +32,6 @@ export const useInitializeTunnels = () => {
DefaultSidebarTriggerTunnel: tunnel(),
DefaultSidebarTabTriggersTunnel: tunnel(),
OverwriteConfirmDialogTunnel: tunnel(),
TTDDialogTriggerTunnel: tunnel(),
jotaiScope: Symbol(),
};
}, []);
-20
View File
@@ -39,7 +39,6 @@
button {
cursor: pointer;
user-select: none;
}
&:focus {
@@ -534,12 +533,6 @@
}
}
input.is-redacted {
// we don't use type=password because browsers (chrome?) prompt
// you to save it which is annoying
-webkit-text-security: disc;
}
input[type="text"],
textarea:not(.excalidraw-wysiwyg) {
color: var(--text-primary-color);
@@ -652,19 +645,6 @@
--button-bg: var(--color-surface-high);
}
}
.excalidraw__paragraph {
margin: 1rem 0;
}
.Modal__content {
.excalidraw__paragraph:first-child {
margin-top: 0;
}
.excalidraw__paragraph + .excalidraw__paragraph {
margin-top: 0rem;
}
}
}
.ErrorSplash.excalidraw {
+2
View File
@@ -97,6 +97,8 @@
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-disabled: var(--color-gray-40);
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
+9
View File
@@ -50,6 +50,15 @@
color: var(--color-on-primary-container);
}
}
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
}
}
@@ -15,6 +15,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 300,
@@ -50,6 +51,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -86,6 +88,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"gap": 1,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 35,
@@ -139,6 +142,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
"gap": 3.834326468444573,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -191,6 +195,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 300,
@@ -230,6 +235,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -274,6 +280,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -320,6 +327,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"gap": 205,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -371,6 +379,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -417,6 +426,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"gap": 1,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -468,6 +478,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -508,6 +519,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -543,6 +555,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -584,6 +597,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"gap": 1,
},
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -635,6 +649,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -679,6 +694,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -723,6 +739,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -758,6 +775,7 @@ exports[`Test Transform > should not allow duplicate ids 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 200,
@@ -790,6 +808,7 @@ exports[`Test Transform > should transform linear elements 1`] = `
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -835,6 +854,7 @@ exports[`Test Transform > should transform linear elements 2`] = `
"endArrowhead": "triangle",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -880,6 +900,7 @@ exports[`Test Transform > should transform linear elements 3`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -925,6 +946,7 @@ exports[`Test Transform > should transform linear elements 4`] = `
"endArrowhead": null,
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -968,6 +990,7 @@ exports[`Test Transform > should transform regular shapes 1`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -998,6 +1021,7 @@ exports[`Test Transform > should transform regular shapes 2`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1028,6 +1052,7 @@ exports[`Test Transform > should transform regular shapes 3`] = `
"backgroundColor": "transparent",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1058,6 +1083,7 @@ exports[`Test Transform > should transform regular shapes 4`] = `
"backgroundColor": "#c0eb75",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1088,6 +1114,7 @@ exports[`Test Transform > should transform regular shapes 5`] = `
"backgroundColor": "#ffc9c9",
"boundElements": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1118,6 +1145,7 @@ exports[`Test Transform > should transform regular shapes 6`] = `
"backgroundColor": "#a5d8ff",
"boundElements": null,
"fillStyle": "cross-hatch",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 100,
@@ -1152,6 +1180,7 @@ exports[`Test Transform > should transform text element 1`] = `
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1191,6 +1220,7 @@ exports[`Test Transform > should transform text element 2`] = `
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1233,6 +1263,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1283,6 +1314,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1333,6 +1365,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1383,6 +1416,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"endArrowhead": "arrow",
"endBinding": null,
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 0,
@@ -1430,6 +1464,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1469,6 +1504,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1508,6 +1544,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1548,6 +1585,7 @@ exports[`Test Transform > should transform to labelled arrows when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1589,6 +1627,7 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 35,
@@ -1624,6 +1663,7 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 85,
@@ -1659,6 +1699,7 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 170,
@@ -1694,6 +1735,7 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 120,
@@ -1729,6 +1771,7 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 85,
@@ -1764,6 +1807,7 @@ exports[`Test Transform > should transform to text containers when label provide
},
],
"fillStyle": "solid",
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 120,
@@ -1798,6 +1842,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 25,
@@ -1837,6 +1882,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1877,6 +1923,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 75,
@@ -1919,6 +1966,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 50,
@@ -1959,6 +2007,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 75,
@@ -2000,6 +2049,7 @@ exports[`Test Transform > should transform to text containers when label provide
"fillStyle": "solid",
"fontFamily": 1,
"fontSize": 20,
"fractionalIndex": null,
"frameId": null,
"groupIds": [],
"height": 75,
+5 -11
View File
@@ -26,7 +26,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY,
ROUNDNESS,
DEFAULT_SIDEBAR,
@@ -44,6 +43,7 @@ import {
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
import { restoreFractionalIndicies } from "../fractionalIndex";
type RestoredAppState = Omit<
AppState,
@@ -101,8 +101,6 @@ const restoreElementWithProperties = <
boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
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>>,
>(
@@ -115,14 +113,14 @@ const restoreElementWithProperties = <
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string;
} = {
const base: Pick<T, keyof ExcalidrawElement> = {
type: extra.type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
versionNonce: element.versionNonce ?? 0,
// TODO: think about this more
fractionalIndex: element.fractionalIndex ?? null,
isDeleted: element.isDeleted ?? false,
id: element.id || randomId(),
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
@@ -166,10 +164,6 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData;
}
if (PRECEDING_ELEMENT_KEY in element) {
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
}
return {
...base,
...getNormalizedDimensions(base),
@@ -467,7 +461,7 @@ export const restoreElements = (
}
}
return restoredElements;
return restoreFractionalIndicies(restoredElements) as ExcalidrawElement[];
};
const coalesceAppStateValue = <
+2 -1
View File
@@ -40,6 +40,7 @@ import { trackEvent } from "../analytics";
import { useAppProps, useExcalidrawAppState } from "../components/App";
import { isEmbeddableElement } from "./typeChecks";
import { ShapeCache } from "../scene/ShapeCache";
import { StoreAction } from "../actions/types";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
@@ -343,7 +344,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor",
openMenu: null,
},
commitToHistory: true,
storeAction: StoreAction.CAPTURE,
};
},
trackEvent: { category: "hyperlink", action: "click" },
+5 -20
View File
@@ -13,7 +13,6 @@ import { Point } from "../types";
import { generateRoughOptions } from "../scene/Shape";
import {
isArrowElement,
isBoundToContainer,
isFreeDrawElement,
isLinearElement,
isTextElement,
@@ -23,7 +22,6 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene";
export type RectangleBox = {
x: number;
@@ -55,29 +53,16 @@ export class ElementBounds {
static getBounds(element: ExcalidrawElement) {
const cachedBounds = ElementBounds.boundsCache.get(element);
if (
cachedBounds?.version &&
cachedBounds.version === element.version &&
// we don't invalidate cache when we update containers and not labels,
// which is causing problems down the line. Fix TBA.
!isBoundToContainer(element)
) {
if (cachedBounds?.version && cachedBounds.version === element.version) {
return cachedBounds.bounds;
}
const bounds = ElementBounds.calculateBounds(element);
// hack to ensure that downstream checks could retrieve element Scene
// so as to have correctly calculated bounds
// FIXME remove when we get rid of all the id:Scene / element:Scene mapping
const shouldCache = Scene.getScene(element);
if (shouldCache) {
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
}
ElementBounds.boundsCache.set(element, {
version: element.version,
bounds,
});
return bounds;
}
+3 -1
View File
@@ -17,6 +17,7 @@ import {
IframeData,
NonDeletedExcalidrawElement,
} from "./types";
import { StoreAction } from "../actions/types";
const embeddedLinkCache = new Map<string, IframeData>();
@@ -286,7 +287,8 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable",
}),
},
commitToHistory: false,
storeAction: StoreAction.NONE,
};
},
});
+3 -3
View File
@@ -33,7 +33,6 @@ import {
InteractiveCanvasAppState,
} from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
import Scene from "../scene/Scene";
import {
@@ -48,6 +47,7 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { Store } from "../store";
const editorMidPointsCache: {
version: number | null;
@@ -602,7 +602,7 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent<HTMLElement>,
appState: AppState,
history: History,
store: Store,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
): {
@@ -654,7 +654,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
history.resumeRecording();
store.resumeCapturing();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
+17 -14
View File
@@ -106,24 +106,27 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
export const newElementWith = <TElement extends ExcalidrawElement>(
element: TElement,
updates: ElementUpdate<TElement>,
forceUpdate: boolean = false,
): TElement => {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
if (!forceUpdate) {
let didChange = false;
for (const key in updates) {
const value = (updates as any)[key];
if (typeof value !== "undefined") {
if (
(element as any)[key] === value &&
// if object, always update because its attrs could have changed
(typeof value !== "object" || value === null)
) {
continue;
}
didChange = true;
}
didChange = true;
}
}
if (!didChange) {
return element;
if (!didChange) {
return element;
}
}
return {
+4
View File
@@ -55,6 +55,7 @@ export type ElementConstructorOpts = MarkOptional<
| "angle"
| "groupIds"
| "frameId"
| "fractionalIndex"
| "boundElements"
| "seed"
| "version"
@@ -88,6 +89,8 @@ const _newElementBase = <T extends ExcalidrawElement>(
angle = 0,
groupIds = [],
frameId = null,
// TODO: think about this more
fractionalIndex = null,
roundness = null,
boundElements = null,
link = null,
@@ -113,6 +116,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity,
groupIds,
frameId,
fractionalIndex,
roundness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
+1
View File
@@ -50,6 +50,7 @@ type _ExcalidrawElementBase = Readonly<{
Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */
versionNonce: number;
fractionalIndex: string | null;
isDeleted: boolean;
/** List of groups the element belongs to.
Ordered from deepest to shallowest. */
+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;
};
+155 -247
View File
@@ -1,265 +1,173 @@
import { AppState } from "./types";
import { AppStateChange, ElementsChange } from "./change";
import { ExcalidrawElement } from "./element/types";
import { isLinearElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement";
import { Mutable } from "./utility-types";
import { AppState } from "./types";
export interface HistoryEntry {
appState: ReturnType<typeof clearAppStatePropertiesForHistory>;
elements: ExcalidrawElement[];
}
// TODO_UNDO: think about limiting the depth of stack
export class History {
private readonly undoStack: HistoryEntry[] = [];
private readonly redoStack: HistoryEntry[] = [];
interface DehydratedExcalidrawElement {
id: string;
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;
}),
};
public get isUndoStackEmpty() {
return this.undoStack.length === 0;
}
private dehydrateHistoryEntry({
appState,
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,
};
}),
};
public get isRedoStackEmpty() {
return this.redoStack.length === 0;
}
getSnapshotForTest() {
return {
recording: this.recording,
stateHistory: this.stateHistory.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
redoStack: this.redoStack.map((dehydratedHistoryEntry) =>
this.hydrateHistoryEntry(dehydratedHistoryEntry),
),
};
}
clear() {
this.stateHistory.length = 0;
public clear() {
this.undoStack.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
* 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.
* Record a local change which will go into the history
*/
setCurrentState(appState: AppState, elements: readonly ExcalidrawElement[]) {
this.lastEntry = this.hydrateHistoryEntry(
this.generateEntry(appState, elements),
);
}
public record(
elementsChange: ElementsChange,
appStateChange: AppStateChange,
) {
const entry = HistoryEntry.create(appStateChange, elementsChange);
// Suspicious that this is called so many places. Seems error-prone.
resumeRecording() {
this.recording = true;
}
if (!entry.isEmpty()) {
this.undoStack.push(entry);
record(state: AppState, elements: readonly ExcalidrawElement[]) {
if (this.recording) {
this.pushEntry(state, elements);
this.recording = false;
// As a new entry was pushed, we invalidate the redo stack
this.redoStack.length = 0;
}
}
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();
}
}
+1 -4
View File
@@ -132,10 +132,7 @@
"sidebarLock": "Keep sidebar open",
"selectAllElementsInFrame": "Select all elements in frame",
"removeAllElementsFromFrame": "Remove all elements from frame",
"eyeDropper": "Pick color from canvas",
"textToDiagram": "Text to diagram",
"prompt": "Prompt",
"textToDrawing": "Text to drawing"
"eyeDropper": "Pick color from canvas"
},
"library": {
"noItems": "No items added yet...",
+1 -1
View File
@@ -1,7 +1,7 @@
[
{
"path": "dist/excalidraw.production.min.js",
"limit": "335 kB"
"limit": "325 kB"
},
{
"path": "dist/excalidraw-assets/locales",
+6 -21
View File
@@ -13,29 +13,14 @@ 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
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.1 (2023-11-28)
### Fixes
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
```
define: {
"process.env.IS_PREACT": JSON.stringify("true"),
}
```
## Excalidraw Library
### Fixes
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
- 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)
+1 -17
View File
@@ -76,8 +76,6 @@ const {
MainMenu,
LiveCollaborationTrigger,
convertToExcalidrawElements,
TTDDialog,
TTDDialogTrigger,
} = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32;
@@ -683,7 +681,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements, state) => {
// console.info("Elements :", elements, "State : ", state);
console.info("Elements :", elements, "State : ", state);
}}
onPointerUpdate={(payload: {
pointer: { x: number; y: number };
@@ -739,20 +737,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
Toggle Custom Sidebar
</Sidebar.Trigger>
{renderMenu()}
{excalidrawAPI && (
<TTDDialogTrigger icon={<span>😀</span>}>
Text to diagram
</TTDDialogTrigger>
)}
<TTDDialog
onTextSubmit={async (_) => {
console.info("submit");
// sleep for 2s
await new Promise((resolve) => setTimeout(resolve, 2000));
throw new Error("error, go away now");
// return "dummy";
}}
/>
</Excalidraw>
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()}

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