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