Compare commits

..

No commits in common. "f1a1c4b28eae527d3561203aa5bb25b3647f7ee1" and "508f16dc044f9f747ad48f8fad2280753862c85b" have entirely different histories.

34 changed files with 739 additions and 605 deletions

View File

@ -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

View File

@ -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: () => {

View File

@ -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,
);

View File

@ -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(),
), ),

View File

@ -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>
); );

View File

@ -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}

View File

@ -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>

View File

@ -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 },
); );

View 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>
);
};

View 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>
);
};

View File

@ -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[],

View File

@ -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") };
}
};

View File

@ -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>

View File

@ -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,
}; };
}); });

View File

@ -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);
});
});

View File

@ -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;
}; };

View File

@ -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;

View File

@ -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;

View File

@ -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,
}
: {}),
}; };
}; };

View File

@ -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(

View File

@ -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

View File

@ -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);

View File

@ -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 {

View File

@ -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],
]); ]);
}); });

View File

@ -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,

View File

@ -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

View File

@ -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"

View File

@ -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)
) )

View File

@ -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>`;

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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);
}); });

View File

@ -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,
}, },
), ),
); );