Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d872adf593 | ||
|
|
260706c42f | ||
|
|
5e98047267 | ||
|
|
741380bd43 | ||
|
|
5ed82cb646 | ||
|
|
dddb07cf57 | ||
|
|
d6a6c40051 | ||
|
|
bf53d90c68 | ||
|
|
b734f7cba8 | ||
|
|
4f218856c3 | ||
|
|
7dfba985f9 | ||
|
|
5bc23d6dee | ||
|
|
093e684d9e | ||
|
|
84c1de7a03 | ||
|
|
d1a9c593cc | ||
|
|
a7154227cf | ||
|
|
1e132e33ae | ||
|
|
00ffa08e28 | ||
|
|
5c1787bdf4 | ||
|
|
de32256466 | ||
|
|
02dc00a47e |
@@ -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!
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
```
|
||||
:::
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 = <
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"path": "dist/excalidraw.production.min.js",
|
||||
"limit": "335 kB"
|
||||
"limit": "325 kB"
|
||||
},
|
||||
{
|
||||
"path": "dist/excalidraw-assets/locales",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user