Compare commits
No commits in common. "f1a1c4b28eae527d3561203aa5bb25b3647f7ee1" and "508f16dc044f9f747ad48f8fad2280753862c85b" have entirely different histories.
f1a1c4b28e
...
508f16dc04
@ -1,8 +1,6 @@
|
|||||||
*
|
*
|
||||||
!.env.development
|
!.env.development
|
||||||
!.env.development.local
|
|
||||||
!.env.production
|
!.env.production
|
||||||
!.env.production.local
|
|
||||||
!.eslintrc.json
|
!.eslintrc.json
|
||||||
!.npmrc
|
!.npmrc
|
||||||
!.prettierrc
|
!.prettierrc
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import {
|
|||||||
} from "../packages/excalidraw/utils";
|
} from "../packages/excalidraw/utils";
|
||||||
import {
|
import {
|
||||||
FIREBASE_STORAGE_PREFIXES,
|
FIREBASE_STORAGE_PREFIXES,
|
||||||
|
isExcalidrawPlusSignedUser,
|
||||||
STORAGE_KEYS,
|
STORAGE_KEYS,
|
||||||
SYNC_BROWSER_TABS_TIMEOUT,
|
SYNC_BROWSER_TABS_TIMEOUT,
|
||||||
} from "./app_constants";
|
} from "./app_constants";
|
||||||
@ -55,6 +56,7 @@ import Collab, {
|
|||||||
isOfflineAtom,
|
isOfflineAtom,
|
||||||
} from "./collab/Collab";
|
} from "./collab/Collab";
|
||||||
import {
|
import {
|
||||||
|
exportToBackend,
|
||||||
getCollaborationLinkData,
|
getCollaborationLinkData,
|
||||||
isCollaborationLink,
|
isCollaborationLink,
|
||||||
loadScene,
|
loadScene,
|
||||||
@ -66,10 +68,14 @@ import {
|
|||||||
import CustomStats from "./CustomStats";
|
import CustomStats from "./CustomStats";
|
||||||
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
|
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
|
||||||
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
|
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
|
||||||
|
import {
|
||||||
|
ExportToExcalidrawPlus,
|
||||||
|
exportToExcalidrawPlus,
|
||||||
|
} from "./components/ExportToExcalidrawPlus";
|
||||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||||
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
|
||||||
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
|
||||||
import { loadFilesFromDatabase } from "./data/database";
|
import { loadFilesFromFirebase } from "./data/firebase";
|
||||||
import {
|
import {
|
||||||
LibraryIndexedDBAdapter,
|
LibraryIndexedDBAdapter,
|
||||||
LibraryLocalStorageMigrationAdapter,
|
LibraryLocalStorageMigrationAdapter,
|
||||||
@ -105,7 +111,9 @@ import {
|
|||||||
GithubIcon,
|
GithubIcon,
|
||||||
XBrandIcon,
|
XBrandIcon,
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
|
ExcalLogo,
|
||||||
usersIcon,
|
usersIcon,
|
||||||
|
exportToPlus,
|
||||||
share,
|
share,
|
||||||
youtubeIcon,
|
youtubeIcon,
|
||||||
} from "../packages/excalidraw/components/icons";
|
} from "../packages/excalidraw/components/icons";
|
||||||
@ -393,7 +401,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (collabAPI?.isCollaborating()) {
|
if (collabAPI?.isCollaborating()) {
|
||||||
if (data.scene.elements) {
|
if (data.scene.elements) {
|
||||||
collabAPI
|
collabAPI
|
||||||
.fetchImageFiles({
|
.fetchImageFilesFromFirebase({
|
||||||
elements: data.scene.elements,
|
elements: data.scene.elements,
|
||||||
forceFetchFiles: true,
|
forceFetchFiles: true,
|
||||||
})
|
})
|
||||||
@ -416,7 +424,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
}, [] as FileId[]) || [];
|
}, [] as FileId[]) || [];
|
||||||
|
|
||||||
if (data.isExternalScene) {
|
if (data.isExternalScene) {
|
||||||
loadFilesFromDatabase(
|
loadFilesFromFirebase(
|
||||||
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
|
||||||
data.key,
|
data.key,
|
||||||
fileIds,
|
fileIds,
|
||||||
@ -641,12 +649,7 @@ const ExcalidrawWrapper = () => {
|
|||||||
|
|
||||||
// Render the debug scene if the debug canvas is available
|
// Render the debug scene if the debug canvas is available
|
||||||
if (debugCanvasRef.current && excalidrawAPI) {
|
if (debugCanvasRef.current && excalidrawAPI) {
|
||||||
debugRenderer(
|
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
|
||||||
debugCanvasRef.current,
|
|
||||||
appState,
|
|
||||||
window.devicePixelRatio,
|
|
||||||
() => forceRefresh((prev) => !prev),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -662,8 +665,36 @@ const ExcalidrawWrapper = () => {
|
|||||||
if (exportedElements.length === 0) {
|
if (exportedElements.length === 0) {
|
||||||
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
||||||
}
|
}
|
||||||
// Not supported as of now
|
try {
|
||||||
throw new Error(t("alerts.couldNotCreateShareableLink"));
|
const { url, errorMessage } = await exportToBackend(
|
||||||
|
exportedElements,
|
||||||
|
{
|
||||||
|
...appState,
|
||||||
|
viewBackgroundColor: appState.exportBackground
|
||||||
|
? appState.viewBackgroundColor
|
||||||
|
: getDefaultAppState().viewBackgroundColor,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (errorMessage) {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
setLatestShareableLink(url);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
const { width, height } = appState;
|
||||||
|
console.error(error, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
devicePixelRatio: window.devicePixelRatio,
|
||||||
|
});
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCustomStats = (
|
const renderCustomStats = (
|
||||||
@ -705,6 +736,45 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ExcalidrawPlusCommand = {
|
||||||
|
label: "Excalidraw+",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: ["plus", "cloud", "server"],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ExcalidrawPlusAppCommand = {
|
||||||
|
label: "Sign up",
|
||||||
|
category: DEFAULT_CATEGORIES.links,
|
||||||
|
predicate: true,
|
||||||
|
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
|
||||||
|
keywords: [
|
||||||
|
"excalidraw",
|
||||||
|
"plus",
|
||||||
|
"cloud",
|
||||||
|
"server",
|
||||||
|
"signin",
|
||||||
|
"login",
|
||||||
|
"signup",
|
||||||
|
],
|
||||||
|
perform: () => {
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ height: "100%" }}
|
style={{ height: "100%" }}
|
||||||
@ -723,6 +793,30 @@ const ExcalidrawWrapper = () => {
|
|||||||
toggleTheme: true,
|
toggleTheme: true,
|
||||||
export: {
|
export: {
|
||||||
onExportToBackend,
|
onExportToBackend,
|
||||||
|
renderCustomUI: excalidrawAPI
|
||||||
|
? (elements, appState, files) => {
|
||||||
|
return (
|
||||||
|
<ExportToExcalidrawPlus
|
||||||
|
elements={elements}
|
||||||
|
appState={appState}
|
||||||
|
files={files}
|
||||||
|
name={excalidrawAPI.getName()}
|
||||||
|
onError={(error) => {
|
||||||
|
excalidrawAPI?.updateScene({
|
||||||
|
appState: {
|
||||||
|
errorMessage: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
excalidrawAPI.updateScene({
|
||||||
|
appState: { openDialog: null },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@ -764,6 +858,22 @@ const ExcalidrawWrapper = () => {
|
|||||||
<OverwriteConfirmDialog>
|
<OverwriteConfirmDialog>
|
||||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||||
|
{excalidrawAPI && (
|
||||||
|
<OverwriteConfirmDialog.Action
|
||||||
|
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
||||||
|
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
||||||
|
onClick={() => {
|
||||||
|
exportToExcalidrawPlus(
|
||||||
|
excalidrawAPI.getSceneElements(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
||||||
|
</OverwriteConfirmDialog.Action>
|
||||||
|
)}
|
||||||
</OverwriteConfirmDialog>
|
</OverwriteConfirmDialog>
|
||||||
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
<AppFooter onChange={() => excalidrawAPI?.refresh()} />
|
||||||
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
{excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
|
||||||
@ -946,6 +1056,32 @@ const ExcalidrawWrapper = () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(isExcalidrawPlusSignedUser
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...ExcalidrawPlusAppCommand,
|
||||||
|
label: "Sign in / Go to Excalidraw+",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
|
||||||
|
|
||||||
|
{
|
||||||
|
label: t("overwriteConfirm.action.excalidrawPlus.button"),
|
||||||
|
category: DEFAULT_CATEGORIES.export,
|
||||||
|
icon: exportToPlus,
|
||||||
|
predicate: true,
|
||||||
|
keywords: ["plus", "export", "save", "backup"],
|
||||||
|
perform: () => {
|
||||||
|
if (excalidrawAPI) {
|
||||||
|
exportToExcalidrawPlus(
|
||||||
|
excalidrawAPI.getSceneElements(),
|
||||||
|
excalidrawAPI.getAppState(),
|
||||||
|
excalidrawAPI.getFiles(),
|
||||||
|
excalidrawAPI.getName(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...CommandPalette.defaultItems.toggleTheme,
|
...CommandPalette.defaultItems.toggleTheme,
|
||||||
perform: () => {
|
perform: () => {
|
||||||
|
|||||||
@ -53,3 +53,7 @@ export const STORAGE_KEYS = {
|
|||||||
export const COOKIES = {
|
export const COOKIES = {
|
||||||
AUTH_STATE_COOKIE: "excplus-auth",
|
AUTH_STATE_COOKIE: "excplus-auth",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||||
|
COOKIES.AUTH_STATE_COOKIE,
|
||||||
|
);
|
||||||
|
|||||||
@ -46,12 +46,12 @@ import {
|
|||||||
getSyncableElements,
|
getSyncableElements,
|
||||||
} from "../data";
|
} from "../data";
|
||||||
import {
|
import {
|
||||||
isSavedToDatabase,
|
isSavedToFirebase,
|
||||||
loadFilesFromDatabase,
|
loadFilesFromFirebase,
|
||||||
loadFromDatabase,
|
loadFromFirebase,
|
||||||
saveFilesToDatabase,
|
saveFilesToFirebase,
|
||||||
saveToDatabase,
|
saveToFirebase,
|
||||||
} from "../data/database";
|
} from "../data/firebase";
|
||||||
import {
|
import {
|
||||||
importUsernameFromLocalStorage,
|
importUsernameFromLocalStorage,
|
||||||
saveUsernameToLocalStorage,
|
saveUsernameToLocalStorage,
|
||||||
@ -111,7 +111,7 @@ export interface CollabAPI {
|
|||||||
startCollaboration: CollabInstance["startCollaboration"];
|
startCollaboration: CollabInstance["startCollaboration"];
|
||||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
fetchImageFiles: CollabInstance["fetchImageFiles"];
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||||
setUsername: CollabInstance["setUsername"];
|
setUsername: CollabInstance["setUsername"];
|
||||||
getUsername: CollabInstance["getUsername"];
|
getUsername: CollabInstance["getUsername"];
|
||||||
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
|
||||||
@ -149,7 +149,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return loadFilesFromDatabase(`files/rooms/${roomId}`, roomKey, fileIds);
|
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
|
||||||
},
|
},
|
||||||
saveFiles: async ({ addedFiles }) => {
|
saveFiles: async ({ addedFiles }) => {
|
||||||
const { roomId, roomKey } = this.portal;
|
const { roomId, roomKey } = this.portal;
|
||||||
@ -157,7 +157,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
|
|
||||||
return saveFilesToDatabase({
|
return saveFilesToFirebase({
|
||||||
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
||||||
files: await encodeFilesForUpload({
|
files: await encodeFilesForUpload({
|
||||||
files: addedFiles,
|
files: addedFiles,
|
||||||
@ -201,7 +201,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
onPointerUpdate: this.onPointerUpdate,
|
onPointerUpdate: this.onPointerUpdate,
|
||||||
startCollaboration: this.startCollaboration,
|
startCollaboration: this.startCollaboration,
|
||||||
syncElements: this.syncElements,
|
syncElements: this.syncElements,
|
||||||
fetchImageFiles: this.fetchImageFiles,
|
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||||
stopCollaboration: this.stopCollaboration,
|
stopCollaboration: this.stopCollaboration,
|
||||||
setUsername: this.setUsername,
|
setUsername: this.setUsername,
|
||||||
getUsername: this.getUsername,
|
getUsername: this.getUsername,
|
||||||
@ -265,21 +265,21 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
if (
|
if (
|
||||||
this.isCollaborating() &&
|
this.isCollaborating() &&
|
||||||
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
||||||
!isSavedToDatabase(this.portal, syncableElements))
|
!isSavedToFirebase(this.portal, syncableElements))
|
||||||
) {
|
) {
|
||||||
// this won't run in time if user decides to leave the site, but
|
// this won't run in time if user decides to leave the site, but
|
||||||
// the purpose is to run in immediately after user decides to stay
|
// the purpose is to run in immediately after user decides to stay
|
||||||
this.saveCollabRoom(syncableElements);
|
this.saveCollabRoomToFirebase(syncableElements);
|
||||||
|
|
||||||
preventUnload(event);
|
preventUnload(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
saveCollabRoom = async (
|
saveCollabRoomToFirebase = async (
|
||||||
syncableElements: readonly SyncableExcalidrawElement[],
|
syncableElements: readonly SyncableExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const storedElements = await saveToDatabase(
|
const storedElements = await saveToFirebase(
|
||||||
this.portal,
|
this.portal,
|
||||||
syncableElements,
|
syncableElements,
|
||||||
this.excalidrawAPI.getAppState(),
|
this.excalidrawAPI.getAppState(),
|
||||||
@ -318,11 +318,11 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
stopCollaboration = (keepRemoteState = true) => {
|
stopCollaboration = (keepRemoteState = true) => {
|
||||||
this.queueBroadcastAllElements.cancel();
|
this.queueBroadcastAllElements.cancel();
|
||||||
this.queueSaveToDatabase.cancel();
|
this.queueSaveToFirebase.cancel();
|
||||||
this.loadImageFiles.cancel();
|
this.loadImageFiles.cancel();
|
||||||
this.resetErrorIndicator(true);
|
this.resetErrorIndicator(true);
|
||||||
|
|
||||||
this.saveCollabRoom(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
),
|
),
|
||||||
@ -379,7 +379,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private fetchImageFiles = async (opts: {
|
private fetchImageFilesFromFirebase = async (opts: {
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
/**
|
/**
|
||||||
* Indicates whether to fetch files that are errored or pending and older
|
* Indicates whether to fetch files that are errored or pending and older
|
||||||
@ -513,7 +513,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
storeAction: StoreAction.UPDATE,
|
storeAction: StoreAction.UPDATE,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveCollabRoom(getSyncableElements(elements));
|
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback in case you're not alone in the room but still don't receive
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
@ -547,7 +547,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
const reconciledElements =
|
const reconciledElements =
|
||||||
this._reconcileElements(remoteElements);
|
this._reconcileElements(remoteElements);
|
||||||
this.handleRemoteSceneUpdate(reconciledElements);
|
this.handleRemoteSceneUpdate(reconciledElements);
|
||||||
// noop if already resolved via init from database
|
// noop if already resolved via init from firebase
|
||||||
scenePromise.resolve({
|
scenePromise.resolve({
|
||||||
elements: reconciledElements,
|
elements: reconciledElements,
|
||||||
scrollToContent: true,
|
scrollToContent: true,
|
||||||
@ -678,7 +678,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.excalidrawAPI.resetScene();
|
this.excalidrawAPI.resetScene();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const elements = await loadFromDatabase(
|
const elements = await loadFromFirebase(
|
||||||
roomLinkData.roomId,
|
roomLinkData.roomId,
|
||||||
roomLinkData.roomKey,
|
roomLinkData.roomKey,
|
||||||
this.portal.socket,
|
this.portal.socket,
|
||||||
@ -730,7 +730,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
private loadImageFiles = throttle(async () => {
|
private loadImageFiles = throttle(async () => {
|
||||||
const { loadedFiles, erroredFiles } =
|
const { loadedFiles, erroredFiles } =
|
||||||
await this.fetchImageFiles({
|
await this.fetchImageFilesFromFirebase({
|
||||||
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -896,7 +896,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
|
|
||||||
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
|
||||||
this.broadcastElements(elements);
|
this.broadcastElements(elements);
|
||||||
this.queueSaveToDatabase();
|
this.queueSaveToFirebase();
|
||||||
};
|
};
|
||||||
|
|
||||||
queueBroadcastAllElements = throttle(() => {
|
queueBroadcastAllElements = throttle(() => {
|
||||||
@ -913,10 +913,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
|||||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||||
|
|
||||||
queueSaveToDatabase = throttle(
|
queueSaveToFirebase = throttle(
|
||||||
() => {
|
() => {
|
||||||
if (this.portal.socketInitialized) {
|
if (this.portal.socketInitialized) {
|
||||||
this.saveCollabRoom(
|
this.saveCollabRoomToFirebase(
|
||||||
getSyncableElements(
|
getSyncableElements(
|
||||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Footer } from "../../packages/excalidraw/index";
|
import { Footer } from "../../packages/excalidraw/index";
|
||||||
import { EncryptedIcon } from "./EncryptedIcon";
|
import { EncryptedIcon } from "./EncryptedIcon";
|
||||||
|
import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
|
||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
|
||||||
|
|
||||||
export const AppFooter = React.memo(
|
export const AppFooter = React.memo(
|
||||||
@ -15,7 +17,11 @@ export const AppFooter = React.memo(
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
{isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
|
||||||
|
{isExcalidrawPlusSignedUser ? (
|
||||||
|
<ExcalidrawPlusAppLink />
|
||||||
|
) : (
|
||||||
<EncryptedIcon />
|
<EncryptedIcon />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Footer>
|
</Footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { eyeIcon } from "../../packages/excalidraw/components/icons";
|
import {
|
||||||
|
loginIcon,
|
||||||
|
ExcalLogo,
|
||||||
|
eyeIcon,
|
||||||
|
} from "../../packages/excalidraw/components/icons";
|
||||||
import type { Theme } from "../../packages/excalidraw/element/types";
|
import type { Theme } from "../../packages/excalidraw/element/types";
|
||||||
import { MainMenu } from "../../packages/excalidraw/index";
|
import { MainMenu } from "../../packages/excalidraw/index";
|
||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
import { LanguageList } from "../app-language/LanguageList";
|
import { LanguageList } from "../app-language/LanguageList";
|
||||||
import { saveDebugState } from "./DebugCanvas";
|
import { saveDebugState } from "./DebugCanvas";
|
||||||
|
|
||||||
@ -30,7 +35,25 @@ export const AppMainMenu: React.FC<{
|
|||||||
<MainMenu.DefaultItems.Help />
|
<MainMenu.DefaultItems.Help />
|
||||||
<MainMenu.DefaultItems.ClearCanvas />
|
<MainMenu.DefaultItems.ClearCanvas />
|
||||||
<MainMenu.Separator />
|
<MainMenu.Separator />
|
||||||
|
<MainMenu.ItemLink
|
||||||
|
icon={ExcalLogo}
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
|
||||||
|
className=""
|
||||||
|
>
|
||||||
|
Excalidraw+
|
||||||
|
</MainMenu.ItemLink>
|
||||||
<MainMenu.DefaultItems.Socials />
|
<MainMenu.DefaultItems.Socials />
|
||||||
|
<MainMenu.ItemLink
|
||||||
|
icon={loginIcon}
|
||||||
|
href={`${import.meta.env.VITE_APP_PLUS_APP}${
|
||||||
|
isExcalidrawPlusSignedUser ? "" : "/sign-up"
|
||||||
|
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
|
||||||
|
className="highlighted"
|
||||||
|
>
|
||||||
|
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
|
||||||
|
</MainMenu.ItemLink>
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<MainMenu.Item
|
<MainMenu.Item
|
||||||
icon={eyeIcon}
|
icon={eyeIcon}
|
||||||
|
|||||||
@ -1,13 +1,39 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { loginIcon } from "../../packages/excalidraw/components/icons";
|
||||||
import { useI18n } from "../../packages/excalidraw/i18n";
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
import { WelcomeScreen } from "../../packages/excalidraw/index";
|
||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
import { POINTER_EVENTS } from "../../packages/excalidraw/constants";
|
||||||
|
|
||||||
export const AppWelcomeScreen: React.FC<{
|
export const AppWelcomeScreen: React.FC<{
|
||||||
onCollabDialogOpen: () => any;
|
onCollabDialogOpen: () => any;
|
||||||
isCollabEnabled: boolean;
|
isCollabEnabled: boolean;
|
||||||
}> = React.memo((props) => {
|
}> = React.memo((props) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const headingContent = t("welcomeScreen.app.center_heading");
|
let headingContent;
|
||||||
|
|
||||||
|
if (isExcalidrawPlusSignedUser) {
|
||||||
|
headingContent = t("welcomeScreen.app.center_heading_plus")
|
||||||
|
.split(/(Excalidraw\+)/)
|
||||||
|
.map((bit, idx) => {
|
||||||
|
if (bit === "Excalidraw+") {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
style={{ pointerEvents: POINTER_EVENTS.inheritFromUI }}
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
Excalidraw+
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bit;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
headingContent = t("welcomeScreen.app.center_heading");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WelcomeScreen>
|
<WelcomeScreen>
|
||||||
@ -29,6 +55,17 @@ export const AppWelcomeScreen: React.FC<{
|
|||||||
onSelect={() => props.onCollabDialogOpen()}
|
onSelect={() => props.onCollabDialogOpen()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isExcalidrawPlusSignedUser && (
|
||||||
|
<WelcomeScreen.Center.MenuItemLink
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_LP
|
||||||
|
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
|
||||||
|
shortcut={null}
|
||||||
|
icon={loginIcon}
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</WelcomeScreen.Center.MenuItemLink>
|
||||||
|
)}
|
||||||
</WelcomeScreen.Center.Menu>
|
</WelcomeScreen.Center.Menu>
|
||||||
</WelcomeScreen.Center>
|
</WelcomeScreen.Center>
|
||||||
</WelcomeScreen>
|
</WelcomeScreen>
|
||||||
|
|||||||
@ -68,17 +68,12 @@ const _debugRenderer = (
|
|||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
scale: number,
|
scale: number,
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
) => {
|
||||||
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (appState.height !== canvas.height || appState.width !== canvas.width) {
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = bootstrapCanvas({
|
const context = bootstrapCanvas({
|
||||||
canvas,
|
canvas,
|
||||||
scale,
|
scale,
|
||||||
@ -143,13 +138,8 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const debugRenderer = throttleRAF(
|
export const debugRenderer = throttleRAF(
|
||||||
(
|
(canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
|
||||||
canvas: HTMLCanvasElement,
|
_debugRenderer(canvas, appState, scale);
|
||||||
appState: AppState,
|
|
||||||
scale: number,
|
|
||||||
refresh: () => void,
|
|
||||||
) => {
|
|
||||||
_debugRenderer(canvas, appState, scale, refresh);
|
|
||||||
},
|
},
|
||||||
{ trailing: true },
|
{ trailing: true },
|
||||||
);
|
);
|
||||||
|
|||||||
19
excalidraw-app/components/ExcalidrawPlusAppLink.tsx
Normal file
19
excalidraw-app/components/ExcalidrawPlusAppLink.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { isExcalidrawPlusSignedUser } from "../app_constants";
|
||||||
|
|
||||||
|
export const ExcalidrawPlusAppLink = () => {
|
||||||
|
if (!isExcalidrawPlusSignedUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="plus-button"
|
||||||
|
>
|
||||||
|
Go to Excalidraw+
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
134
excalidraw-app/components/ExportToExcalidrawPlus.tsx
Normal file
134
excalidraw-app/components/ExportToExcalidrawPlus.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card } from "../../packages/excalidraw/components/Card";
|
||||||
|
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
|
||||||
|
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||||
|
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
|
||||||
|
import type {
|
||||||
|
FileId,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "../../packages/excalidraw/element/types";
|
||||||
|
import type {
|
||||||
|
AppState,
|
||||||
|
BinaryFileData,
|
||||||
|
BinaryFiles,
|
||||||
|
} from "../../packages/excalidraw/types";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { useI18n } from "../../packages/excalidraw/i18n";
|
||||||
|
import {
|
||||||
|
encryptData,
|
||||||
|
generateEncryptionKey,
|
||||||
|
} from "../../packages/excalidraw/data/encryption";
|
||||||
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||||
|
import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
|
||||||
|
import { encodeFilesForUpload } from "../data/FileManager";
|
||||||
|
import { MIME_TYPES } from "../../packages/excalidraw/constants";
|
||||||
|
import { trackEvent } from "../../packages/excalidraw/analytics";
|
||||||
|
import { getFrame } from "../../packages/excalidraw/utils";
|
||||||
|
import { ExcalidrawLogo } from "../../packages/excalidraw/components/ExcalidrawLogo";
|
||||||
|
|
||||||
|
export const exportToExcalidrawPlus = async (
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
appState: Partial<AppState>,
|
||||||
|
files: BinaryFiles,
|
||||||
|
name: string,
|
||||||
|
) => {
|
||||||
|
const firebase = await loadFirebaseStorage();
|
||||||
|
|
||||||
|
const id = `${nanoid(12)}`;
|
||||||
|
|
||||||
|
const encryptionKey = (await generateEncryptionKey())!;
|
||||||
|
const encryptedData = await encryptData(
|
||||||
|
encryptionKey,
|
||||||
|
serializeAsJSON(elements, appState, files, "database"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob(
|
||||||
|
[encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
|
||||||
|
{
|
||||||
|
type: MIME_TYPES.binary,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await firebase
|
||||||
|
.storage()
|
||||||
|
.ref(`/migrations/scenes/${id}`)
|
||||||
|
.put(blob, {
|
||||||
|
customMetadata: {
|
||||||
|
data: JSON.stringify({ version: 2, name }),
|
||||||
|
created: Date.now().toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<FileId, BinaryFileData>();
|
||||||
|
for (const element of elements) {
|
||||||
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
|
filesMap.set(element.fileId, files[element.fileId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesMap.size) {
|
||||||
|
const filesToUpload = await encodeFilesForUpload({
|
||||||
|
files: filesMap,
|
||||||
|
encryptionKey,
|
||||||
|
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveFilesToFirebase({
|
||||||
|
prefix: `/migrations/files/scenes/${id}`,
|
||||||
|
files: filesToUpload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
`${
|
||||||
|
import.meta.env.VITE_APP_PLUS_APP
|
||||||
|
}/import?excalidraw=${id},${encryptionKey}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExportToExcalidrawPlus: React.FC<{
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[];
|
||||||
|
appState: Partial<AppState>;
|
||||||
|
files: BinaryFiles;
|
||||||
|
name: string;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<Card color="primary">
|
||||||
|
<div className="Card-icon">
|
||||||
|
<ExcalidrawLogo
|
||||||
|
style={{
|
||||||
|
[`--color-logo-icon` as any]: "#fff",
|
||||||
|
width: "2.8rem",
|
||||||
|
height: "2.8rem",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2>Excalidraw+</h2>
|
||||||
|
<div className="Card-details">
|
||||||
|
{t("exportDialog.excalidrawplus_description")}
|
||||||
|
</div>
|
||||||
|
<ToolButton
|
||||||
|
className="Card-button"
|
||||||
|
type="button"
|
||||||
|
title={t("exportDialog.excalidrawplus_button")}
|
||||||
|
aria-label={t("exportDialog.excalidrawplus_button")}
|
||||||
|
showAriaLabel={true}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
trackEvent("export", "eplus", `ui (${getFrame()})`);
|
||||||
|
await exportToExcalidrawPlus(elements, appState, files, name);
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
if (error.name !== "AbortError") {
|
||||||
|
onError(new Error(t("exportDialog.excalidrawplus_exportError")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -96,7 +96,7 @@ const loadFirestore = async () => {
|
|||||||
return firebase;
|
return firebase;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFirebaseStorage = async () => {
|
export const loadFirebaseStorage = async () => {
|
||||||
const firebase = await _getFirebase();
|
const firebase = await _getFirebase();
|
||||||
if (!firebaseStoragePromise) {
|
if (!firebaseStoragePromise) {
|
||||||
firebaseStoragePromise = import(
|
firebaseStoragePromise = import(
|
||||||
@ -154,7 +154,7 @@ class FirebaseSceneVersionCache {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isSavedToDatabase = (
|
export const isSavedToFirebase = (
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@ -168,7 +168,7 @@ export const isSavedToDatabase = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveFilesToDatabase = async ({
|
export const saveFilesToFirebase = async ({
|
||||||
prefix,
|
prefix,
|
||||||
files,
|
files,
|
||||||
}: {
|
}: {
|
||||||
@ -220,7 +220,7 @@ const createFirebaseSceneDocument = async (
|
|||||||
} as FirebaseStoredScene;
|
} as FirebaseStoredScene;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveToDatabase = async (
|
export const saveToFirebase = async (
|
||||||
portal: Portal,
|
portal: Portal,
|
||||||
elements: readonly SyncableExcalidrawElement[],
|
elements: readonly SyncableExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
@ -231,7 +231,7 @@ export const saveToDatabase = async (
|
|||||||
!roomId ||
|
!roomId ||
|
||||||
!roomKey ||
|
!roomKey ||
|
||||||
!socket ||
|
!socket ||
|
||||||
isSavedToDatabase(portal, elements)
|
isSavedToFirebase(portal, elements)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -289,7 +289,7 @@ export const saveToDatabase = async (
|
|||||||
return storedElements;
|
return storedElements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFromDatabase = async (
|
export const loadFromFirebase = async (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
socket: Socket | null,
|
socket: Socket | null,
|
||||||
@ -314,7 +314,7 @@ export const loadFromDatabase = async (
|
|||||||
return elements;
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadFilesFromDatabase = async (
|
export const loadFilesFromFirebase = async (
|
||||||
prefix: string,
|
prefix: string,
|
||||||
decryptionKey: string,
|
decryptionKey: string,
|
||||||
filesIds: readonly FileId[],
|
filesIds: readonly FileId[],
|
||||||
@ -1,15 +1,28 @@
|
|||||||
import { generateEncryptionKey } from "../../packages/excalidraw/data/encryption";
|
import {
|
||||||
|
compressData,
|
||||||
|
decompressData,
|
||||||
|
} from "../../packages/excalidraw/data/encode";
|
||||||
|
import {
|
||||||
|
decryptData,
|
||||||
|
generateEncryptionKey,
|
||||||
|
IV_LENGTH_BYTES,
|
||||||
|
} from "../../packages/excalidraw/data/encryption";
|
||||||
|
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
|
||||||
import { restore } from "../../packages/excalidraw/data/restore";
|
import { restore } from "../../packages/excalidraw/data/restore";
|
||||||
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
|
||||||
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
|
||||||
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
|
||||||
|
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
|
FileId,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
} from "../../packages/excalidraw/element/types";
|
} from "../../packages/excalidraw/element/types";
|
||||||
import { t } from "../../packages/excalidraw/i18n";
|
import { t } from "../../packages/excalidraw/i18n";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
|
BinaryFileData,
|
||||||
|
BinaryFiles,
|
||||||
SocketId,
|
SocketId,
|
||||||
UserIdleState,
|
UserIdleState,
|
||||||
} from "../../packages/excalidraw/types";
|
} from "../../packages/excalidraw/types";
|
||||||
@ -18,8 +31,11 @@ import { bytesToHexString } from "../../packages/excalidraw/utils";
|
|||||||
import type { WS_SUBTYPES } from "../app_constants";
|
import type { WS_SUBTYPES } from "../app_constants";
|
||||||
import {
|
import {
|
||||||
DELETED_ELEMENT_TIMEOUT,
|
DELETED_ELEMENT_TIMEOUT,
|
||||||
|
FILE_UPLOAD_MAX_BYTES,
|
||||||
ROOM_ID_BYTES,
|
ROOM_ID_BYTES,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
|
import { encodeFilesForUpload } from "./FileManager";
|
||||||
|
import { saveFilesToFirebase } from "./firebase";
|
||||||
|
|
||||||
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
|
||||||
MakeBrand<"SyncableExcalidrawElement">;
|
MakeBrand<"SyncableExcalidrawElement">;
|
||||||
@ -43,6 +59,9 @@ export const getSyncableElements = (
|
|||||||
isSyncableElement(element),
|
isSyncableElement(element),
|
||||||
) as SyncableExcalidrawElement[];
|
) as SyncableExcalidrawElement[];
|
||||||
|
|
||||||
|
const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
|
||||||
|
const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
|
||||||
|
|
||||||
const generateRoomId = async () => {
|
const generateRoomId = async () => {
|
||||||
const buffer = new Uint8Array(ROOM_ID_BYTES);
|
const buffer = new Uint8Array(ROOM_ID_BYTES);
|
||||||
window.crypto.getRandomValues(buffer);
|
window.crypto.getRandomValues(buffer);
|
||||||
@ -141,6 +160,84 @@ export const getCollaborationLink = (data: {
|
|||||||
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
|
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes shareLink data using the legacy buffer format.
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const legacy_decodeFromBackend = async ({
|
||||||
|
buffer,
|
||||||
|
decryptionKey,
|
||||||
|
}: {
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
decryptionKey: string;
|
||||||
|
}) => {
|
||||||
|
let decrypted: ArrayBuffer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Buffer should contain both the IV (fixed length) and encrypted data
|
||||||
|
const iv = buffer.slice(0, IV_LENGTH_BYTES);
|
||||||
|
const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
|
||||||
|
decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Fixed IV (old format, backward compatibility)
|
||||||
|
const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
|
||||||
|
decrypted = await decryptData(fixedIv, buffer, decryptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to convert the decrypted array buffer to a string
|
||||||
|
const string = new window.TextDecoder("utf-8").decode(
|
||||||
|
new Uint8Array(decrypted),
|
||||||
|
);
|
||||||
|
const data: ImportedDataState = JSON.parse(string);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: data.elements || null,
|
||||||
|
appState: data.appState || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const importFromBackend = async (
|
||||||
|
id: string,
|
||||||
|
decryptionKey: string,
|
||||||
|
): Promise<ImportedDataState> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BACKEND_V2_GET}${id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: decodedBuffer } = await decompressData(
|
||||||
|
new Uint8Array(buffer),
|
||||||
|
{
|
||||||
|
decryptionKey,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data: ImportedDataState = JSON.parse(
|
||||||
|
new TextDecoder().decode(decodedBuffer),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
elements: data.elements || null,
|
||||||
|
appState: data.appState || null,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(
|
||||||
|
"error when decoding shareLink data using the new format:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return legacy_decodeFromBackend({ buffer, decryptionKey });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
window.alert(t("alerts.importBackendFailed"));
|
||||||
|
console.error(error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const loadScene = async (
|
export const loadScene = async (
|
||||||
id: string | null,
|
id: string | null,
|
||||||
privateKey: string | null,
|
privateKey: string | null,
|
||||||
@ -150,9 +247,20 @@ export const loadScene = async (
|
|||||||
localDataState: ImportedDataState | undefined | null,
|
localDataState: ImportedDataState | undefined | null,
|
||||||
) => {
|
) => {
|
||||||
let data;
|
let data;
|
||||||
|
if (id != null && privateKey != null) {
|
||||||
|
// the private key is used to decrypt the content from the server, take
|
||||||
|
// extra care not to leak it
|
||||||
|
data = restore(
|
||||||
|
await importFromBackend(id, privateKey),
|
||||||
|
localDataState?.appState,
|
||||||
|
localDataState?.elements,
|
||||||
|
{ repairBindings: true, refreshDimensions: false },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
data = restore(localDataState || null, null, null, {
|
data = restore(localDataState || null, null, null, {
|
||||||
repairBindings: true,
|
repairBindings: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: data.elements,
|
elements: data.elements,
|
||||||
@ -163,3 +271,68 @@ export const loadScene = async (
|
|||||||
files: data.files,
|
files: data.files,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExportToBackendResult =
|
||||||
|
| { url: null; errorMessage: string }
|
||||||
|
| { url: string; errorMessage: null };
|
||||||
|
|
||||||
|
export const exportToBackend = async (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: Partial<AppState>,
|
||||||
|
files: BinaryFiles,
|
||||||
|
): Promise<ExportToBackendResult> => {
|
||||||
|
const encryptionKey = await generateEncryptionKey("string");
|
||||||
|
|
||||||
|
const payload = await compressData(
|
||||||
|
new TextEncoder().encode(
|
||||||
|
serializeAsJSON(elements, appState, files, "database"),
|
||||||
|
),
|
||||||
|
{ encryptionKey },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filesMap = new Map<FileId, BinaryFileData>();
|
||||||
|
for (const element of elements) {
|
||||||
|
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||||
|
filesMap.set(element.fileId, files[element.fileId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesToUpload = await encodeFilesForUpload({
|
||||||
|
files: filesMap,
|
||||||
|
encryptionKey,
|
||||||
|
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(BACKEND_V2_POST, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload.buffer,
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.id) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
// We need to store the key (and less importantly the id) as hash instead
|
||||||
|
// of queryParam in order to never send it to the server
|
||||||
|
url.hash = `json=${json.id},${encryptionKey}`;
|
||||||
|
const urlString = url.toString();
|
||||||
|
|
||||||
|
await saveFilesToFirebase({
|
||||||
|
prefix: `/files/shareLinks/${json.id}`,
|
||||||
|
files: filesToUpload,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url: urlString, errorMessage: null };
|
||||||
|
} else if (json.error_class === "RequestTooLargeError") {
|
||||||
|
return {
|
||||||
|
url: null,
|
||||||
|
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@ -227,5 +227,37 @@
|
|||||||
</header>
|
</header>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="index.tsx"></script>
|
<script type="module" src="index.tsx"></script>
|
||||||
|
<!-- 100% privacy friendly analytics -->
|
||||||
|
<script>
|
||||||
|
// need to load this script dynamically bcs. of iframe embed tracking
|
||||||
|
var scriptEle = document.createElement("script");
|
||||||
|
scriptEle.setAttribute(
|
||||||
|
"src",
|
||||||
|
"https://scripts.simpleanalyticscdn.com/latest.js",
|
||||||
|
);
|
||||||
|
scriptEle.setAttribute("type", "text/javascript");
|
||||||
|
scriptEle.setAttribute("defer", true);
|
||||||
|
scriptEle.setAttribute("async", true);
|
||||||
|
// if iframe
|
||||||
|
if (window.self !== window.top) {
|
||||||
|
scriptEle.setAttribute("data-auto-collect", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(scriptEle);
|
||||||
|
|
||||||
|
// if iframe
|
||||||
|
if (window.self !== window.top) {
|
||||||
|
scriptEle.addEventListener("load", () => {
|
||||||
|
if (window.sa_pageview) {
|
||||||
|
window.window.sa_event(action, {
|
||||||
|
category: "iframe",
|
||||||
|
label: "embed",
|
||||||
|
value: window.location.pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<!-- end LEGACY GOOGLE ANALYTICS -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -26,25 +26,25 @@ Object.defineProperty(window, "crypto", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../../excalidraw-app/data/database.ts", () => {
|
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
||||||
const loadFromDatabase = async () => null;
|
const loadFromFirebase = async () => null;
|
||||||
const saveToDatabase = () => {};
|
const saveToFirebase = () => {};
|
||||||
const isSavedToDatabase = () => true;
|
const isSavedToFirebase = () => true;
|
||||||
const loadFilesFromDatabase = async () => ({
|
const loadFilesFromFirebase = async () => ({
|
||||||
loadedFiles: [],
|
loadedFiles: [],
|
||||||
erroredFiles: [],
|
erroredFiles: [],
|
||||||
});
|
});
|
||||||
const saveFilesToDatabase = async () => ({
|
const saveFilesToFirebase = async () => ({
|
||||||
savedFiles: new Map(),
|
savedFiles: new Map(),
|
||||||
erroredFiles: new Map(),
|
erroredFiles: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadFromDatabase,
|
loadFromFirebase,
|
||||||
saveToDatabase,
|
saveToFirebase,
|
||||||
isSavedToDatabase,
|
isSavedToFirebase,
|
||||||
loadFilesFromDatabase,
|
loadFilesFromFirebase,
|
||||||
saveFilesToDatabase,
|
saveFilesToFirebase,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,211 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Excalidraw } from "../index";
|
|
||||||
import { render } from "../tests/test-utils";
|
|
||||||
import { API } from "../tests/helpers/api";
|
|
||||||
import { point } from "../../math";
|
|
||||||
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
|
|
||||||
|
|
||||||
const { h } = window;
|
|
||||||
|
|
||||||
describe("flipping re-centers selection", () => {
|
|
||||||
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
|
|
||||||
const elements = [
|
|
||||||
API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
id: "rec1",
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
boundElements: [{ id: "arr", type: "arrow" }],
|
|
||||||
}),
|
|
||||||
API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
id: "rec2",
|
|
||||||
x: 220,
|
|
||||||
y: 250,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
boundElements: [{ id: "arr", type: "arrow" }],
|
|
||||||
}),
|
|
||||||
API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arr",
|
|
||||||
x: 149.9,
|
|
||||||
y: 95,
|
|
||||||
width: 156,
|
|
||||||
height: 239.9,
|
|
||||||
startBinding: {
|
|
||||||
elementId: "rec1",
|
|
||||||
focus: 0,
|
|
||||||
gap: 5,
|
|
||||||
fixedPoint: [0.49, -0.05],
|
|
||||||
},
|
|
||||||
endBinding: {
|
|
||||||
elementId: "rec2",
|
|
||||||
focus: 0,
|
|
||||||
gap: 5,
|
|
||||||
fixedPoint: [-0.05, 0.49],
|
|
||||||
},
|
|
||||||
startArrowhead: null,
|
|
||||||
endArrowhead: "arrow",
|
|
||||||
points: [
|
|
||||||
point(0, 0),
|
|
||||||
point(0, -35),
|
|
||||||
point(-90.9, -35),
|
|
||||||
point(-90.9, 204.9),
|
|
||||||
point(65.1, 204.9),
|
|
||||||
],
|
|
||||||
elbowed: true,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
await render(<Excalidraw initialData={{ elements }} />);
|
|
||||||
|
|
||||||
API.setSelectedElements(elements);
|
|
||||||
|
|
||||||
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
|
|
||||||
const rec1 = h.elements.find((el) => el.id === "rec1");
|
|
||||||
expect(rec1?.x).toBeCloseTo(100);
|
|
||||||
expect(rec1?.y).toBeCloseTo(100);
|
|
||||||
|
|
||||||
const rec2 = h.elements.find((el) => el.id === "rec2");
|
|
||||||
expect(rec2?.x).toBeCloseTo(220);
|
|
||||||
expect(rec2?.y).toBeCloseTo(250);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("flipping arrowheads", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await render(<Excalidraw />);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping bound arrow should flip arrowheads only", () => {
|
|
||||||
const rect = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: null,
|
|
||||||
endBinding: {
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect, arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipVertical);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe(null);
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping bound arrow should flip arrowheads only 2", () => {
|
|
||||||
const rect = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const rect2 = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: "circle",
|
|
||||||
startBinding: {
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
endBinding: {
|
|
||||||
elementId: rect2.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect, rect2, arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("circle");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipVertical);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping unbound arrow shouldn't flip arrowheads", () => {
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: "circle",
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([arrow]);
|
|
||||||
API.setSelectedElements([arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe("circle");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
|
|
||||||
const rect = API.createElement({
|
|
||||||
type: "rectangle",
|
|
||||||
boundElements: [{ type: "arrow", id: "arrow1" }],
|
|
||||||
});
|
|
||||||
const arrow = API.createElement({
|
|
||||||
type: "arrow",
|
|
||||||
id: "arrow1",
|
|
||||||
startArrowhead: "arrow",
|
|
||||||
endArrowhead: null,
|
|
||||||
endBinding: {
|
|
||||||
elementId: rect.id,
|
|
||||||
focus: 0.5,
|
|
||||||
gap: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
API.setElements([rect, arrow]);
|
|
||||||
API.setSelectedElements([rect, arrow]);
|
|
||||||
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
|
|
||||||
API.executeAction(actionFlipHorizontal);
|
|
||||||
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
|
|
||||||
expect(API.getElement(arrow).endArrowhead).toBe(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,8 +2,6 @@ import { register } from "./register";
|
|||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawArrowElement,
|
|
||||||
ExcalidrawElbowArrowElement,
|
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
@ -20,13 +18,7 @@ import {
|
|||||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { flipHorizontal, flipVertical } from "../components/icons";
|
import { flipHorizontal, flipVertical } from "../components/icons";
|
||||||
import { StoreAction } from "../store";
|
import { StoreAction } from "../store";
|
||||||
import {
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
isArrowElement,
|
|
||||||
isElbowArrow,
|
|
||||||
isLinearElement,
|
|
||||||
} from "../element/typeChecks";
|
|
||||||
import { mutateElbowArrow } from "../element/routing";
|
|
||||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
@ -117,23 +109,7 @@ const flipElements = (
|
|||||||
flipDirection: "horizontal" | "vertical",
|
flipDirection: "horizontal" | "vertical",
|
||||||
app: AppClassProperties,
|
app: AppClassProperties,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
if (
|
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
|
||||||
selectedElements.every(
|
|
||||||
(element) =>
|
|
||||||
isArrowElement(element) && (element.startBinding || element.endBinding),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return selectedElements.map((element) => {
|
|
||||||
const _element = element as ExcalidrawArrowElement;
|
|
||||||
return newElementWith(_element, {
|
|
||||||
startArrowhead: _element.endArrowhead,
|
|
||||||
endArrowhead: _element.startArrowhead,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { minX, minY, maxX, maxY, midX, midY } =
|
|
||||||
getCommonBoundingBox(selectedElements);
|
|
||||||
|
|
||||||
resizeMultipleElements(
|
resizeMultipleElements(
|
||||||
elementsMap,
|
elementsMap,
|
||||||
@ -155,48 +131,5 @@ const flipElements = (
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// flipping arrow elements (and potentially other) makes the selection group
|
|
||||||
// "move" across the canvas because of how arrows can bump against the "wall"
|
|
||||||
// of the selection, so we need to center the group back to the original
|
|
||||||
// position so that repeated flips don't accumulate the offset
|
|
||||||
|
|
||||||
const { elbowArrows, otherElements } = selectedElements.reduce(
|
|
||||||
(
|
|
||||||
acc: {
|
|
||||||
elbowArrows: ExcalidrawElbowArrowElement[];
|
|
||||||
otherElements: ExcalidrawElement[];
|
|
||||||
},
|
|
||||||
element,
|
|
||||||
) =>
|
|
||||||
isElbowArrow(element)
|
|
||||||
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
|
|
||||||
: { ...acc, otherElements: acc.otherElements.concat(element) },
|
|
||||||
{ elbowArrows: [], otherElements: [] },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { midX: newMidX, midY: newMidY } =
|
|
||||||
getCommonBoundingBox(selectedElements);
|
|
||||||
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
|
|
||||||
otherElements.forEach((element) =>
|
|
||||||
mutateElement(element, {
|
|
||||||
x: element.x + diffX,
|
|
||||||
y: element.y + diffY,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
elbowArrows.forEach((element) =>
|
|
||||||
mutateElbowArrow(
|
|
||||||
element,
|
|
||||||
elementsMap,
|
|
||||||
element.points,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
informMutation: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
return selectedElements;
|
return selectedElements;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1685,6 +1685,19 @@ export const actionChangeArrowType = register({
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
mutateElement(
|
||||||
|
newElement,
|
||||||
|
{
|
||||||
|
startBinding: newElement.startBinding
|
||||||
|
? { ...newElement.startBinding, fixedPoint: null }
|
||||||
|
: null,
|
||||||
|
endBinding: newElement.endBinding
|
||||||
|
? { ...newElement.endBinding, fixedPoint: null }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newElement;
|
return newElement;
|
||||||
|
|||||||
@ -185,7 +185,6 @@ import type {
|
|||||||
MagicGenerationData,
|
MagicGenerationData,
|
||||||
ExcalidrawNonSelectionElement,
|
ExcalidrawNonSelectionElement,
|
||||||
ExcalidrawArrowElement,
|
ExcalidrawArrowElement,
|
||||||
NonDeletedSceneElementsMap,
|
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getCenter, getDistance } from "../gesture";
|
import { getCenter, getDistance } from "../gesture";
|
||||||
import {
|
import {
|
||||||
@ -288,7 +287,6 @@ import {
|
|||||||
getDateTime,
|
getDateTime,
|
||||||
isShallowEqual,
|
isShallowEqual,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
toBrandedType,
|
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
createSrcDoc,
|
createSrcDoc,
|
||||||
@ -437,7 +435,7 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
|||||||
import { getVisibleSceneBounds } from "../element/bounds";
|
import { getVisibleSceneBounds } from "../element/bounds";
|
||||||
import { isMaybeMermaidDefinition } from "../mermaid";
|
import { isMaybeMermaidDefinition } from "../mermaid";
|
||||||
import NewElementCanvas from "./canvases/NewElementCanvas";
|
import NewElementCanvas from "./canvases/NewElementCanvas";
|
||||||
import { mutateElbowArrow, updateElbowArrow } from "../element/routing";
|
import { mutateElbowArrow } from "../element/routing";
|
||||||
import {
|
import {
|
||||||
FlowChartCreator,
|
FlowChartCreator,
|
||||||
FlowChartNavigator,
|
FlowChartNavigator,
|
||||||
@ -3111,45 +3109,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
retainSeed?: boolean;
|
retainSeed?: boolean;
|
||||||
fitToContent?: boolean;
|
fitToContent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
let elements = opts.elements.map((el, _, elements) => {
|
const elements = restoreElements(opts.elements, null, undefined);
|
||||||
if (isElbowArrow(el)) {
|
|
||||||
const startEndElements = [
|
|
||||||
el.startBinding &&
|
|
||||||
elements.find((l) => l.id === el.startBinding?.elementId),
|
|
||||||
el.endBinding &&
|
|
||||||
elements.find((l) => l.id === el.endBinding?.elementId),
|
|
||||||
];
|
|
||||||
const startBinding = startEndElements[0] ? el.startBinding : null;
|
|
||||||
const endBinding = startEndElements[1] ? el.endBinding : null;
|
|
||||||
return {
|
|
||||||
...el,
|
|
||||||
...updateElbowArrow(
|
|
||||||
{
|
|
||||||
...el,
|
|
||||||
startBinding,
|
|
||||||
endBinding,
|
|
||||||
},
|
|
||||||
toBrandedType<NonDeletedSceneElementsMap>(
|
|
||||||
new Map(
|
|
||||||
startEndElements
|
|
||||||
.filter((x) => x != null)
|
|
||||||
.map(
|
|
||||||
(el) =>
|
|
||||||
[el!.id, el] as [
|
|
||||||
string,
|
|
||||||
Ordered<NonDeletedExcalidrawElement>,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[el.points[0], el.points[el.points.length - 1]],
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return el;
|
|
||||||
});
|
|
||||||
elements = restoreElements(elements, null, undefined);
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
|
|
||||||
const elementsCenterX = distance(minX, maxX) / 2;
|
const elementsCenterX = distance(minX, maxX) / 2;
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import type {
|
|||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawSelectionElement,
|
ExcalidrawSelectionElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FixedPointBinding,
|
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
OrderedExcalidrawElement,
|
OrderedExcalidrawElement,
|
||||||
PointBinding,
|
PointBinding,
|
||||||
@ -22,7 +21,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
isUsingAdaptiveRadius,
|
isUsingAdaptiveRadius,
|
||||||
@ -103,8 +101,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||||||
|
|
||||||
const repairBinding = (
|
const repairBinding = (
|
||||||
element: ExcalidrawLinearElement,
|
element: ExcalidrawLinearElement,
|
||||||
binding: PointBinding | FixedPointBinding | null,
|
binding: PointBinding | null,
|
||||||
): PointBinding | FixedPointBinding | null => {
|
): PointBinding | null => {
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -112,11 +110,9 @@ const repairBinding = (
|
|||||||
return {
|
return {
|
||||||
...binding,
|
...binding,
|
||||||
focus: binding.focus || 0,
|
focus: binding.focus || 0,
|
||||||
...(isElbowArrow(element) && isFixedPointBinding(binding)
|
fixedPoint: isElbowArrow(element)
|
||||||
? {
|
? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
|
||||||
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]),
|
: null,
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,6 @@ import {
|
|||||||
isBindingElement,
|
isBindingElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFixedPointBinding,
|
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isRectangularElement,
|
isRectangularElement,
|
||||||
@ -798,7 +797,7 @@ export const bindPointToSnapToElementOutline = (
|
|||||||
isVertical
|
isVertical
|
||||||
? Math.abs(p[1] - i[1]) < 0.1
|
? Math.abs(p[1] - i[1]) < 0.1
|
||||||
: Math.abs(p[0] - i[0]) < 0.1,
|
: Math.abs(p[0] - i[0]) < 0.1,
|
||||||
)[0] ?? p;
|
)[0] ?? point;
|
||||||
}
|
}
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
@ -1014,7 +1013,7 @@ const updateBoundPoint = (
|
|||||||
const direction = startOrEnd === "startBinding" ? -1 : 1;
|
const direction = startOrEnd === "startBinding" ? -1 : 1;
|
||||||
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
|
||||||
|
|
||||||
if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) {
|
if (isElbowArrow(linearElement)) {
|
||||||
const fixedPoint =
|
const fixedPoint =
|
||||||
normalizeFixedPoint(binding.fixedPoint) ??
|
normalizeFixedPoint(binding.fixedPoint) ??
|
||||||
calculateFixedPointForElbowArrowBinding(
|
calculateFixedPointForElbowArrowBinding(
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export const dragSelectedElements = (
|
|||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
_selectedElements.length === 1 &&
|
_selectedElements.length === 1 &&
|
||||||
|
isArrowElement(_selectedElements[0]) &&
|
||||||
isElbowArrow(_selectedElements[0]) &&
|
isElbowArrow(_selectedElements[0]) &&
|
||||||
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
(_selectedElements[0].startBinding || _selectedElements[0].endBinding)
|
||||||
) {
|
) {
|
||||||
@ -42,7 +43,13 @@ export const dragSelectedElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = _selectedElements.filter(
|
const selectedElements = _selectedElements.filter(
|
||||||
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding),
|
(el) =>
|
||||||
|
!(
|
||||||
|
isArrowElement(el) &&
|
||||||
|
isElbowArrow(el) &&
|
||||||
|
el.startBinding &&
|
||||||
|
el.endBinding
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// we do not want a frame and its elements to be selected at the same time
|
// we do not want a frame and its elements to be selected at the same time
|
||||||
|
|||||||
@ -102,7 +102,6 @@ export class LinearElementEditor {
|
|||||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||||
public readonly hoverPointIndex: number;
|
public readonly hoverPointIndex: number;
|
||||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||||
public readonly elbowed: boolean;
|
|
||||||
|
|
||||||
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
constructor(element: NonDeleted<ExcalidrawLinearElement>) {
|
||||||
this.elementId = element.id as string & {
|
this.elementId = element.id as string & {
|
||||||
@ -132,7 +131,6 @@ export class LinearElementEditor {
|
|||||||
};
|
};
|
||||||
this.hoverPointIndex = -1;
|
this.hoverPointIndex = -1;
|
||||||
this.segmentMidPointHoveredCoords = null;
|
this.segmentMidPointHoveredCoords = null;
|
||||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1479,9 +1477,7 @@ export class LinearElementEditor {
|
|||||||
nextPoints,
|
nextPoints,
|
||||||
vector(offsetX, offsetY),
|
vector(offsetX, offsetY),
|
||||||
bindings,
|
bindings,
|
||||||
{
|
options,
|
||||||
isDragging: options?.isDragging,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import type {
|
|||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ElementsMap,
|
ElementsMap,
|
||||||
ExcalidrawArrowElement,
|
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@ -910,8 +909,6 @@ export const resizeMultipleElements = (
|
|||||||
fontSize?: ExcalidrawTextElement["fontSize"];
|
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||||
scale?: ExcalidrawImageElement["scale"];
|
scale?: ExcalidrawImageElement["scale"];
|
||||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||||
startBinding?: ExcalidrawArrowElement["startBinding"];
|
|
||||||
endBinding?: ExcalidrawArrowElement["endBinding"];
|
|
||||||
};
|
};
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
@ -996,6 +993,19 @@ export const resizeMultipleElements = (
|
|||||||
|
|
||||||
mutateElement(element, update, false);
|
mutateElement(element, update, false);
|
||||||
|
|
||||||
|
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||||
|
mutateElbowArrow(
|
||||||
|
element,
|
||||||
|
elementsMap,
|
||||||
|
element.points,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
informMutation: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
updateBoundElements(element, elementsMap, {
|
updateBoundElements(element, elementsMap, {
|
||||||
simultaneouslyUpdated: elementsToUpdate,
|
simultaneouslyUpdated: elementsToUpdate,
|
||||||
oldSize: { width: oldWidth, height: oldHeight },
|
oldSize: { width: oldWidth, height: oldHeight },
|
||||||
@ -1049,7 +1059,7 @@ const rotateMultipleElements = (
|
|||||||
(centerAngle + origAngle - element.angle) as Radians,
|
(centerAngle + origAngle - element.angle) as Radians,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isElbowArrow(element)) {
|
if (isArrowElement(element) && isElbowArrow(element)) {
|
||||||
const points = getArrowLocalFixedPoints(element, elementsMap);
|
const points = getArrowLocalFixedPoints(element, elementsMap);
|
||||||
mutateElbowArrow(element, elementsMap, points);
|
mutateElbowArrow(element, elementsMap, points);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -94,16 +94,7 @@ describe("elbow arrow routing", () => {
|
|||||||
|
|
||||||
describe("elbow arrow ui", () => {
|
describe("elbow arrow ui", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
|
||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
|
||||||
button: 2,
|
|
||||||
clientX: 1,
|
|
||||||
clientY: 1,
|
|
||||||
});
|
|
||||||
const contextMenu = UI.queryContextMenu();
|
|
||||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can follow bound shapes", async () => {
|
it("can follow bound shapes", async () => {
|
||||||
@ -139,8 +130,8 @@ describe("elbow arrow ui", () => {
|
|||||||
expect(arrow.elbowed).toBe(true);
|
expect(arrow.elbowed).toBe(true);
|
||||||
expect(arrow.points).toEqual([
|
expect(arrow.points).toEqual([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[45, 0],
|
[35, 0],
|
||||||
[45, 200],
|
[35, 200],
|
||||||
[90, 200],
|
[90, 200],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -172,6 +163,14 @@ describe("elbow arrow ui", () => {
|
|||||||
h.state,
|
h.state,
|
||||||
)[0] as ExcalidrawArrowElement;
|
)[0] as ExcalidrawArrowElement;
|
||||||
|
|
||||||
|
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||||
|
button: 2,
|
||||||
|
clientX: 1,
|
||||||
|
clientY: 1,
|
||||||
|
});
|
||||||
|
const contextMenu = UI.queryContextMenu();
|
||||||
|
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||||
|
|
||||||
mouse.click(51, 51);
|
mouse.click(51, 51);
|
||||||
|
|
||||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||||
@ -183,8 +182,8 @@ describe("elbow arrow ui", () => {
|
|||||||
[0, 0],
|
[0, 0],
|
||||||
[35, 0],
|
[35, 0],
|
||||||
[35, 90],
|
[35, 90],
|
||||||
[35, 90], // Note that coordinates are rounded above!
|
[25, 90],
|
||||||
[35, 165],
|
[25, 165],
|
||||||
[103, 165],
|
[103, 165],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,11 +36,11 @@ import {
|
|||||||
HEADING_UP,
|
HEADING_UP,
|
||||||
vectorToHeading,
|
vectorToHeading,
|
||||||
} from "./heading";
|
} from "./heading";
|
||||||
import type { ElementUpdate } from "./mutateElement";
|
|
||||||
import { mutateElement } from "./mutateElement";
|
import { mutateElement } from "./mutateElement";
|
||||||
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
|
import { isBindableElement, isRectanguloidElement } from "./typeChecks";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElbowArrowElement,
|
ExcalidrawElbowArrowElement,
|
||||||
|
FixedPointBinding,
|
||||||
NonDeletedSceneElementsMap,
|
NonDeletedSceneElementsMap,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@ -72,48 +72,16 @@ export const mutateElbowArrow = (
|
|||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
||||||
nextPoints: readonly LocalPoint[],
|
nextPoints: readonly LocalPoint[],
|
||||||
offset?: Vector,
|
offset?: Vector,
|
||||||
otherUpdates?: Omit<
|
otherUpdates?: {
|
||||||
ElementUpdate<ExcalidrawElbowArrowElement>,
|
startBinding?: FixedPointBinding | null;
|
||||||
"angle" | "x" | "y" | "width" | "height" | "elbowed" | "points"
|
endBinding?: FixedPointBinding | null;
|
||||||
>,
|
|
||||||
options?: {
|
|
||||||
isDragging?: boolean;
|
|
||||||
informMutation?: boolean;
|
|
||||||
},
|
},
|
||||||
) => {
|
|
||||||
const update = updateElbowArrow(
|
|
||||||
arrow,
|
|
||||||
elementsMap,
|
|
||||||
nextPoints,
|
|
||||||
offset,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
if (update) {
|
|
||||||
mutateElement(
|
|
||||||
arrow,
|
|
||||||
{
|
|
||||||
...otherUpdates,
|
|
||||||
...update,
|
|
||||||
angle: 0 as Radians,
|
|
||||||
},
|
|
||||||
options?.informMutation,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error("Elbow arrow cannot find a route");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateElbowArrow = (
|
|
||||||
arrow: ExcalidrawElbowArrowElement,
|
|
||||||
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
|
|
||||||
nextPoints: readonly LocalPoint[],
|
|
||||||
offset?: Vector,
|
|
||||||
options?: {
|
options?: {
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
disableBinding?: boolean;
|
disableBinding?: boolean;
|
||||||
informMutation?: boolean;
|
informMutation?: boolean;
|
||||||
},
|
},
|
||||||
): ElementUpdate<ExcalidrawElbowArrowElement> | null => {
|
) => {
|
||||||
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
const origStartGlobalPoint: GlobalPoint = pointTranslate(
|
||||||
pointTranslate<LocalPoint, GlobalPoint>(
|
pointTranslate<LocalPoint, GlobalPoint>(
|
||||||
nextPoints[0],
|
nextPoints[0],
|
||||||
@ -267,8 +235,6 @@ export const updateElbowArrow = (
|
|||||||
BASE_PADDING,
|
BASE_PADDING,
|
||||||
),
|
),
|
||||||
boundsOverlap,
|
boundsOverlap,
|
||||||
hoveredStartElement && aabbForElement(hoveredStartElement),
|
|
||||||
hoveredEndElement && aabbForElement(hoveredEndElement),
|
|
||||||
);
|
);
|
||||||
const startDonglePosition = getDonglePosition(
|
const startDonglePosition = getDonglePosition(
|
||||||
dynamicAABBs[0],
|
dynamicAABBs[0],
|
||||||
@ -329,10 +295,18 @@ export const updateElbowArrow = (
|
|||||||
startDongle && points.unshift(startGlobalPoint);
|
startDongle && points.unshift(startGlobalPoint);
|
||||||
endDongle && points.push(endGlobalPoint);
|
endDongle && points.push(endGlobalPoint);
|
||||||
|
|
||||||
return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0);
|
mutateElement(
|
||||||
|
arrow,
|
||||||
|
{
|
||||||
|
...otherUpdates,
|
||||||
|
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
|
||||||
|
angle: 0 as Radians,
|
||||||
|
},
|
||||||
|
options?.informMutation,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error("Elbow arrow cannot find a route");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const offsetFromHeading = (
|
const offsetFromHeading = (
|
||||||
@ -501,11 +475,7 @@ const generateDynamicAABBs = (
|
|||||||
startDifference?: [number, number, number, number],
|
startDifference?: [number, number, number, number],
|
||||||
endDifference?: [number, number, number, number],
|
endDifference?: [number, number, number, number],
|
||||||
disableSideHack?: boolean,
|
disableSideHack?: boolean,
|
||||||
startElementBounds?: Bounds | null,
|
|
||||||
endElementBounds?: Bounds | null,
|
|
||||||
): Bounds[] => {
|
): Bounds[] => {
|
||||||
const startEl = startElementBounds ?? a;
|
|
||||||
const endEl = endElementBounds ?? b;
|
|
||||||
const [startUp, startRight, startDown, startLeft] = startDifference ?? [
|
const [startUp, startRight, startDown, startLeft] = startDifference ?? [
|
||||||
0, 0, 0, 0,
|
0, 0, 0, 0,
|
||||||
];
|
];
|
||||||
@ -514,29 +484,29 @@ const generateDynamicAABBs = (
|
|||||||
const first = [
|
const first = [
|
||||||
a[0] > b[2]
|
a[0] > b[2]
|
||||||
? a[1] > b[3] || a[3] < b[1]
|
? a[1] > b[3] || a[3] < b[1]
|
||||||
? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft)
|
? Math.min((a[0] + b[2]) / 2, a[0] - startLeft)
|
||||||
: (startEl[0] + endEl[2]) / 2
|
: (a[0] + b[2]) / 2
|
||||||
: a[0] > b[0]
|
: a[0] > b[0]
|
||||||
? a[0] - startLeft
|
? a[0] - startLeft
|
||||||
: common[0] - startLeft,
|
: common[0] - startLeft,
|
||||||
a[1] > b[3]
|
a[1] > b[3]
|
||||||
? a[0] > b[2] || a[2] < b[0]
|
? a[0] > b[2] || a[2] < b[0]
|
||||||
? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp)
|
? Math.min((a[1] + b[3]) / 2, a[1] - startUp)
|
||||||
: (startEl[1] + endEl[3]) / 2
|
: (a[1] + b[3]) / 2
|
||||||
: a[1] > b[1]
|
: a[1] > b[1]
|
||||||
? a[1] - startUp
|
? a[1] - startUp
|
||||||
: common[1] - startUp,
|
: common[1] - startUp,
|
||||||
a[2] < b[0]
|
a[2] < b[0]
|
||||||
? a[1] > b[3] || a[3] < b[1]
|
? a[1] > b[3] || a[3] < b[1]
|
||||||
? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight)
|
? Math.max((a[2] + b[0]) / 2, a[2] + startRight)
|
||||||
: (startEl[2] + endEl[0]) / 2
|
: (a[2] + b[0]) / 2
|
||||||
: a[2] < b[2]
|
: a[2] < b[2]
|
||||||
? a[2] + startRight
|
? a[2] + startRight
|
||||||
: common[2] + startRight,
|
: common[2] + startRight,
|
||||||
a[3] < b[1]
|
a[3] < b[1]
|
||||||
? a[0] > b[2] || a[2] < b[0]
|
? a[0] > b[2] || a[2] < b[0]
|
||||||
? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown)
|
? Math.max((a[3] + b[1]) / 2, a[3] + startDown)
|
||||||
: (startEl[3] + endEl[1]) / 2
|
: (a[3] + b[1]) / 2
|
||||||
: a[3] < b[3]
|
: a[3] < b[3]
|
||||||
? a[3] + startDown
|
? a[3] + startDown
|
||||||
: common[3] + startDown,
|
: common[3] + startDown,
|
||||||
@ -544,29 +514,29 @@ const generateDynamicAABBs = (
|
|||||||
const second = [
|
const second = [
|
||||||
b[0] > a[2]
|
b[0] > a[2]
|
||||||
? b[1] > a[3] || b[3] < a[1]
|
? b[1] > a[3] || b[3] < a[1]
|
||||||
? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft)
|
? Math.min((b[0] + a[2]) / 2, b[0] - endLeft)
|
||||||
: (endEl[0] + startEl[2]) / 2
|
: (b[0] + a[2]) / 2
|
||||||
: b[0] > a[0]
|
: b[0] > a[0]
|
||||||
? b[0] - endLeft
|
? b[0] - endLeft
|
||||||
: common[0] - endLeft,
|
: common[0] - endLeft,
|
||||||
b[1] > a[3]
|
b[1] > a[3]
|
||||||
? b[0] > a[2] || b[2] < a[0]
|
? b[0] > a[2] || b[2] < a[0]
|
||||||
? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp)
|
? Math.min((b[1] + a[3]) / 2, b[1] - endUp)
|
||||||
: (endEl[1] + startEl[3]) / 2
|
: (b[1] + a[3]) / 2
|
||||||
: b[1] > a[1]
|
: b[1] > a[1]
|
||||||
? b[1] - endUp
|
? b[1] - endUp
|
||||||
: common[1] - endUp,
|
: common[1] - endUp,
|
||||||
b[2] < a[0]
|
b[2] < a[0]
|
||||||
? b[1] > a[3] || b[3] < a[1]
|
? b[1] > a[3] || b[3] < a[1]
|
||||||
? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight)
|
? Math.max((b[2] + a[0]) / 2, b[2] + endRight)
|
||||||
: (endEl[2] + startEl[0]) / 2
|
: (b[2] + a[0]) / 2
|
||||||
: b[2] < a[2]
|
: b[2] < a[2]
|
||||||
? b[2] + endRight
|
? b[2] + endRight
|
||||||
: common[2] + endRight,
|
: common[2] + endRight,
|
||||||
b[3] < a[1]
|
b[3] < a[1]
|
||||||
? b[0] > a[2] || b[2] < a[0]
|
? b[0] > a[2] || b[2] < a[0]
|
||||||
? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown)
|
? Math.max((b[3] + a[1]) / 2, b[3] + endDown)
|
||||||
: (endEl[3] + startEl[1]) / 2
|
: (b[3] + a[1]) / 2
|
||||||
: b[3] < a[3]
|
: b[3] < a[3]
|
||||||
? b[3] + endDown
|
? b[3] + endDown
|
||||||
: common[3] + endDown,
|
: common[3] + endDown,
|
||||||
|
|||||||
@ -320,12 +320,9 @@ export const getDefaultRoundnessTypeForElement = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isFixedPointBinding = (
|
export const isFixedPointBinding = (
|
||||||
binding: PointBinding | FixedPointBinding,
|
binding: PointBinding,
|
||||||
): binding is FixedPointBinding => {
|
): binding is FixedPointBinding => {
|
||||||
return (
|
return binding.fixedPoint != null;
|
||||||
Object.hasOwn(binding, "fixedPoint") &&
|
|
||||||
(binding as FixedPointBinding).fixedPoint != null
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Move this to @excalidraw/math
|
// TODO: Move this to @excalidraw/math
|
||||||
|
|||||||
@ -193,7 +193,6 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawGenericElement
|
| ExcalidrawGenericElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawLinearElement
|
| ExcalidrawLinearElement
|
||||||
| ExcalidrawArrowElement
|
|
||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawFrameElement
|
| ExcalidrawFrameElement
|
||||||
@ -269,19 +268,15 @@ export type PointBinding = {
|
|||||||
elementId: ExcalidrawBindableElement["id"];
|
elementId: ExcalidrawBindableElement["id"];
|
||||||
focus: number;
|
focus: number;
|
||||||
gap: number;
|
gap: number;
|
||||||
};
|
|
||||||
|
|
||||||
export type FixedPointBinding = Merge<
|
|
||||||
PointBinding,
|
|
||||||
{
|
|
||||||
// Represents the fixed point binding information in form of a vertical and
|
// Represents the fixed point binding information in form of a vertical and
|
||||||
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
|
||||||
// gives the user selected fixed point by multiplying the bound element width
|
// gives the user selected fixed point by multiplying the bound element width
|
||||||
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
|
||||||
// bound element-local point coordinate.
|
// bound element-local point coordinate.
|
||||||
fixedPoint: FixedPoint;
|
fixedPoint: FixedPoint | null;
|
||||||
}
|
};
|
||||||
>;
|
|
||||||
|
export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
|
||||||
|
|
||||||
export type Arrowhead =
|
export type Arrowhead =
|
||||||
| "arrow"
|
| "arrow"
|
||||||
|
|||||||
@ -52,6 +52,7 @@ import {
|
|||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import oc from "open-color";
|
import oc from "open-color";
|
||||||
import {
|
import {
|
||||||
|
isArrowElement,
|
||||||
isElbowArrow,
|
isElbowArrow,
|
||||||
isFrameLikeElement,
|
isFrameLikeElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@ -806,6 +807,7 @@ const _renderInteractiveScene = ({
|
|||||||
// Elbow arrow elements cannot be selected when bound on either end
|
// Elbow arrow elements cannot be selected when bound on either end
|
||||||
(
|
(
|
||||||
isSingleLinearElementSelected &&
|
isSingleLinearElementSelected &&
|
||||||
|
isArrowElement(element) &&
|
||||||
isElbowArrow(element) &&
|
isElbowArrow(element) &&
|
||||||
(element.startBinding || element.endBinding)
|
(element.startBinding || element.endBinding)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -185,11 +185,6 @@ export const exportToCanvas = async (
|
|||||||
exportingFrame ?? null,
|
exportingFrame ?? null,
|
||||||
appState.frameRendering ?? null,
|
appState.frameRendering ?? null,
|
||||||
);
|
);
|
||||||
// for canvas export, don't clip if exporting a specific frame as it would
|
|
||||||
// clip the corners of the content
|
|
||||||
if (exportingFrame) {
|
|
||||||
frameRendering.clip = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elementsForRender = prepareElementsForRender({
|
const elementsForRender = prepareElementsForRender({
|
||||||
elements,
|
elements,
|
||||||
@ -356,11 +351,6 @@ export const exportToSvg = async (
|
|||||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
}) rotate(${frame.angle} ${cx} ${cy})"
|
||||||
width="${frame.width}"
|
width="${frame.width}"
|
||||||
height="${frame.height}"
|
height="${frame.height}"
|
||||||
${
|
|
||||||
exportingFrame
|
|
||||||
? ""
|
|
||||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
</rect>
|
</rect>
|
||||||
</clipPath>`;
|
</clipPath>`;
|
||||||
|
|||||||
@ -8430,7 +8430,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
|||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
"selectedLinearElement": LinearElementEditor {
|
"selectedLinearElement": LinearElementEditor {
|
||||||
"elbowed": false,
|
|
||||||
"elementId": "id0",
|
"elementId": "id0",
|
||||||
"endBindingElement": "keep",
|
"endBindingElement": "keep",
|
||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
@ -8650,7 +8649,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
|||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
"selectedLinearElement": LinearElementEditor {
|
"selectedLinearElement": LinearElementEditor {
|
||||||
"elbowed": false,
|
|
||||||
"elementId": "id0",
|
"elementId": "id0",
|
||||||
"endBindingElement": "keep",
|
"endBindingElement": "keep",
|
||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
@ -9060,7 +9058,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
|||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
"selectedLinearElement": LinearElementEditor {
|
"selectedLinearElement": LinearElementEditor {
|
||||||
"elbowed": false,
|
|
||||||
"elementId": "id0",
|
"elementId": "id0",
|
||||||
"endBindingElement": "keep",
|
"endBindingElement": "keep",
|
||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
@ -9457,7 +9454,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
|||||||
"selectedElementsAreBeingDragged": false,
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": {},
|
"selectedGroupIds": {},
|
||||||
"selectedLinearElement": LinearElementEditor {
|
"selectedLinearElement": LinearElementEditor {
|
||||||
"elbowed": false,
|
|
||||||
"elementId": "id0",
|
"elementId": "id0",
|
||||||
"endBindingElement": "keep",
|
"endBindingElement": "keep",
|
||||||
"hoverPointIndex": -1,
|
"hoverPointIndex": -1,
|
||||||
|
|||||||
@ -9,8 +9,6 @@ import type {
|
|||||||
ExcalidrawFrameElement,
|
ExcalidrawFrameElement,
|
||||||
ExcalidrawElementType,
|
ExcalidrawElementType,
|
||||||
ExcalidrawMagicFrameElement,
|
ExcalidrawMagicFrameElement,
|
||||||
ExcalidrawElbowArrowElement,
|
|
||||||
ExcalidrawArrowElement,
|
|
||||||
} from "../../element/types";
|
} from "../../element/types";
|
||||||
import { newElement, newTextElement, newLinearElement } from "../../element";
|
import { newElement, newTextElement, newLinearElement } from "../../element";
|
||||||
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
|
||||||
@ -129,10 +127,6 @@ export class API {
|
|||||||
expect(API.getSelectedElements().length).toBe(0);
|
expect(API.getSelectedElements().length).toBe(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
static getElement = <T extends ExcalidrawElement>(element: T): T => {
|
|
||||||
return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element;
|
|
||||||
}
|
|
||||||
|
|
||||||
static createElement = <
|
static createElement = <
|
||||||
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
|
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
|
||||||
>({
|
>({
|
||||||
@ -185,16 +179,10 @@ export class API {
|
|||||||
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
|
||||||
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
|
||||||
startBinding?: T extends "arrow"
|
startBinding?: T extends "arrow"
|
||||||
? ExcalidrawArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"]
|
? ExcalidrawLinearElement["startBinding"]
|
||||||
: never;
|
: never;
|
||||||
endBinding?: T extends "arrow"
|
endBinding?: T extends "arrow"
|
||||||
? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"]
|
? ExcalidrawLinearElement["endBinding"]
|
||||||
: never;
|
|
||||||
startArrowhead?: T extends "arrow"
|
|
||||||
? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"]
|
|
||||||
: never;
|
|
||||||
endArrowhead?: T extends "arrow"
|
|
||||||
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
|
|
||||||
: never;
|
: never;
|
||||||
elbowed?: boolean;
|
elbowed?: boolean;
|
||||||
}): T extends "arrow" | "line"
|
}): T extends "arrow" | "line"
|
||||||
@ -352,8 +340,6 @@ export class API {
|
|||||||
if (element.type === "arrow") {
|
if (element.type === "arrow") {
|
||||||
element.startBinding = rest.startBinding ?? null;
|
element.startBinding = rest.startBinding ?? null;
|
||||||
element.endBinding = rest.endBinding ?? null;
|
element.endBinding = rest.endBinding ?? null;
|
||||||
element.startArrowhead = rest.startArrowhead ?? null;
|
|
||||||
element.endArrowhead = rest.endArrowhead ?? null;
|
|
||||||
}
|
}
|
||||||
if (id) {
|
if (id) {
|
||||||
element.id = id;
|
element.id = id;
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import type {
|
|||||||
ExcalidrawGenericElement,
|
ExcalidrawGenericElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
FixedPointBinding,
|
|
||||||
FractionalIndex,
|
FractionalIndex,
|
||||||
SceneElementsMap,
|
SceneElementsMap,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
@ -2050,13 +2049,13 @@ describe("history", () => {
|
|||||||
focus: -0.001587301587301948,
|
focus: -0.001587301587301948,
|
||||||
gap: 5,
|
gap: 5,
|
||||||
fixedPoint: [1.0318471337579618, 0.49920634920634904],
|
fixedPoint: [1.0318471337579618, 0.49920634920634904],
|
||||||
} as FixedPointBinding,
|
},
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: "u2JGnnmoJ0VATV4vCNJE5",
|
elementId: "u2JGnnmoJ0VATV4vCNJE5",
|
||||||
focus: -0.0016129032258049847,
|
focus: -0.0016129032258049847,
|
||||||
gap: 3.537079145500037,
|
gap: 3.537079145500037,
|
||||||
fixedPoint: [0.4991935483870975, -0.03875193720914723],
|
fixedPoint: [0.4991935483870975, -0.03875193720914723],
|
||||||
} as FixedPointBinding,
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
storeAction: StoreAction.CAPTURE,
|
storeAction: StoreAction.CAPTURE,
|
||||||
@ -4456,7 +4455,7 @@ describe("history", () => {
|
|||||||
elements: [
|
elements: [
|
||||||
h.elements[0],
|
h.elements[0],
|
||||||
newElementWith(h.elements[1], { boundElements: [] }),
|
newElementWith(h.elements[1], { boundElements: [] }),
|
||||||
newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, {
|
newElementWith(h.elements[2] as ExcalidrawLinearElement, {
|
||||||
endBinding: {
|
endBinding: {
|
||||||
elementId: remoteContainer.id,
|
elementId: remoteContainer.id,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
@ -4656,7 +4655,7 @@ describe("history", () => {
|
|||||||
// Simulate remote update
|
// Simulate remote update
|
||||||
API.updateScene({
|
API.updateScene({
|
||||||
elements: [
|
elements: [
|
||||||
newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, {
|
newElementWith(h.elements[0] as ExcalidrawLinearElement, {
|
||||||
startBinding: {
|
startBinding: {
|
||||||
elementId: rect1.id,
|
elementId: rect1.id,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { render } from "./test-utils";
|
|||||||
import { reseed } from "../random";
|
import { reseed } from "../random";
|
||||||
import { UI, Keyboard, Pointer } from "./helpers/ui";
|
import { UI, Keyboard, Pointer } from "./helpers/ui";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElbowArrowElement,
|
|
||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
@ -334,62 +333,6 @@ describe("arrow element", () => {
|
|||||||
expect(label.angle).toBeCloseTo(0);
|
expect(label.angle).toBeCloseTo(0);
|
||||||
expect(label.fontSize).toEqual(20);
|
expect(label.fontSize).toEqual(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flips the fixed point binding on negative resize for single bindable", () => {
|
|
||||||
const rectangle = UI.createElement("rectangle", {
|
|
||||||
x: -100,
|
|
||||||
y: -75,
|
|
||||||
width: 95,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
UI.clickTool("arrow");
|
|
||||||
UI.clickOnTestId("elbow-arrow");
|
|
||||||
mouse.reset();
|
|
||||||
mouse.moveTo(-5, 0);
|
|
||||||
mouse.click();
|
|
||||||
mouse.moveTo(120, 200);
|
|
||||||
mouse.click();
|
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
|
||||||
h.state,
|
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
|
||||||
|
|
||||||
UI.resize(rectangle, "se", [-200, -150]);
|
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flips the fixed point binding on negative resize for group selection", () => {
|
|
||||||
const rectangle = UI.createElement("rectangle", {
|
|
||||||
x: -100,
|
|
||||||
y: -75,
|
|
||||||
width: 95,
|
|
||||||
height: 100,
|
|
||||||
});
|
|
||||||
UI.clickTool("arrow");
|
|
||||||
UI.clickOnTestId("elbow-arrow");
|
|
||||||
mouse.reset();
|
|
||||||
mouse.moveTo(-5, 0);
|
|
||||||
mouse.click();
|
|
||||||
mouse.moveTo(120, 200);
|
|
||||||
mouse.click();
|
|
||||||
|
|
||||||
const arrow = h.scene.getSelectedElements(
|
|
||||||
h.state,
|
|
||||||
)[0] as ExcalidrawElbowArrowElement;
|
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
|
|
||||||
|
|
||||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
|
||||||
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
|
|
||||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("text element", () => {
|
describe("text element", () => {
|
||||||
@ -885,6 +828,7 @@ describe("multiple selection", () => {
|
|||||||
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
expect(leftBoundArrow.endBinding?.elementId).toBe(
|
||||||
leftArrowBinding.elementId,
|
leftArrowBinding.elementId,
|
||||||
);
|
);
|
||||||
|
expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull();
|
||||||
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
|
||||||
|
|
||||||
expect(rightBoundArrow.x).toBeCloseTo(210);
|
expect(rightBoundArrow.x).toBeCloseTo(210);
|
||||||
@ -899,6 +843,7 @@ describe("multiple selection", () => {
|
|||||||
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
expect(rightBoundArrow.endBinding?.elementId).toBe(
|
||||||
rightArrowBinding.elementId,
|
rightArrowBinding.elementId,
|
||||||
);
|
);
|
||||||
|
expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull();
|
||||||
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
|
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -110,8 +110,8 @@ export const debugDrawBoundingBox = (
|
|||||||
export const debugDrawBounds = (
|
export const debugDrawBounds = (
|
||||||
box: Bounds | Bounds[],
|
box: Bounds | Bounds[],
|
||||||
opts?: {
|
opts?: {
|
||||||
color?: string;
|
color: string;
|
||||||
permanent?: boolean;
|
permanent: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
(isBounds(box) ? [box] : box).forEach((bbox) =>
|
(isBounds(box) ? [box] : box).forEach((bbox) =>
|
||||||
@ -136,7 +136,7 @@ export const debugDrawBounds = (
|
|||||||
],
|
],
|
||||||
{
|
{
|
||||||
color: opts?.color ?? "green",
|
color: opts?.color ?? "green",
|
||||||
permanent: !!opts?.permanent,
|
permanent: opts?.permanent,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user