Compare commits

..

1 Commits

Author SHA1 Message Date
zsviczian 847fd3da32 set textarea width based on text size ceil value 2024-04-03 22:40:51 +02:00
153 changed files with 21050 additions and 45975 deletions
@@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git
| API | Signature | Usage |
| --- | --- | --- |
| [updateScene](#updatescene) | `function` | updates the scene with the sceneData |
| [updateLibrary](#updatelibrary) | `function` | updates the library |
| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData |
| [addFiles](#addfiles) | `function` | add files data to the appState |
| [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
| [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene |
@@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the
| `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene |
| `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. |
| `collaborators` | <code>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/types.ts#L37">Collaborator></a></code> | The list of collaborators to be updated in the scene. |
| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. |
| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
```jsx live
function App() {
@@ -115,7 +115,7 @@ function App() {
<button className="custom-button" onClick={updateScene}>
Update Scene
</button>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
</div>
);
}
@@ -188,7 +188,7 @@ function App() {
Update Library
</button>
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
ref={(api) => setExcalidrawAPI(api)}
// initial data retrieved from https://github.com/excalidraw/excalidraw/blob/master/dev-docs/packages/excalidraw/initialData.js
initialData={{
libraryItems: initialData.libraryItems,
+28 -40
View File
@@ -14,9 +14,10 @@ import {
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
Theme,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
@@ -26,9 +27,7 @@ import {
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
StoreAction,
reconcileElements,
} from "../packages/excalidraw";
} from "../packages/excalidraw/index";
import {
AppState,
ExcalidrawImperativeAPI,
@@ -89,6 +88,7 @@ import {
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
@@ -108,7 +108,6 @@ import { OverwriteConfirmDialog } from "../packages/excalidraw/components/Overwr
import Trans from "../packages/excalidraw/components/Trans";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
import {
CommandPalette,
DEFAULT_CATEGORIES,
@@ -121,9 +120,7 @@ import {
usersIcon,
exportToPlus,
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
polyfill();
@@ -272,7 +269,7 @@ const initializeScene = async (opts: {
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
},
@@ -303,9 +300,6 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
// initial state
// ---------------------------------------------------------------------------
@@ -437,7 +431,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
});
}
});
@@ -468,7 +462,6 @@ const ExcalidrawWrapper = () => {
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
});
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
@@ -570,8 +563,25 @@ const ExcalidrawWrapper = () => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
const [theme, setTheme] = useState<Theme>(
() =>
(localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_THEME,
) as Theme | null) ||
// FIXME migration from old LS scheme. Can be removed later. #5660
importFromLocalStorage().appState?.theme ||
THEME.LIGHT,
);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
// currently only used for body styling during init (see public/index.html),
// but may change in the future
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
}, [theme]);
const onChange = (
elements: readonly OrderedExcalidrawElement[],
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
@@ -579,6 +589,8 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements);
}
setTheme(appState.theme);
// this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) {
@@ -604,7 +616,6 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
@@ -784,7 +795,7 @@ const ExcalidrawWrapper = () => {
detectScroll={false}
handleKeyboardGlobally={true}
autoFocus={true}
theme={editorTheme}
theme={theme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
@@ -806,8 +817,6 @@ const ExcalidrawWrapper = () => {
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@@ -1055,20 +1064,6 @@ const ExcalidrawWrapper = () => {
);
},
},
{
label: "YouTube",
icon: youtubeIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["features", "tutorials", "howto", "help", "community"],
perform: () => {
window.open(
"https://youtube.com/@excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
...(isExcalidrawPlusSignedUser
? [
{
@@ -1095,14 +1090,7 @@ const ExcalidrawWrapper = () => {
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
CommandPalette.defaultItems.toggleTheme,
]}
/>
</Excalidraw>
+39 -33
View File
@@ -10,15 +10,12 @@ import { ImportedDataState } from "../../packages/excalidraw/data/types";
import {
ExcalidrawElement,
InitializedExcalidrawImageElement,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import {
StoreAction,
getSceneVersion,
restoreElements,
zoomToFitBounds,
reconcileElements,
} from "../../packages/excalidraw";
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
@@ -72,6 +69,10 @@ import {
isInitializedImageElement,
} from "../../packages/excalidraw/element/typeChecks";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import {
ReconciledElements,
reconcileElements as _reconcileElements,
} from "./reconciliation";
import { decryptData } from "../../packages/excalidraw/data/encryption";
import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
@@ -81,10 +82,6 @@ import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils";
import { collabErrorIndicatorAtom } from "./CollabError";
import type {
ReconciledExcalidrawElement,
RemoteExcalidrawElement,
} from "../../packages/excalidraw/data/reconcile";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
@@ -277,7 +274,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
syncableElements: readonly SyncableExcalidrawElement[],
) => {
try {
const storedElements = await saveToFirebase(
const savedData = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
@@ -285,8 +282,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.resetErrorIndicator();
if (this.isCollaborating() && storedElements) {
this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
}
} catch (error: any) {
const errorMessage = /is longer than.*?bytes/.test(error.message)
@@ -357,7 +356,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
commitToHistory: false,
});
}
};
@@ -430,7 +429,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
startCollaboration = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
) => {
): Promise<ImportedDataState | null> => {
if (!this.state.username) {
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
const username = getRandomUsername();
@@ -456,11 +455,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
);
}
// TODO: `ImportedDataState` type here seems abused
const scenePromise = resolvablePromise<
| (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
| null
>();
const scenePromise = resolvablePromise<ImportedDataState | null>();
this.setIsCollaborating(true);
LocalData.pauseSave("collaboration");
@@ -502,13 +497,14 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
return element;
});
// remove deleted elements from elements array to ensure we don't
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
commitToHistory: true,
});
this.saveCollabRoomToFirebase(getSyncableElements(elements));
@@ -542,9 +538,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
if (!this.portal.socketInitialized) {
this.initializeRoom({ fetchScene: false });
const remoteElements = decryptedData.payload.elements;
const reconciledElements =
this._reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements);
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
// noop if already resolved via init from firebase
scenePromise.resolve({
elements: reconciledElements,
@@ -555,7 +552,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
case WS_SUBTYPES.UPDATE:
this.handleRemoteSceneUpdate(
this._reconcileElements(decryptedData.payload.elements),
this.reconcileElements(decryptedData.payload.elements),
);
break;
case WS_SUBTYPES.MOUSE_LOCATION: {
@@ -703,15 +700,17 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return null;
};
private _reconcileElements = (
private reconcileElements = (
remoteElements: readonly ExcalidrawElement[],
): ReconciledExcalidrawElement[] => {
): ReconciledElements => {
const localElements = this.getSceneElementsIncludingDeleted();
const appState = this.excalidrawAPI.getAppState();
const restoredRemoteElements = restoreElements(remoteElements, null);
const reconciledElements = reconcileElements(
remoteElements = restoreElements(remoteElements, null);
const reconciledElements = _reconcileElements(
localElements,
restoredRemoteElements as RemoteExcalidrawElement[],
remoteElements,
appState,
);
@@ -742,13 +741,20 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}, LOAD_IMAGES_TIMEOUT);
private handleRemoteSceneUpdate = (
elements: ReconciledExcalidrawElement[],
elements: ReconciledElements,
{ init = false }: { init?: boolean } = {},
) => {
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
commitToHistory: !!init,
});
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
// when we receive any messages from another peer. This UX can be pretty rough -- if you
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
// right now we think this is the right tradeoff.
this.excalidrawAPI.history.clear();
this.loadImageFiles();
};
@@ -881,7 +887,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@@ -892,7 +898,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
};
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
syncElements = (elements: readonly ExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
+1 -1
View File
@@ -23,7 +23,7 @@
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
transform: rotate(0eg);
}
75% {
transform: rotate(-10deg);
+23 -16
View File
@@ -2,12 +2,11 @@ import {
isSyncableElement,
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { TCollabClass } from "./Collab";
import { OrderedExcalidrawElement } from "../../packages/excalidraw/element/types";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
import {
OnUserFollowedPayload,
@@ -17,9 +16,10 @@ import {
import { trackEvent } from "../../packages/excalidraw/analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { BroadcastedExcalidrawElement } from "./reconciliation";
import { encryptData } from "../../packages/excalidraw/data/encryption";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import type { Socket } from "socket.io-client";
import { StoreAction } from "../../packages/excalidraw";
class Portal {
collab: TCollabClass;
@@ -128,13 +128,12 @@ class Portal {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
}, FILE_UPLOAD_TIMEOUT);
broadcastScene = async (
updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
elements: readonly OrderedExcalidrawElement[],
allElements: readonly ExcalidrawElement[],
syncAll: boolean,
) => {
if (updateType === WS_SUBTYPES.INIT && !syncAll) {
@@ -144,17 +143,25 @@ class Portal {
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
const syncableElements = elements.reduce((acc, element) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version > this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push(element);
}
return acc;
}, [] as SyncableExcalidrawElement[]);
const syncableElements = allElements.reduce(
(acc, element: BroadcastedExcalidrawElement, idx, elements) => {
if (
(syncAll ||
!this.broadcastedElementVersions.has(element.id) ||
element.version >
this.broadcastedElementVersions.get(element.id)!) &&
isSyncableElement(element)
) {
acc.push({
...element,
// z-index info for the reconciler
[PRECEDING_ELEMENT_KEY]: idx === 0 ? "^" : elements[idx - 1]?.id,
});
}
return acc;
},
[] as BroadcastedExcalidrawElement[],
);
const data: SocketUpdateDataSource[typeof updateType] = {
type: updateType,
+154
View File
@@ -0,0 +1,154 @@
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import { arrayToMapWithIndex } from "../../packages/excalidraw/utils";
export type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
export type BroadcastedExcalidrawElement = ExcalidrawElement & {
[PRECEDING_ELEMENT_KEY]?: string;
};
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: ExcalidrawElement | undefined,
remote: BroadcastedExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id ||
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
export const reconcileElements = (
localElements: readonly ExcalidrawElement[],
remoteElements: readonly BroadcastedExcalidrawElement[],
localAppState: AppState,
): ReconciledElements => {
const localElementsData =
arrayToMapWithIndex<ExcalidrawElement>(localElements);
const reconciledElements: ExcalidrawElement[] = localElements.slice();
const duplicates = new WeakMap<ExcalidrawElement, true>();
let cursor = 0;
let offset = 0;
let remoteElementIdx = -1;
for (const remoteElement of remoteElements) {
remoteElementIdx++;
const local = localElementsData.get(remoteElement.id);
if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) {
if (remoteElement[PRECEDING_ELEMENT_KEY]) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
}
continue;
}
// Mark duplicate for removal as it'll be replaced with the remote element
if (local) {
// Unless the remote and local elements are the same element in which case
// we need to keep it as we'd otherwise discard it from the resulting
// array.
if (local[0] === remoteElement) {
continue;
}
duplicates.set(local[0], true);
}
// parent may not be defined in case the remote client is running an older
// excalidraw version
const parent =
remoteElement[PRECEDING_ELEMENT_KEY] ||
remoteElements[remoteElementIdx - 1]?.id ||
null;
if (parent != null) {
delete remoteElement[PRECEDING_ELEMENT_KEY];
// ^ indicates the element is the first in elements array
if (parent === "^") {
offset++;
if (cursor === 0) {
reconciledElements.unshift(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor - offset,
]);
} else {
reconciledElements.splice(cursor + 1, 0, remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
}
} else {
let idx = localElementsData.has(parent)
? localElementsData.get(parent)![1]
: null;
if (idx != null) {
idx += offset;
}
if (idx != null && idx >= cursor) {
reconciledElements.splice(idx + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
idx + 1 - offset,
]);
cursor = idx + 1;
} else if (idx != null) {
reconciledElements.splice(cursor + 1, 0, remoteElement);
offset++;
localElementsData.set(remoteElement.id, [
remoteElement,
cursor + 1 - offset,
]);
cursor++;
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
// no parent z-index information, local element exists → replace in place
} else if (local) {
reconciledElements[local[1]] = remoteElement;
localElementsData.set(remoteElement.id, [remoteElement, local[1]]);
// otherwise push to the end
} else {
reconciledElements.push(remoteElement);
localElementsData.set(remoteElement.id, [
remoteElement,
reconciledElements.length - 1 - offset,
]);
}
}
const ret: readonly ExcalidrawElement[] = reconciledElements.filter(
(element) => !duplicates.has(element),
);
return ret as ReconciledElements;
};
+6 -26
View File
@@ -1,19 +1,12 @@
import React from "react";
import {
arrowBarToLeftIcon,
ExcalLogo,
} from "../../packages/excalidraw/components/icons";
import { Theme } from "../../packages/excalidraw/element/types";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import { MainMenu } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
import { LanguageList } from "./LanguageList";
export const AppMainMenu: React.FC<{
onCollabDialogOpen: () => any;
isCollaborating: boolean;
isCollabEnabled: boolean;
theme: Theme | "system";
setTheme: (theme: Theme | "system") => void;
}> = React.memo((props) => {
return (
<MainMenu>
@@ -27,35 +20,22 @@ export const AppMainMenu: React.FC<{
onSelect={() => props.onCollabDialogOpen()}
/>
)}
<MainMenu.DefaultItems.CommandPalette className="highlighted" />
<MainMenu.DefaultItems.CommandPalette />
<MainMenu.DefaultItems.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={ExcalLogo}
icon={PlusPromoIcon}
href={`${
import.meta.env.VITE_APP_PLUS_APP
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
className=""
className="ExcalidrawPlus"
>
Excalidraw+
</MainMenu.ItemLink>
<MainMenu.DefaultItems.Socials />
<MainMenu.ItemLink
icon={arrowBarToLeftIcon}
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>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>
@@ -1,5 +1,5 @@
import React from "react";
import { arrowBarToLeftIcon } from "../../packages/excalidraw/components/icons";
import { PlusPromoIcon } from "../../packages/excalidraw/components/icons";
import { useI18n } from "../../packages/excalidraw/i18n";
import { WelcomeScreen } from "../../packages/excalidraw/index";
import { isExcalidrawPlusSignedUser } from "../app_constants";
@@ -61,9 +61,9 @@ export const AppWelcomeScreen: React.FC<{
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
shortcut={null}
icon={arrowBarToLeftIcon}
icon={PlusPromoIcon}
>
Sign up
Try Excalidraw Plus!
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
-2
View File
@@ -1,4 +1,3 @@
import { StoreAction } from "../../packages/excalidraw";
import { compressData } from "../../packages/excalidraw/data/encode";
import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
@@ -239,6 +238,5 @@ export const updateStaleImageStatuses = (params: {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
};
+27 -32
View File
@@ -1,8 +1,6 @@
import { reconcileElements } from "../../packages/excalidraw";
import {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import Portal from "../collab/Portal";
@@ -20,10 +18,10 @@ import {
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import type { RemoteExcalidrawElement } from "../../packages/excalidraw/data/reconcile";
// private
// -----------------------------------------------------------------------------
@@ -232,7 +230,7 @@ export const saveToFirebase = async (
!socket ||
isSavedToFirebase(portal, elements)
) {
return null;
return false;
}
const firebase = await loadFirestore();
@@ -240,59 +238,56 @@ export const saveToFirebase = async (
const docRef = firestore.collection("scenes").doc(roomId);
const storedScene = await firestore.runTransaction(async (transaction) => {
const savedData = await firestore.runTransaction(async (transaction) => {
const snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
const storedScene = await createFirebaseSceneDocument(
const sceneDocument = await createFirebaseSceneDocument(
firebase,
elements,
roomKey,
);
transaction.set(docRef, storedScene);
transaction.set(docRef, sceneDocument);
return storedScene;
return {
elements,
reconciledElements: null,
};
}
const prevStoredScene = snapshot.data() as FirebaseStoredScene;
const prevStoredElements = getSyncableElements(
restoreElements(await decryptElements(prevStoredScene, roomKey), null),
);
const reconciledElements = getSyncableElements(
reconcileElements(
elements,
prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
appState,
),
const prevDocData = snapshot.data() as FirebaseStoredScene;
const prevElements = getSyncableElements(
await decryptElements(prevDocData, roomKey),
);
const storedScene = await createFirebaseSceneDocument(
const reconciledElements = getSyncableElements(
reconcileElements(elements, prevElements, appState),
);
const sceneDocument = await createFirebaseSceneDocument(
firebase,
reconciledElements,
roomKey,
);
transaction.update(docRef, storedScene);
// Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
return storedScene;
transaction.update(docRef, sceneDocument);
return {
elements,
reconciledElements,
};
});
const storedElements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
);
FirebaseSceneVersionCache.set(socket, savedData.elements);
FirebaseSceneVersionCache.set(socket, storedElements);
return storedElements;
return { reconciledElements: savedData.reconciledElements };
};
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: Socket | null,
): Promise<readonly SyncableExcalidrawElement[] | null> => {
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();
@@ -303,14 +298,14 @@ export const loadFromFirebase = async (
}
const storedScene = doc.data() as FirebaseStoredScene;
const elements = getSyncableElements(
restoreElements(await decryptElements(storedScene, roomKey), null),
await decryptElements(storedScene, roomKey),
);
if (socket) {
FirebaseSceneVersionCache.set(socket, elements);
}
return elements;
return restoreElements(elements, null);
};
export const loadFilesFromFirebase = async (
+6 -8
View File
@@ -16,7 +16,6 @@ import { isInitializedImageElement } from "../../packages/excalidraw/element/typ
import {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import {
@@ -26,7 +25,6 @@ import {
SocketId,
UserIdleState,
} from "../../packages/excalidraw/types";
import { MakeBrand } from "../../packages/excalidraw/utility-types";
import { bytesToHexString } from "../../packages/excalidraw/utils";
import {
DELETED_ELEMENT_TIMEOUT,
@@ -37,11 +35,12 @@ import {
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
export type SyncableExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"SyncableExcalidrawElement">;
export type SyncableExcalidrawElement = ExcalidrawElement & {
_brand: "SyncableExcalidrawElement";
};
export const isSyncableElement = (
element: OrderedExcalidrawElement,
element: ExcalidrawElement,
): element is SyncableExcalidrawElement => {
if (element.isDeleted) {
if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
@@ -52,9 +51,7 @@ export const isSyncableElement = (
return !isInvisiblySmallElement(element);
};
export const getSyncableElements = (
elements: readonly OrderedExcalidrawElement[],
) =>
export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter((element) =>
isSyncableElement(element),
) as SyncableExcalidrawElement[];
@@ -269,6 +266,7 @@ export const loadScene = async (
// in the scene database/localStorage, and instead fetch them async
// from a different database
files: data.files,
commitToHistory: false,
};
};
+8 -23
View File
@@ -64,30 +64,12 @@
<!-- to minimize white flash on load when user has dark mode enabled -->
<script>
try {
function setTheme(theme) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
//
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme === "dark") {
document.documentElement.classList.add("dark");
}
function getTheme() {
const theme = window.localStorage.getItem("excalidraw-theme");
if (theme && theme === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} else {
return theme || "light";
}
}
setTheme(getTheme());
} catch (e) {
console.error("Error setting dark mode", e);
}
} catch {}
</script>
<style>
html.dark {
@@ -215,6 +197,8 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@@ -247,5 +231,6 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>
+1 -1
View File
@@ -38,7 +38,7 @@
background-color: #ecfdf5;
color: #064e3c;
}
&.highlighted {
&.ExcalidrawPlus {
color: var(--color-promo);
}
}
@@ -216,23 +216,32 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
stroke-width="2"
viewBox="0 0 24 24"
>
<g>
<g
stroke-width="1.5"
>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M10 12l10 0"
<rect
height="4"
rx="1"
width="18"
x="3"
y="8"
/>
<line
x1="12"
x2="12"
y1="8"
y2="21"
/>
<path
d="M10 12l4 4"
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
/>
<path
d="M10 12l4 -4"
/>
<path
d="M4 4l0 16"
d="M7.5 8a2.5 2.5 0 0 1 0 -5a4.8 8 0 0 1 4.5 5a4.8 8 0 0 1 4.5 -5a2.5 2.5 0 0 1 0 5"
/>
</g>
</svg>
@@ -240,7 +249,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
<div
class="welcome-screen-menu-item__text"
>
Sign up
Try Excalidraw Plus!
</div>
</a>
</div>
+21 -179
View File
@@ -1,19 +1,12 @@
import { vi } from "vitest";
import {
act,
render,
updateSceneData,
waitFor,
} from "../../packages/excalidraw/tests/test-utils";
import ExcalidrawApp from "../App";
import { API } from "../../packages/excalidraw/tests/helpers/api";
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
import {
createRedoAction,
createUndoAction,
} from "../../packages/excalidraw/actions/actionHistory";
import { StoreAction, newElementWith } from "../../packages/excalidraw";
import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory";
const { h } = window;
Object.defineProperty(window, "crypto", {
@@ -63,190 +56,39 @@ vi.mock("socket.io-client", () => {
};
});
/**
* These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
* while having access to both scenes, appstates stores, histories and etc.
* i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
*/
describe("collaboration", () => {
it("should allow to undo / redo even on force-deleted elements", async () => {
it("creating room should reset deleted elements", async () => {
await render(<ExcalidrawApp />);
const rect1Props = {
type: "rectangle",
id: "A",
height: 200,
width: 100,
} as const;
const rect2Props = {
type: "rectangle",
id: "B",
width: 100,
height: 200,
} as const;
const rect1 = API.createElement({ ...rect1Props });
const rect2 = API.createElement({ ...rect2Props });
// To update the scene with deleted elements before starting collab
updateSceneData({
elements: syncInvalidIndices([rect1, rect2]),
storeAction: StoreAction.CAPTURE,
elements: [
API.createElement({ type: "rectangle", id: "A" }),
API.createElement({
type: "rectangle",
id: "B",
isDeleted: true,
}),
],
});
updateSceneData({
elements: syncInvalidIndices([
rect1,
newElementWith(h.elements[1], { isDeleted: true }),
]),
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
expect.objectContaining({ id: "A" }),
expect.objectContaining({ id: "B", isDeleted: true }),
]);
expect(API.getStateHistory().length).toBe(1);
});
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
window.collab.startCollaboration(null);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
// we never delete from the local snapshot as it is used for correct diff calculation
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
});
const undoAction = createUndoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(undoAction));
// with explicit undo (as addition) we expect our item to be restored from the snapshot!
const undoAction = createUndoAction(h.history);
// noop
h.app.actionManager.executeAction(undoAction);
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false }),
]);
});
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
const redoAction = createRedoAction(h.history, h.store);
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as removal) we again restore the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// simulate local update
updateSceneData({
elements: syncInvalidIndices([
h.elements[0],
newElementWith(h.elements[1], { x: 100 }),
]),
storeAction: StoreAction.CAPTURE,
});
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
]);
});
act(() => h.app.actionManager.executeAction(undoAction));
// we expect to iterate the stack to the first visible change
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
expect(h.elements).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
]);
});
// simulate force deleting the element remotely
updateSceneData({
elements: syncInvalidIndices([rect1]),
storeAction: StoreAction.UPDATE,
});
// snapshot was correctly updated and marked the element as deleted
await waitFor(() => {
expect(API.getUndoStack().length).toBe(1);
expect(API.getRedoStack().length).toBe(1);
expect(API.getSnapshot()).toEqual([
expect.objectContaining(rect1Props),
expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
]);
expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
});
act(() => h.app.actionManager.executeAction(redoAction));
// with explicit redo (as update) we again restored the element from the snapshot!
await waitFor(() => {
expect(API.getUndoStack().length).toBe(2);
expect(API.getRedoStack().length).toBe(0);
expect(API.getSnapshot()).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.history.isRedoStackEmpty).toBeTruthy();
expect(h.elements).toEqual([
expect.objectContaining({ id: "A", isDeleted: false }),
expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
]);
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
expect(API.getStateHistory().length).toBe(1);
});
});
});
+421
View File
@@ -0,0 +1,421 @@
import { expect } from "chai";
import { PRECEDING_ELEMENT_KEY } from "../../packages/excalidraw/constants";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import {
BroadcastedExcalidrawElement,
ReconciledElements,
reconcileElements,
} from "../../excalidraw-app/collab/reconciliation";
import { randomInteger } from "../../packages/excalidraw/random";
import { AppState } from "../../packages/excalidraw/types";
import { cloneJSON } from "../../packages/excalidraw/utils";
type Id = string;
type ElementLike = {
id: string;
version: number;
versionNonce: number;
[PRECEDING_ELEMENT_KEY]?: string | null;
};
type Cache = Record<string, ExcalidrawElement | undefined>;
const createElement = (opts: { uid: string } | ElementLike) => {
let uid: string;
let id: string;
let version: number | null;
let parent: string | null = null;
let versionNonce: number | null = null;
if ("uid" in opts) {
const match = opts.uid.match(
/^(?:\((\^|\w+)\))?(\w+)(?::(\d+))?(?:\((\w+)\))?$/,
)!;
parent = match[1];
id = match[2];
version = match[3] ? parseInt(match[3]) : null;
uid = version ? `${id}:${version}` : id;
} else {
({ id, version, versionNonce } = opts);
parent = parent || null;
uid = id;
}
return {
uid,
id,
version,
versionNonce: versionNonce || randomInteger(),
[PRECEDING_ELEMENT_KEY]: parent || null,
};
};
const idsToElements = (
ids: (Id | ElementLike)[],
cache: Cache = {},
): readonly ExcalidrawElement[] => {
return ids.reduce((acc, _uid, idx) => {
const {
uid,
id,
version,
[PRECEDING_ELEMENT_KEY]: parent,
versionNonce,
} = createElement(typeof _uid === "string" ? { uid: _uid } : _uid);
const cached = cache[uid];
const elem = {
id,
version: version ?? 0,
versionNonce,
...cached,
[PRECEDING_ELEMENT_KEY]: parent,
} as BroadcastedExcalidrawElement;
// @ts-ignore
cache[uid] = elem;
acc.push(elem);
return acc;
}, [] as ExcalidrawElement[]);
};
const addParents = (elements: BroadcastedExcalidrawElement[]) => {
return elements.map((el, idx, els) => {
el[PRECEDING_ELEMENT_KEY] = els[idx - 1]?.id || "^";
return el;
});
};
const cleanElements = (elements: ReconciledElements) => {
return elements.map((el) => {
// @ts-ignore
delete el[PRECEDING_ELEMENT_KEY];
// @ts-ignore
delete el.next;
// @ts-ignore
delete el.prev;
return el;
});
};
const test = <U extends `${string}:${"L" | "R"}`>(
local: (Id | ElementLike)[],
remote: (Id | ElementLike)[],
target: U[],
bidirectional = true,
) => {
const cache: Cache = {};
const _local = idsToElements(local, cache);
const _remote = idsToElements(remote, cache);
const _target = target.map((uid) => {
const [, id, source] = uid.match(/^(\w+):([LR])$/)!;
return (source === "L" ? _local : _remote).find((e) => e.id === id)!;
}) as any as ReconciledElements;
const remoteReconciled = reconcileElements(_local, _remote, {} as AppState);
expect(target.length).equal(remoteReconciled.length);
expect(cleanElements(remoteReconciled)).deep.equal(
cleanElements(_target),
"remote reconciliation",
);
const __local = cleanElements(cloneJSON(_remote) as ReconciledElements);
const __remote = addParents(cleanElements(cloneJSON(remoteReconciled)));
if (bidirectional) {
try {
expect(
cleanElements(
reconcileElements(
cloneJSON(__local),
cloneJSON(__remote),
{} as AppState,
),
),
).deep.equal(cleanElements(remoteReconciled), "local re-reconciliation");
} catch (error: any) {
console.error("local original", __local);
console.error("remote reconciled", __remote);
throw error;
}
}
};
export const findIndex = <T>(
array: readonly T[],
cb: (element: T, index: number, array: readonly T[]) => boolean,
fromIndex: number = 0,
) => {
if (fromIndex < 0) {
fromIndex = array.length + fromIndex;
}
fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
let index = fromIndex - 1;
while (++index < array.length) {
if (cb(array[index], index, array)) {
return index;
}
}
return -1;
};
// -----------------------------------------------------------------------------
describe("elements reconciliation", () => {
it("reconcileElements()", () => {
// -------------------------------------------------------------------------
//
// in following tests, we pass:
// (1) an array of local elements and their version (:1, :2...)
// (2) an array of remote elements and their version (:1, :2...)
// (3) expected reconciled elements
//
// in the reconciled array:
// :L means local element was resolved
// :R means remote element was resolved
//
// if a remote element is prefixed with parentheses, the enclosed string:
// (^) means the element is the first element in the array
// (<id>) means the element is preceded by <id> element
//
// if versions are missing, it defaults to version 0
// -------------------------------------------------------------------------
// non-annotated elements
// -------------------------------------------------------------------------
// usually when we sync elements they should always be annotated with
// their (preceding elements) parents, but let's test a couple of cases when
// they're not for whatever reason (remote clients are on older version...),
// in which case the first synced element either replaces existing element
// or is pushed at the end of the array
test(["A:1", "B:1", "C:1"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "A:2"], ["B:R", "A:R", "C:L"]);
test(["A:2", "B:1", "C"], ["B:2", "A:1"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1"], ["C:1"], ["A:L", "B:L", "C:R"]);
test(["A", "B"], ["A:1"], ["A:R", "B:L"]);
test(["A"], ["A", "B"], ["A:L", "B:R"]);
test(["A"], ["A:1", "B"], ["A:R", "B:R"]);
test(["A:2"], ["A:1", "B"], ["A:L", "B:R"]);
test(["A:2"], ["B", "A:1"], ["A:L", "B:R"]);
test(["A:1"], ["B", "A:2"], ["B:R", "A:R"]);
test(["A"], ["A:1"], ["A:R"]);
// C isn't added to the end because it follows B (even if B was resolved
// to local version)
test(["A", "B:1", "D"], ["B", "C:2", "A"], ["B:L", "C:R", "A:R", "D:L"]);
// some of the following tests are kinda arbitrary and they're less
// likely to happen in real-world cases
test(["A", "B"], ["B:1", "A:1"], ["B:R", "A:R"]);
test(["A:2", "B:2"], ["B:1", "A:1"], ["A:L", "B:L"]);
test(["A", "B", "C"], ["A", "B:2", "G", "C"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(["A", "B", "C"], ["A", "B:2", "G"], ["A:L", "B:R", "G:R", "C:L"]);
test(
["A:2", "B:2", "C"],
["D", "B:1", "A:3"],
["B:L", "A:R", "C:L", "D:R"],
);
test(
["A:2", "B:2", "C"],
["D", "B:2", "A:3", "C"],
["D:R", "B:L", "A:R", "C:L"],
);
test(
["A", "B", "C", "D", "E", "F"],
["A", "B:2", "X", "E:2", "F", "Y"],
["A:L", "B:R", "X:R", "E:R", "F:L", "Y:R", "C:L", "D:L"],
);
// annotated elements
// -------------------------------------------------------------------------
test(
["A", "B", "C"],
["(B)X", "(A)Y", "(Y)Z"],
["A:L", "B:L", "X:R", "Y:R", "Z:R", "C:L"],
);
test(["A"], ["(^)X", "Y"], ["X:R", "Y:R", "A:L"]);
test(["A"], ["(^)X", "Y", "Z"], ["X:R", "Y:R", "Z:R", "A:L"]);
test(
["A", "B"],
["(A)C", "(^)D", "F"],
["A:L", "C:R", "D:R", "F:R", "B:L"],
);
test(
["A", "B", "C", "D"],
["(B)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "A:L", "Y:R", "B:L", "Z:R", "C:L"],
);
test(
["B", "A", "C"],
["(^)X", "(A)Y", "(B)Z"],
["X:R", "B:L", "A:L", "Y:R", "Z:R", "C:L"],
);
test(["A", "B"], ["(A)X", "(A)Y"], ["A:L", "X:R", "Y:R", "B:L"]);
test(
["A", "B", "C", "D", "E"],
["(A)X", "(C)Y", "(D)Z"],
["A:L", "X:R", "B:L", "C:L", "Y:R", "D:L", "Z:R", "E:L"],
);
test(
["X", "Y", "Z"],
["(^)A", "(A)B", "(B)C", "(C)X", "(X)D", "(D)Y", "(Y)Z"],
["A:R", "B:R", "C:R", "X:L", "D:R", "Y:L", "Z:L"],
);
test(
["A", "B", "C", "D", "E"],
["(C)X", "(A)Y", "(D)E:1"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "D:L", "E:R"],
);
test(
["C:1", "B", "D:1"],
["A", "B", "C:1", "D:1"],
["A:R", "B:L", "C:L", "D:L"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["A", "B", "C", "D"],
["(A)C:1", "(C)B", "(B)D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(
["C:1", "B", "D:1"],
["(^)A", "(A)B", "(B)C:2", "(C)D:1"],
["A:R", "B:L", "C:R", "D:L"],
);
test(
["A", "B", "C", "D"],
["(C)X", "(B)Y", "(A)Z"],
["A:L", "B:L", "C:L", "X:R", "Y:R", "Z:R", "D:L"],
);
test(["A", "B", "C", "D"], ["(A)B:1", "C:1"], ["A:L", "B:R", "C:R", "D:L"]);
test(["A", "B", "C", "D"], ["(A)C:1", "B:1"], ["A:L", "C:R", "B:R", "D:L"]);
test(
["A", "B", "C", "D"],
["(A)C:1", "B", "D:1"],
["A:L", "C:R", "B:L", "D:R"],
);
test(["A:1", "B:1", "C"], ["B:2"], ["A:L", "B:R", "C:L"]);
test(["A:1", "B:1", "C"], ["B:2", "C:2"], ["A:L", "B:R", "C:R"]);
test(["A", "B"], ["(A)C", "(B)D"], ["A:L", "C:R", "B:L", "D:R"]);
test(["A", "B"], ["(X)C", "(X)D"], ["A:L", "B:L", "C:R", "D:R"]);
test(["A", "B"], ["(X)C", "(A)D"], ["A:L", "D:R", "B:L", "C:R"]);
test(["A", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B"], ["(A)B:1"], ["A:L", "B:R"]);
test(["A:2", "B:2"], ["B:1"], ["A:L", "B:L"]);
test(["A:2", "B:2"], ["B:1", "C"], ["A:L", "B:L", "C:R"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
test(["A:2", "B:2"], ["(A)C", "B:1"], ["A:L", "C:R", "B:L"]);
});
it("test identical elements reconciliation", () => {
const testIdentical = (
local: ElementLike[],
remote: ElementLike[],
expected: Id[],
) => {
const ret = reconcileElements(
local as any as ExcalidrawElement[],
remote as any as ExcalidrawElement[],
{} as AppState,
);
if (new Set(ret.map((x) => x.id)).size !== ret.length) {
throw new Error("reconcileElements: duplicate elements found");
}
expect(ret.map((x) => x.id)).to.deep.equal(expected);
};
// identical id/version/versionNonce
// -------------------------------------------------------------------------
testIdentical(
[{ id: "A", version: 1, versionNonce: 1 }],
[{ id: "A", version: 1, versionNonce: 1 }],
["A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
testIdentical(
[
{ id: "A", version: 1, versionNonce: 1 },
{ id: "B", version: 1, versionNonce: 1 },
],
[
{ id: "B", version: 1, versionNonce: 1 },
{ id: "A", version: 1, versionNonce: 1 },
],
["B", "A"],
);
// actually identical (arrays and element objects)
// -------------------------------------------------------------------------
const elements1 = [
{
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
{
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
},
];
testIdentical(elements1, elements1, ["A", "B"]);
testIdentical(elements1, elements1.slice(), ["A", "B"]);
testIdentical(elements1.slice(), elements1, ["A", "B"]);
testIdentical(elements1.slice(), elements1.slice(), ["A", "B"]);
const el1 = {
id: "A",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
const el2 = {
id: "B",
version: 1,
versionNonce: 1,
[PRECEDING_ELEMENT_KEY]: null,
};
testIdentical([el1, el2], [el2, el1], ["A", "B"]);
});
});
-70
View File
@@ -1,70 +0,0 @@
import { atom, useAtom } from "jotai";
import { useEffect, useLayoutEffect, useState } from "react";
import { THEME } from "../packages/excalidraw";
import { EVENT } from "../packages/excalidraw/constants";
import { Theme } from "../packages/excalidraw/element/types";
import { CODES, KEYS } from "../packages/excalidraw/keys";
import { STORAGE_KEYS } from "./app_constants";
export const appThemeAtom = atom<Theme | "system">(
(localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
| Theme
| "system"
| null) || THEME.LIGHT,
);
const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
window.matchMedia?.("(prefers-color-scheme: dark)");
export const useHandleAppTheme = () => {
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
useEffect(() => {
const mediaQuery = getDarkThemeMediaQuery();
const handleChange = (e: MediaQueryListEvent) => {
setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
};
if (appTheme === "system") {
mediaQuery?.addEventListener("change", handleChange);
}
const handleKeydown = (event: KeyboardEvent) => {
if (
!event[KEYS.CTRL_OR_CMD] &&
event.altKey &&
event.shiftKey &&
event.code === CODES.D
) {
event.preventDefault();
event.stopImmediatePropagation();
setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
return () => {
mediaQuery?.removeEventListener("change", handleChange);
document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
capture: true,
});
};
}, [appTheme, editorTheme, setAppTheme]);
useLayoutEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
if (appTheme === "system") {
setEditorTheme(
getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
);
} else {
setEditorTheme(appTheme);
}
}, [appTheme]);
return { editorTheme };
};
+4 -4
View File
@@ -26,8 +26,8 @@
"@types/chai": "4.3.0",
"@types/jest": "27.4.0",
"@types/lodash.throttle": "4.1.7",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/react": "18.0.15",
"@types/react-dom": "18.0.6",
"@types/socket.io-client": "3.0.0",
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-v8": "0.33.0",
@@ -50,11 +50,11 @@
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.5.3",
"vitest": "1.0.1",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
"node": "18.0.0 - 22.x.x"
"node": "18.0.0 - 20.x.x"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",
+2 -14
View File
@@ -15,14 +15,8 @@ Please add the latest change on the top under the correct section.
### Features
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
- Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638).
@@ -35,14 +29,6 @@ Please add the latest change on the top under the correct section.
### Breaking Changes
- `updateScene` API has changed due to the added `Store` component as part of the multiplayer undo / redo initiative. Specifically, `sceneData` property `commitToHistory: boolean` was replaced with `storeAction: StoreActionType`. Make sure to update all instances of `updateScene` according to the _before / after_ table below. [#7898](https://github.com/excalidraw/excalidraw/pull/7898)
| | Before `commitToHistory` | After `storeAction` | Notes |
| --- | --- | --- | --- |
| _Immediately undoable_ | `true` | `"capture"` | As before, use for all updates which should be recorded by the store & history. Should be used for the most of the local updates. These updates will _immediately_ make it to the local undo / redo stacks. |
| _Eventually undoable_ | `false` | `"none"` | Similar to before, use for all updates which should not be recorded immediately (likely exceptions which are part of some async multi-step process) or those not meant to be recorded at all (i.e. updates to `collaborators` object, parts of `AppState` which are not observed by the store & history - not `ObservedAppState`).<br/><br/>**IMPORTANT** It's likely you should switch to `"update"` in all the other cases. Otherwise, all such updates would end up being recorded with the next `"capture"` - triggered either by the next `updateScene` or internally by the editor. These updates will _eventually_ make it to the local undo / redo stacks. |
| _Never undoable_ | n/a | `"update"` | **NEW**: previously there was no equivalent for this value. Now, it's recommended to use `"update"` for all remote updates (from the other clients), scene initialization, or those updates, which should not be locally "undoable". These updates will _never_ make it to the local undo / redo stacks. |
- `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539)
- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties.
@@ -105,6 +91,8 @@ define: {
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
## 0.17.0 (2023-11-14)
### Features
@@ -3,7 +3,6 @@ import { deepCopyElement } from "../element/newElement";
import { randomId } from "../random";
import { t } from "../i18n";
import { LIBRARY_DISABLED_TYPES } from "../constants";
import { StoreAction } from "../store";
export const actionAddToLibrary = register({
name: "addToLibrary",
@@ -18,7 +17,7 @@ export const actionAddToLibrary = register({
for (const type of LIBRARY_DISABLED_TYPES) {
if (selectedElements.some((element) => element.type === type)) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t(`errors.libraryElementTypeError.${type}`),
@@ -42,7 +41,7 @@ export const actionAddToLibrary = register({
})
.then(() => {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
toast: { message: t("toast.addedToLibrary") },
@@ -51,7 +50,7 @@ export const actionAddToLibrary = register({
})
.catch((error) => {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
+6 -7
View File
@@ -15,7 +15,6 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import { AppClassProperties, AppState, UIAppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
@@ -71,7 +70,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -104,7 +103,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -137,7 +136,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -170,7 +169,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -203,7 +202,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -232,7 +231,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@@ -31,10 +31,8 @@ import {
} from "../element/types";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils";
import { getFontString } from "../utils";
import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionUnbindText = register({
name: "unbindText",
@@ -86,7 +84,7 @@ export const actionUnbindText = register({
return {
elements,
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@@ -162,7 +160,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@@ -182,8 +180,6 @@ const pushTextAboveContainer = (
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
@@ -202,8 +198,6 @@ const pushContainerBelowText = (
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
@@ -310,7 +304,6 @@ export const actionWrapTextInContainer = register({
container,
textElement,
);
containerIds[container.id] = true;
}
}
@@ -321,7 +314,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
+15 -28
View File
@@ -10,13 +10,7 @@ import {
ZoomResetIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
@@ -37,7 +31,6 @@ import {
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@@ -53,9 +46,7 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.viewBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@@ -111,7 +102,7 @@ export const actionClearCanvas = register({
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@@ -136,17 +127,16 @@ export const actionZoomIn = register({
),
userToFollow: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
className="zoom-in-button zoom-button"
icon={ZoomInIcon}
title={`${t("buttons.zoomIn")}${getShortcutKey("CtrlOrCmd++")}`}
aria-label={t("buttons.zoomIn")}
disabled={appState.zoom.value >= MAX_ZOOM}
onClick={() => {
updateData(null);
}}
@@ -177,17 +167,16 @@ export const actionZoomOut = register({
),
userToFollow: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
className="zoom-out-button zoom-button"
icon={ZoomOutIcon}
title={`${t("buttons.zoomOut")}${getShortcutKey("CtrlOrCmd+-")}`}
aria-label={t("buttons.zoomOut")}
disabled={appState.zoom.value <= MIN_ZOOM}
onClick={() => {
updateData(null);
}}
@@ -218,7 +207,7 @@ export const actionResetZoom = register({
),
userToFollow: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
@@ -293,8 +282,8 @@ export const zoomToFitBounds = ({
// Apply clamping to newZoomValue to be between 10% and 3000%
newZoomValue = Math.min(
Math.max(newZoomValue, MIN_ZOOM),
MAX_ZOOM,
Math.max(newZoomValue, 0.1),
30.0,
) as NormalizedZoomValue;
let appStateWidth = appState.width;
@@ -339,7 +328,7 @@ export const zoomToFitBounds = ({
scrollY,
zoom: { value: newZoomValue },
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
};
@@ -443,9 +432,7 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK
? "buttons.lightMode"
: "buttons.darkMode";
return appState.theme === "dark" ? "buttons.lightMode" : "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
@@ -458,7 +445,7 @@ export const actionToggleTheme = register({
theme:
value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
@@ -496,7 +483,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
@@ -535,7 +522,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+13 -14
View File
@@ -14,7 +14,6 @@ import { isTextElement } from "../element";
import { t } from "../i18n";
import { isFirefox } from "../constants";
import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionCopy = register({
name: "copy",
@@ -32,7 +31,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
@@ -41,7 +40,7 @@ export const actionCopy = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
// don't supply a shortcut since we handle this conditionally via onCopy event
@@ -67,7 +66,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@@ -76,7 +75,7 @@ export const actionPaste = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@@ -89,7 +88,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@@ -98,7 +97,7 @@ export const actionPaste = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
// don't supply a shortcut since we handle this conditionally via onCopy event
@@ -125,7 +124,7 @@ export const actionCopyAsSvg = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
@@ -148,7 +147,7 @@ export const actionCopyAsSvg = register({
},
);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
} catch (error: any) {
console.error(error);
@@ -157,7 +156,7 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@@ -175,7 +174,7 @@ export const actionCopyAsPng = register({
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
@@ -209,7 +208,7 @@ export const actionCopyAsPng = register({
}),
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
} catch (error: any) {
console.error(error);
@@ -218,7 +217,7 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@@ -253,7 +252,7 @@ export const copyText = register({
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) => {
@@ -13,7 +13,6 @@ import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
@@ -113,7 +112,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
}
@@ -145,7 +144,7 @@ export const actionDeleteSelected = register({
: [0],
},
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
let { elements: nextElements, appState: nextAppState } =
@@ -165,12 +164,10 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
storeAction: isSomeElementSelected(
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)
? StoreAction.CAPTURE
: StoreAction.NONE,
),
};
},
keyTest: (event, appState, elements) =>
@@ -11,7 +11,6 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
@@ -59,7 +58,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -90,7 +89,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -31,8 +31,6 @@ import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
@@ -55,13 +53,13 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
return {
...duplicateElements(elements, appState),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
@@ -92,7 +90,6 @@ const duplicateElements = (
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
@@ -104,7 +101,6 @@ const duplicateElements = (
y: element.y + GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
@@ -242,10 +238,8 @@ const duplicateElements = (
}
// step (3)
const finalElements = syncMovedIndices(
finalElementsReversed.reverse(),
arrayToMap(newElements),
);
const finalElements = finalElementsReversed.reverse();
// ---------------------------------------------------------------------------
@@ -4,7 +4,6 @@ import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { arrayToMap } from "../utils";
import { register } from "./register";
@@ -67,7 +66,7 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event, appState, elements, app) => {
@@ -112,7 +111,7 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
label: "labels.elementLock.unlockAll",
+11 -15
View File
@@ -19,17 +19,13 @@ import { nativeFileSystemSupported } from "../data/filesystem";
import { Theme } from "../element/types";
import "../components/ToolIcon.scss";
import { StoreAction } from "../store";
export const actionChangeProjectName = register({
name: "changeProjectName",
label: "labels.fileTitle",
trackEvent: false,
perform: (_elements, appState, value) => {
return {
appState: { ...appState, name: value },
storeAction: StoreAction.NONE,
};
return { appState: { ...appState, name: value }, commitToHistory: false };
},
PanelComponent: ({ appState, updateData, appProps, data, app }) => (
<ProjectName
@@ -48,7 +44,7 @@ export const actionChangeExportScale = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@@ -98,7 +94,7 @@ export const actionChangeExportBackground = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
@@ -118,7 +114,7 @@ export const actionChangeExportEmbedScene = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
@@ -160,7 +156,7 @@ export const actionSaveToActiveFile = register({
: await saveAsJSON(elements, appState, app.files, app.getName());
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
fileHandle,
@@ -182,7 +178,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { storeAction: StoreAction.NONE };
return { commitToHistory: false };
}
},
keyTest: (event) =>
@@ -207,7 +203,7 @@ export const actionSaveFileToDisk = register({
app.getName(),
);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
openDialog: null,
@@ -221,7 +217,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { storeAction: StoreAction.NONE };
return { commitToHistory: false };
}
},
keyTest: (event) =>
@@ -260,7 +256,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@@ -271,7 +267,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@@ -285,7 +281,7 @@ export const actionExportWithDarkMode = register({
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
+16 -11
View File
@@ -8,6 +8,7 @@ import { register } from "./register";
import { mutateElement } from "../element/mutateElement";
import { isPathALoop } from "../math";
import { LinearElementEditor } from "../element/linearElementEditor";
import Scene from "../scene/Scene";
import {
maybeBindLinearElement,
bindOrUnbindLinearElement,
@@ -15,15 +16,17 @@ import {
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import { AppState } from "../types";
import { resetCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionFinalize = register({
name: "finalize",
label: "",
trackEvent: false,
perform: (elements, appState, _, app) => {
const { interactiveCanvas, focusContainer, scene } = app;
perform: (
elements,
appState,
_,
{ interactiveCanvas, focusContainer, scene },
) => {
const elementsMap = scene.getNonDeletedElementsMap();
if (appState.editingLinearElement) {
@@ -49,9 +52,8 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
}
@@ -92,9 +94,7 @@ export const actionFinalize = register({
});
}
}
if (isInvisiblySmallElement(multiPointElement)) {
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
newElements = newElements.filter(
(el) => el.id !== multiPointElement.id,
);
@@ -131,7 +131,13 @@ export const actionFinalize = register({
-1,
arrayToMap(elements),
);
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
maybeBindLinearElement(
multiPointElement,
appState,
Scene.getScene(multiPointElement)!,
{ x, y },
elementsMap,
);
}
}
@@ -190,8 +196,7 @@ export const actionFinalize = register({
: appState.selectedLinearElement,
pendingImageElementId: null,
},
// TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit
storeAction: StoreAction.CAPTURE,
commitToHistory: appState.activeTool.type === "freedraw",
};
},
keyTest: (event, appState) =>
+10 -18
View File
@@ -7,18 +7,17 @@ import {
NonDeletedSceneElementsMap,
} from "../element/types";
import { resizeMultipleElements } from "../element/resizeElements";
import { AppClassProperties, AppState } from "../types";
import { AppState } from "../types";
import { arrayToMap } from "../utils";
import { CODES, KEYS } from "../keys";
import { getCommonBoundingBox } from "../element/bounds";
import {
bindOrUnbindLinearElements,
bindOrUnbindSelectedElements,
isBindingEnabled,
unbindLinearElements,
} from "../element/binding";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store";
import { isLinearElement } from "../element/typeChecks";
export const actionFlipHorizontal = register({
name: "flipHorizontal",
@@ -33,13 +32,12 @@ export const actionFlipHorizontal = register({
app.scene.getNonDeletedElementsMap(),
appState,
"horizontal",
app,
),
appState,
app,
),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event.shiftKey && event.code === CODES.H,
@@ -58,13 +56,12 @@ export const actionFlipVertical = register({
app.scene.getNonDeletedElementsMap(),
appState,
"vertical",
app,
),
appState,
app,
),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -76,7 +73,6 @@ const flipSelectedElements = (
elementsMap: NonDeletedSceneElementsMap,
appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
@@ -89,10 +85,10 @@ const flipSelectedElements = (
const updatedElements = flipElements(
selectedElements,
elements,
elementsMap,
appState,
flipDirection,
app,
);
const updatedElementsMap = arrayToMap(updatedElements);
@@ -104,10 +100,10 @@ const flipSelectedElements = (
const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
appState: AppState,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
@@ -117,17 +113,13 @@ const flipElements = (
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
app,
isBindingEnabled(appState),
[],
);
isBindingEnabled(appState)
? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap)
: unbindLinearElements(selectedElements, elementsMap);
return selectedElements;
};
+6 -7
View File
@@ -9,7 +9,6 @@ import { setCursorForShape } from "../cursor";
import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
import { frameToolIcon } from "../components/icons";
import { StoreAction } from "../store";
const isSingleFrameSelected = (
appState: UIAppState,
@@ -45,14 +44,14 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
}
return {
elements,
appState,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) =>
@@ -76,14 +75,14 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
return {
elements,
appState,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) =>
@@ -105,7 +104,7 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.frameRendering.enabled,
@@ -135,7 +134,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) =>
+13 -20
View File
@@ -17,11 +17,7 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import {
ExcalidrawElement,
ExcalidrawTextElement,
OrderedExcalidrawElement,
} from "../element/types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
@@ -31,8 +27,6 @@ import {
removeElementsFromFrame,
replaceAllElementsInFrame,
} from "../frame";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
if (elements.length >= 2) {
@@ -77,7 +71,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
@@ -97,7 +91,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
}
@@ -139,19 +133,18 @@ export const actionGroup = register({
// to the z order of the highest element in the layer stack
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
const lastGroupElementIndex = nextElements.lastIndexOf(
lastElementInGroup as OrderedExcalidrawElement,
);
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
const elementsBeforeGroup = nextElements
.slice(0, lastGroupElementIndex)
.filter(
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
);
const reorderedElements = syncMovedIndices(
[...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup],
arrayToMap(elementsInGroup),
);
nextElements = [
...elementsBeforeGroup,
...elementsInGroup,
...elementsAfterGroup,
];
return {
appState: {
@@ -162,8 +155,8 @@ export const actionGroup = register({
getNonDeletedElements(nextElements),
),
},
elements: reorderedElements,
storeAction: StoreAction.CAPTURE,
elements: nextElements,
commitToHistory: true,
};
},
predicate: (elements, appState, _, app) =>
@@ -193,7 +186,7 @@ export const actionUngroup = register({
const elementsMap = arrayToMap(elements);
if (groupIds.length === 0) {
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
let nextElements = [...elements];
@@ -266,7 +259,7 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+61 -68
View File
@@ -2,117 +2,110 @@ import { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { History, HistoryChangedEvent } from "../history";
import History, { HistoryEntry } from "../history";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { KEYS } from "../keys";
import { newElementWith } from "../element/mutateElement";
import { fixBindingsAfterDeletion } from "../element/binding";
import { arrayToMap } from "../utils";
import { isWindows } from "../constants";
import { SceneElementsMap } from "../element/types";
import { Store, StoreAction } from "../store";
import { useEmitter } from "../hooks/useEmitter";
const writeData = (
appState: Readonly<AppState>,
updater: () => [SceneElementsMap, AppState] | void,
prevElements: readonly ExcalidrawElement[],
appState: AppState,
updater: () => HistoryEntry | null,
): ActionResult => {
const commitToHistory = false;
if (
!appState.multiElement &&
!appState.resizingElement &&
!appState.editingElement &&
!appState.draggingElement
) {
const result = updater();
if (!result) {
return { storeAction: StoreAction.NONE };
const data = updater();
if (data === null) {
return { commitToHistory };
}
const [nextElementsMap, nextAppState] = result;
const nextElements = Array.from(nextElementsMap.values());
const prevElementMap = arrayToMap(prevElements);
const nextElements = data.elements;
const nextElementMap = arrayToMap(nextElements);
const deletedElements = prevElements.filter(
(prevElement) => !nextElementMap.has(prevElement.id),
);
const elements = nextElements
.map((nextElement) =>
newElementWith(
prevElementMap.get(nextElement.id) || nextElement,
nextElement,
),
)
.concat(
deletedElements.map((prevElement) =>
newElementWith(prevElement, { isDeleted: true }),
),
);
fixBindingsAfterDeletion(elements, deletedElements);
return {
appState: nextAppState,
elements: nextElements,
storeAction: StoreAction.UPDATE,
elements,
appState: { ...appState, ...data.appState },
commitToHistory,
syncHistory: true,
};
}
return { storeAction: StoreAction.NONE };
return { commitToHistory };
};
type ActionCreator = (history: History, store: Store) => Action;
type ActionCreator = (history: History) => Action;
export const createUndoAction: ActionCreator = (history, store) => ({
export const createUndoAction: ActionCreator = (history) => ({
name: "undo",
label: "buttons.undo",
icon: UndoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
),
writeData(elements, appState, () => history.undoOnce()),
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData, data }) => {
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
);
return (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isUndoStackEmpty}
/>
);
},
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
});
export const createRedoAction: ActionCreator = (history, store) => ({
export const createRedoAction: ActionCreator = (history) => ({
name: "redo",
label: "buttons.redo",
icon: RedoIcon,
trackEvent: { category: "history" },
viewMode: false,
perform: (elements, appState) =>
writeData(appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
),
),
writeData(elements, appState, () => history.redoOnce()),
keyTest: (event) =>
(event[KEYS.CTRL_OR_CMD] &&
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData, data }) => {
const { isRedoStackEmpty } = useEmitter(
history.onHistoryChangedEmitter,
new HistoryChangedEvent(),
);
return (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isRedoStackEmpty}
/>
);
},
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
});
@@ -2,7 +2,6 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette"
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleLinearEditor = register({
@@ -42,7 +41,7 @@ export const actionToggleLinearEditor = register({
...appState,
editingLinearElement,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
},
});
+1 -2
View File
@@ -5,7 +5,6 @@ import { isEmbeddableElement } from "../element/typeChecks";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { getShortcutKey } from "../utils";
import { register } from "./register";
@@ -25,7 +24,7 @@ export const actionLink = register({
showHyperlinkPopup: "editor",
openMenu: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
trackEvent: { category: "hyperlink", action: "click" },
+3 -4
View File
@@ -4,7 +4,6 @@ import { t } from "../i18n";
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
import { register } from "./register";
import { KEYS } from "../keys";
import { StoreAction } from "../store";
export const actionToggleCanvasMenu = register({
name: "toggleCanvasMenu",
@@ -15,7 +14,7 @@ export const actionToggleCanvasMenu = register({
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
storeAction: StoreAction.NONE,
commitToHistory: false,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@@ -37,7 +36,7 @@ export const actionToggleEditMenu = register({
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
storeAction: StoreAction.NONE,
commitToHistory: false,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@@ -74,7 +73,7 @@ export const actionShortcuts = register({
name: "help",
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
@@ -7,7 +7,6 @@ import {
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { StoreAction } from "../store";
import { Collaborator } from "../types";
import { register } from "./register";
import clsx from "clsx";
@@ -28,7 +27,7 @@ export const actionGoToCollaborator = register({
...appState,
userToFollow: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
@@ -42,7 +41,7 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, data, appState }) => {
@@ -96,7 +96,6 @@ import {
import { hasStrokeColor } from "../scene/comparisons";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
import { StoreAction } from "../store";
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
@@ -232,7 +231,7 @@ const changeFontSize = (
? [...newFontSizes][0]
: fallbackValue ?? appState.currentItemFontSize,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
};
@@ -262,9 +261,7 @@ export const actionChangeStrokeColor = register({
...appState,
...value,
},
storeAction: !!value.currentItemStrokeColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.currentItemStrokeColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -308,9 +305,7 @@ export const actionChangeBackgroundColor = register({
...appState,
...value,
},
storeAction: !!value.currentItemBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.currentItemBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => (
@@ -354,7 +349,7 @@ export const actionChangeFillStyle = register({
}),
),
appState: { ...appState, currentItemFillStyle: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -427,7 +422,7 @@ export const actionChangeStrokeWidth = register({
}),
),
appState: { ...appState, currentItemStrokeWidth: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -482,7 +477,7 @@ export const actionChangeSloppiness = register({
}),
),
appState: { ...appState, currentItemRoughness: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -533,7 +528,7 @@ export const actionChangeStrokeStyle = register({
}),
),
appState: { ...appState, currentItemStrokeStyle: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -588,7 +583,7 @@ export const actionChangeOpacity = register({
true,
),
appState: { ...appState, currentItemOpacity: value },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
@@ -763,7 +758,7 @@ export const actionChangeFontFamily = register({
...appState,
currentItemFontFamily: value,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@@ -864,7 +859,7 @@ export const actionChangeTextAlign = register({
...appState,
currentItemTextAlign: value,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@@ -954,7 +949,7 @@ export const actionChangeVerticalAlign = register({
appState: {
...appState,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => {
@@ -1035,7 +1030,7 @@ export const actionChangeRoundness = register({
...appState,
currentItemRoundness: value,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -1187,7 +1182,7 @@ export const actionChangeArrowhead = register({
? "currentItemStartArrowhead"
: "currentItemEndArrowhead"]: value.type,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => {
@@ -7,7 +7,6 @@ import { isLinearElement } from "../element/typeChecks";
import { LinearElementEditor } from "../element/linearElementEditor";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { selectAllIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionSelectAll = register({
name: "selectAll",
@@ -51,7 +50,7 @@ export const actionSelectAll = register({
? new LinearElementEditor(elements[0])
: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
+3 -4
View File
@@ -26,7 +26,6 @@ import {
import { getSelectedElements } from "../scene";
import { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
@@ -55,7 +54,7 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) =>
@@ -72,7 +71,7 @@ export const actionPasteStyles = register({
const pastedElement = elementsCopied[0];
const boundTextElement = elementsCopied[1];
if (!isExcalidrawElement(pastedElement)) {
return { elements, storeAction: StoreAction.NONE };
return { elements, commitToHistory: false };
}
const selectedElements = getSelectedElements(elements, appState, {
@@ -161,7 +160,7 @@ export const actionPasteStyles = register({
}
return element;
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -2,14 +2,10 @@ import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import { AppState } from "../types";
import { gridIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleGridMode = register({
name: "gridMode",
icon: gridIcon,
keywords: ["snap"],
label: "labels.toggleGrid",
label: "labels.showGrid",
viewMode: true,
trackEvent: {
category: "canvas",
@@ -22,7 +18,7 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
@@ -1,6 +1,5 @@
import { magnetIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
@@ -19,7 +18,7 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
@@ -1,7 +1,6 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { abacusIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleStats = register({
name: "stats",
@@ -16,7 +15,7 @@ export const actionToggleStats = register({
...appState,
showStats: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.showStats,
@@ -1,6 +1,5 @@
import { eyeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleViewMode = register({
@@ -19,7 +18,7 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
@@ -1,6 +1,5 @@
import { coffeeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleZenMode = register({
@@ -19,7 +18,7 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
+5 -5
View File
@@ -1,3 +1,4 @@
import React from "react";
import {
moveOneLeft,
moveOneRight,
@@ -15,7 +16,6 @@ import {
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
import { StoreAction } from "../store";
export const actionSendBackward = register({
name: "sendBackward",
@@ -26,7 +26,7 @@ export const actionSendBackward = register({
return {
elements: moveOneLeft(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyPriority: 40,
@@ -55,7 +55,7 @@ export const actionBringForward = register({
return {
elements: moveOneRight(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyPriority: 40,
@@ -84,7 +84,7 @@ export const actionSendToBack = register({
return {
elements: moveAllLeft(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@@ -121,7 +121,7 @@ export const actionBringToFront = register({
return {
elements: moveAllRight(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
+3 -3
View File
@@ -7,7 +7,7 @@ import {
PanelComponentProps,
ActionSource,
} from "./types";
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { trackEvent } from "../analytics";
import { isPromiseLike } from "../utils";
@@ -46,13 +46,13 @@ export class ActionManager {
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
getAppState: () => Readonly<AppState>;
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[];
getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
app: AppClassProperties;
constructor(
updater: UpdaterFn,
getAppState: () => AppState,
getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[],
getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
app: AppClassProperties,
) {
this.updater = (actionResult) => {
+4 -4
View File
@@ -1,5 +1,5 @@
import React from "react";
import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import {
AppClassProperties,
AppState,
@@ -8,7 +8,6 @@ import {
UIAppState,
} from "../types";
import { MarkOptional } from "../utility-types";
import { StoreActionType } from "../store";
export type ActionSource =
| "ui"
@@ -26,13 +25,14 @@ export type ActionResult =
"offsetTop" | "offsetLeft" | "width" | "height"
> | null;
files?: BinaryFiles | null;
storeAction: StoreActionType;
commitToHistory: boolean;
syncHistory?: boolean;
replaceFiles?: boolean;
}
| false;
type ActionFn = (
elements: readonly OrderedExcalidrawElement[],
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
formData: any,
app: AppClassProperties,
+1 -1
View File
@@ -1,6 +1,6 @@
// place here categories that you want to track. We want to track just a
// small subset of categories at a given time.
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,6 @@
font-size: 0.875rem !important;
width: var(--lg-button-size);
height: var(--lg-button-size);
svg {
width: var(--lg-icon-size) !important;
height: var(--lg-icon-size) !important;
File diff suppressed because it is too large Load Diff
@@ -49,8 +49,6 @@ import { jotaiStore } from "../../jotai";
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
import { CommandPaletteItem } from "./types";
import * as defaultItems from "./defaultCommandPaletteItems";
import { trackEvent } from "../../analytics";
import { useStable } from "../../hooks/useStable";
import "./CommandPalette.scss";
@@ -132,20 +130,12 @@ export const CommandPalette = Object.assign(
if (isCommandPaletteToggleShortcut(event)) {
event.preventDefault();
event.stopPropagation();
setAppState((appState) => {
const nextState =
setAppState((appState) => ({
openDialog:
appState.openDialog?.name === "commandPalette"
? null
: ({ name: "commandPalette" } as const);
if (nextState) {
trackEvent("command_palette", "open", "shortcut");
}
return {
openDialog: nextState,
};
});
: { name: "commandPalette" },
}));
}
};
window.addEventListener(EVENT.KEYDOWN, commandPaletteShortcut, {
@@ -184,20 +174,10 @@ function CommandPaletteInner({
const inputRef = useRef<HTMLInputElement>(null);
const stableDeps = useStable({
uiAppState,
customCommandPaletteItems,
appProps,
});
useEffect(() => {
// these props change often and we don't want them to re-run the effect
// which would renew `allCommands`, cascading down and resetting state.
//
// This means that the commands won't update on appState/appProps changes
// while the command palette is open
const { uiAppState, customCommandPaletteItems, appProps } = stableDeps;
if (!uiAppState || !app.scene || !actionManager) {
return;
}
const getActionLabel = (action: Action) => {
let label = "";
if (action.label) {
@@ -309,7 +289,6 @@ function CommandPaletteInner({
actionManager.actions.zoomToFit,
actionManager.actions.zenMode,
actionManager.actions.viewMode,
actionManager.actions.gridMode,
actionManager.actions.objectsSnapMode,
actionManager.actions.toggleShortcuts,
actionManager.actions.selectAll,
@@ -554,13 +533,15 @@ function CommandPaletteInner({
);
}
}, [
stableDeps,
app,
appProps,
uiAppState,
actionManager,
setAllCommands,
lastUsed?.label,
setLastUsed,
setAppState,
customCommandPaletteItems,
]);
const [commandSearch, setCommandSearch] = useState("");
@@ -14,9 +14,7 @@ export const DarkModeToggle = (props: {
}) => {
const title =
props.title ||
(props.value === THEME.DARK
? t("buttons.lightMode")
: t("buttons.darkMode"));
(props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
return (
<ToolButton
+5 -14
View File
@@ -4,7 +4,7 @@ import { KEYS } from "../keys";
import { Dialog } from "./Dialog";
import { getShortcutKey } from "../utils";
import "./HelpDialog.scss";
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
import { ExternalLinkIcon } from "./icons";
import { probablySupportsClipboardBlob } from "../clipboard";
import { isDarwin, isFirefox, isWindows } from "../constants";
import { getShortcutFromShortcutName } from "../actions/shortcuts";
@@ -17,8 +17,8 @@ const Header = () => (
target="_blank"
rel="noopener noreferrer"
>
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.documentation")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a>
<a
className="HelpDialog__btn"
@@ -26,8 +26,8 @@ const Header = () => (
target="_blank"
rel="noopener noreferrer"
>
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
{t("helpDialog.blog")}
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a>
<a
className="HelpDialog__btn"
@@ -35,17 +35,8 @@ const Header = () => (
target="_blank"
rel="noopener noreferrer"
>
<div className="HelpDialog__link-icon">{GithubIcon}</div>
{t("helpDialog.github")}
</a>
<a
className="HelpDialog__btn"
href="https://youtube.com/@excalidraw"
target="_blank"
rel="noopener noreferrer"
>
<div className="HelpDialog__link-icon">{youtubeIcon}</div>
YouTube
<div className="HelpDialog__link-icon">{ExternalLinkIcon}</div>
</a>
</div>
);
@@ -273,7 +264,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
shortcuts={[getShortcutKey("Alt+S")]}
/>
<Shortcut
label={t("labels.toggleGrid")}
label={t("labels.showGrid")}
shortcuts={[getShortcutKey("CtrlOrCmd+'")]}
/>
<Shortcut
@@ -3,8 +3,7 @@ import "./RadioGroup.scss";
export type RadioGroupChoice<T> = {
value: T;
label: React.ReactNode;
ariaLabel?: string;
label: string;
};
export type RadioGroupProps<T> = {
@@ -27,15 +26,13 @@ export const RadioGroup = function <T>({
className={clsx("RadioGroup__choice", {
active: choice.value === value,
})}
key={String(choice.value)}
title={choice.ariaLabel}
key={choice.label}
>
<input
name={name}
type="radio"
checked={choice.value === value}
onChange={() => onChange(choice.value)}
aria-label={choice.ariaLabel}
/>
{choice.label}
</div>
+1 -3
View File
@@ -1,5 +1,3 @@
@import "../css/variables.module.scss";
.excalidraw {
.SVGLayer {
pointer-events: none;
@@ -9,7 +7,7 @@
top: 0;
left: 0;
z-index: var(--zIndex-svgLayer);
z-index: 2;
& svg {
image-rendering: auto;
@@ -18,7 +18,7 @@ import { TTDDialogInput } from "./TTDDialogInput";
import { TTDDialogOutput } from "./TTDDialogOutput";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { EDITOR_LS_KEYS } from "../../constants";
import { debounce, isDevEnv } from "../../utils";
import { debounce } from "../../utils";
import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
const MERMAID_EXAMPLE =
@@ -54,11 +54,7 @@ const MermaidToExcalidraw = ({
mermaidToExcalidrawLib,
setError,
mermaidDefinition: deferredText,
}).catch((err) => {
if (isDevEnv()) {
console.error("Failed to parse mermaid definition", err);
}
});
}).catch(() => {});
debouncedSaveMermaidDefinition(deferredText);
}, [deferredText, mermaidToExcalidrawLib]);
@@ -25,7 +25,6 @@ type ToolButtonBaseProps = {
hidden?: boolean;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
@@ -125,14 +124,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
type={type}
onClick={onClick}
ref={innerRef}
disabled={isLoading || props.isLoading || !!props.disabled}
disabled={isLoading || props.isLoading}
>
{(props.icon || props.label) && (
<div
className="ToolIcon__icon"
aria-hidden="true"
aria-disabled={!!props.disabled}
>
<div className="ToolIcon__icon" aria-hidden="true">
{props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
+2 -17
View File
@@ -77,7 +77,8 @@
}
.ToolIcon_type_button,
.Modal .ToolIcon_type_button {
.Modal .ToolIcon_type_button,
.ToolIcon_type_button {
padding: 0;
border: none;
margin: 0;
@@ -100,22 +101,6 @@
background-color: var(--button-gray-3);
}
&:disabled {
cursor: default;
&:active,
&:focus-visible,
&:hover {
background-color: initial;
border: none;
box-shadow: none;
}
svg {
color: var(--color-disabled);
}
}
&--show {
visibility: visible;
}
@@ -3,7 +3,7 @@ import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
import { CURSOR_TYPE } from "../../constants";
import { t } from "../../i18n";
import type { DOMAttributes } from "react";
import type { AppState, Device, InteractiveCanvasAppState } from "../../types";
import type { AppState, InteractiveCanvasAppState } from "../../types";
import type {
InteractiveCanvasRenderConfig,
RenderableElementsMap,
@@ -23,7 +23,6 @@ type InteractiveCanvasProps = {
selectionNonce: number | undefined;
scale: number;
appState: InteractiveCanvasAppState;
device: Device;
renderInteractiveSceneCallback: (
data: RenderInteractiveSceneCallback,
) => void;
@@ -133,7 +132,6 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
selectionColor,
renderScrollbars: false,
},
device: props.device,
callback: props.renderInteractiveSceneCallback,
},
isRenderThrottlingEnabled(),
@@ -75,12 +75,6 @@
&__shortcut {
margin-inline-start: auto;
opacity: 0.5;
&--orphaned {
text-align: right;
font-size: 0.875rem;
padding: 0 0.625rem;
}
}
&:hover {
@@ -100,22 +94,6 @@
}
}
.dropdown-menu-item-bare {
align-items: center;
height: 2rem;
justify-content: space-between;
@media screen and (min-width: 1921px) {
height: 2.25rem;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
.dropdown-menu-item-custom {
margin-top: 0.5rem;
}
@@ -1,51 +0,0 @@
import { useDevice } from "../App";
import { RadioGroup } from "../RadioGroup";
type Props<T> = {
value: T;
shortcut?: string;
choices: {
value: T;
label: React.ReactNode;
ariaLabel?: string;
}[];
onChange: (value: T) => void;
children: React.ReactNode;
name: string;
};
const DropdownMenuItemContentRadio = <T,>({
value,
shortcut,
onChange,
choices,
children,
name,
}: Props<T>) => {
const device = useDevice();
return (
<>
<div className="dropdown-menu-item-base dropdown-menu-item-bare">
<label className="dropdown-menu-item__text" htmlFor={name}>
{children}
</label>
<RadioGroup
name={name}
value={value}
onChange={onChange}
choices={choices}
/>
</div>
{shortcut && !device.editor.isMobile && (
<div className="dropdown-menu-item__shortcut dropdown-menu-item__shortcut--orphaned">
{shortcut}
</div>
)}
</>
);
};
DropdownMenuItemContentRadio.displayName = "DropdownMenuItemContentRadio";
export default DropdownMenuItemContentRadio;
@@ -26,9 +26,9 @@ import clsx from "clsx";
import { KEYS } from "../../keys";
import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
import { getElementAbsoluteCoords } from "../../element/bounds";
import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
import { getSelectedElements } from "../../scene";
import { hitElementBoundingBox } from "../../element/collision";
import { isPointHittingElementBoundingBox } from "../../element/collision";
import { isLocalLink, normalizeLink } from "../../data/url";
import "./Hyperlink.scss";
@@ -425,7 +425,15 @@ const shouldHideLinkPopup = (
const threshold = 15 / appState.zoom.value;
// hitbox to prevent hiding when hovered in element bounding box
if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
if (
isPointHittingElementBoundingBox(
element,
elementsMap,
[sceneX, sceneY],
threshold,
null,
)
) {
return false;
}
const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
@@ -1,6 +1,6 @@
import { MIME_TYPES } from "../../constants";
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
import { hitElementBoundingBox } from "../../element/collision";
import { isPointHittingElementBoundingBox } from "../../element/collision";
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
@@ -75,10 +75,17 @@ export const isPointHittingLink = (
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
hitElementBoundingBox(x, y, element, elementsMap)
isPointHittingElementBoundingBox(
element,
elementsMap,
[x, y],
threshold,
null,
)
) {
return true;
}
+7 -44
View File
@@ -433,10 +433,15 @@ export const MoonIcon = createIcon(
);
export const SunIcon = createIcon(
<g stroke="currentColor" strokeLinejoin="round">
<g
stroke="currentColor"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM10 4.167V2.5M14.167 5.833l1.166-1.166M15.833 10H17.5M14.167 14.167l1.166 1.166M10 15.833V17.5M5.833 14.167l-1.166 1.166M5 10H3.333M5.833 5.833 4.667 4.667" />
</g>,
{ ...modifiedTablerIconProps, strokeWidth: 1.5 },
modifiedTablerIconProps,
);
export const HamburgerMenuIcon = createIcon(
@@ -2087,45 +2092,3 @@ export const coffeeIcon = createIcon(
</g>,
tablerIconProps,
);
export const DeviceDesktopIcon = createIcon(
<g stroke="currentColor">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 5a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-10zM7 20h10M9 16v4M15 16v4" />
</g>,
{ ...tablerIconProps, strokeWidth: 1.5 },
);
// arrow-bar-to-left
export const arrowBarToLeftIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12l10 0" />
<path d="M10 12l4 4" />
<path d="M10 12l4 -4" />
<path d="M4 4l0 16" />
</g>,
tablerIconProps,
);
export const youtubeIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M2 8a4 4 0 0 1 4 -4h12a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-12a4 4 0 0 1 -4 -4v-8z" />
<path d="M10 9l5 3l-5 3z" />
</g>,
tablerIconProps,
);
export const gridIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 6h18" />
<path d="M3 12h18" />
<path d="M3 18h18" />
<path d="M6 3v18" />
<path d="M12 3v18" />
<path d="M18 3v18" />
</g>,
tablerIconProps,
);
@@ -8,7 +8,6 @@ import {
} from "../App";
import {
boltIcon,
DeviceDesktopIcon,
ExportIcon,
ExportImageIcon,
HelpIcon,
@@ -36,10 +35,6 @@ import { jotaiScope } from "../../jotai";
import { useUIAppState } from "../../context/ui-appState";
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
import Trans from "../Trans";
import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio";
import { THEME } from "../../constants";
import type { Theme } from "../../element/types";
import { trackEvent } from "../../analytics";
import "./DefaultItems.scss";
@@ -123,7 +118,7 @@ export const SaveAsImage = () => {
};
SaveAsImage.displayName = "SaveAsImage";
export const CommandPalette = (opts?: { className?: string }) => {
export const CommandPalette = () => {
const setAppState = useExcalidrawSetAppState();
const { t } = useI18n();
@@ -131,13 +126,9 @@ export const CommandPalette = (opts?: { className?: string }) => {
<DropdownMenuItem
icon={boltIcon}
data-testid="command-palette-button"
onSelect={() => {
trackEvent("command_palette", "open", "menu");
setAppState({ openDialog: { name: "commandPalette" } });
}}
onSelect={() => setAppState({ openDialog: { name: "commandPalette" } })}
shortcut={getShortcutFromShortcutName("commandPalette")}
aria-label={t("commandPalette.title")}
className={opts?.className}
>
{t("commandPalette.title")}
</DropdownMenuItem>
@@ -190,80 +181,32 @@ export const ClearCanvas = () => {
};
ClearCanvas.displayName = "ClearCanvas";
export const ToggleTheme = (
props:
| {
allowSystemTheme: true;
theme: Theme | "system";
onSelect: (theme: Theme | "system") => void;
}
| {
allowSystemTheme?: false;
onSelect?: (theme: Theme) => void;
},
) => {
export const ToggleTheme = () => {
const { t } = useI18n();
const appState = useUIAppState();
const actionManager = useExcalidrawActionManager();
const shortcut = getShortcutFromShortcutName("toggleTheme");
if (!actionManager.isActionEnabled(actionToggleTheme)) {
return null;
}
if (props?.allowSystemTheme) {
return (
<DropdownMenuItemContentRadio
name="theme"
value={props.theme}
onChange={(value: Theme | "system") => props.onSelect(value)}
choices={[
{
value: THEME.LIGHT,
label: SunIcon,
ariaLabel: `${t("buttons.lightMode")} - ${shortcut}`,
},
{
value: THEME.DARK,
label: MoonIcon,
ariaLabel: `${t("buttons.darkMode")} - ${shortcut}`,
},
{
value: "system",
label: DeviceDesktopIcon,
ariaLabel: t("buttons.systemMode"),
},
]}
>
{t("labels.theme")}
</DropdownMenuItemContentRadio>
);
}
return (
<DropdownMenuItem
onSelect={(event) => {
// do not close the menu when changing theme
event.preventDefault();
if (props?.onSelect) {
props.onSelect(
appState.theme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
} else {
return actionManager.executeAction(actionToggleTheme);
}
return actionManager.executeAction(actionToggleTheme);
}}
icon={appState.theme === THEME.DARK ? SunIcon : MoonIcon}
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
data-testid="toggle-dark-mode"
shortcut={shortcut}
shortcut={getShortcutFromShortcutName("toggleTheme")}
aria-label={
appState.theme === THEME.DARK
appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")
}
>
{appState.theme === THEME.DARK
{appState.theme === "dark"
? t("buttons.lightMode")
: t("buttons.darkMode")}
</DropdownMenuItem>
+4 -8
View File
@@ -148,13 +148,6 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING;
// a small epsilon to make side resizing always take precedence
// (avoids an increase in renders and changes to tests)
const EPSILON = 0.00001;
export const DEFAULT_COLLISION_THRESHOLD =
2 * SIDE_RESIZING_THRESHOLD - EPSILON;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS
@@ -217,7 +210,6 @@ export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1;
export const MIN_ZOOM = 0.1;
export const MAX_ZOOM = 30.0;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds
@@ -324,6 +316,10 @@ export const ROUNDNESS = {
ADAPTIVE_RADIUS: 3,
} as const;
/** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const ROUGHNESS = {
architect: 0,
artist: 1,
-1
View File
@@ -4,7 +4,6 @@
:root {
--zIndex-canvas: 1;
--zIndex-interactiveCanvas: 2;
--zIndex-svgLayer: 3;
--zIndex-wysiwyg: 3;
--zIndex-canvasButtons: 3;
--zIndex-layerUI: 4;
-4
View File
@@ -98,8 +98,6 @@
--color-gray-90: #1e1e1e;
--color-gray-100: #121212;
--color-disabled: var(--color-gray-40);
--color-warning: #fceeca;
--color-warning-dark: #f5c354;
--color-warning-darker: #f3ab2c;
@@ -210,8 +208,6 @@
--color-primary-light-darker: #43415e;
--color-primary-hover: #bbb8ff;
--color-disabled: var(--color-gray-70);
--color-text-warning: var(--color-gray-80);
--color-danger: #ffa8a5;
@@ -50,15 +50,6 @@
color: var(--color-on-primary-container);
}
}
&[aria-disabled="true"] {
background: initial;
border: none;
svg {
color: var(--color-disabled);
}
}
}
}
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,4 +1,3 @@
import { THEME } from "../constants";
import { Theme } from "../element/types";
import { DataURL } from "../types";
import { OpenAIInput, OpenAIOutput } from "./ai/types";
@@ -40,7 +39,7 @@ export async function diagramToHTML({
image,
apiKey,
text,
theme = THEME.LIGHT,
theme = "light",
}: {
image: DataURL;
apiKey: string;
-79
View File
@@ -1,79 +0,0 @@
import { OrderedExcalidrawElement } from "../element/types";
import { orderByFractionalIndex, syncInvalidIndices } from "../fractionalIndex";
import { AppState } from "../types";
import { MakeBrand } from "../utility-types";
import { arrayToMap } from "../utils";
export type ReconciledExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"ReconciledElement">;
export type RemoteExcalidrawElement = OrderedExcalidrawElement &
MakeBrand<"RemoteExcalidrawElement">;
const shouldDiscardRemoteElement = (
localAppState: AppState,
local: OrderedExcalidrawElement | undefined,
remote: RemoteExcalidrawElement,
): boolean => {
if (
local &&
// local element is being edited
(local.id === localAppState.editingElement?.id ||
local.id === localAppState.resizingElement?.id ||
local.id === localAppState.draggingElement?.id || // TODO: Is this still valid? As draggingElement is selection element, which is never part of the elements array
// local element is newer
local.version > remote.version ||
// resolve conflicting edits deterministically by taking the one with
// the lowest versionNonce
(local.version === remote.version &&
local.versionNonce < remote.versionNonce))
) {
return true;
}
return false;
};
export const reconcileElements = (
localElements: readonly OrderedExcalidrawElement[],
remoteElements: readonly RemoteExcalidrawElement[],
localAppState: AppState,
): ReconciledExcalidrawElement[] => {
const localElementsMap = arrayToMap(localElements);
const reconciledElements: OrderedExcalidrawElement[] = [];
const added = new Set<string>();
// process remote elements
for (const remoteElement of remoteElements) {
if (!added.has(remoteElement.id)) {
const localElement = localElementsMap.get(remoteElement.id);
const discardRemoteElement = shouldDiscardRemoteElement(
localAppState,
localElement,
remoteElement,
);
if (localElement && discardRemoteElement) {
reconciledElements.push(localElement);
added.add(localElement.id);
} else {
reconciledElements.push(remoteElement);
added.add(remoteElement.id);
}
}
}
// process remaining local elements
for (const localElement of localElements) {
if (!added.has(localElement.id)) {
reconciledElements.push(localElement);
added.add(localElement.id);
}
}
const orderedElements = orderByFractionalIndex(reconciledElements);
// de-duplicate indices
syncInvalidIndices(orderedElements);
return orderedElements as ReconciledExcalidrawElement[];
};
+31 -30
View File
@@ -4,7 +4,6 @@ import {
ExcalidrawSelectionElement,
ExcalidrawTextElement,
FontFamilyValues,
OrderedExcalidrawElement,
PointBinding,
StrokeRoundness,
} from "../element/types";
@@ -27,6 +26,7 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
DEFAULT_VERTICAL_ALIGN,
PRECEDING_ELEMENT_KEY,
FONT_FAMILY,
ROUNDNESS,
DEFAULT_SIDEBAR,
@@ -44,7 +44,6 @@ import {
getDefaultLineHeight,
} from "../element/textElement";
import { normalizeLink } from "./url";
import { syncInvalidIndices } from "../fractionalIndex";
type RestoredAppState = Omit<
AppState,
@@ -74,7 +73,7 @@ export const AllowedExcalidrawActiveTools: Record<
};
export type RestoredDataState = {
elements: OrderedExcalidrawElement[];
elements: ExcalidrawElement[];
appState: RestoredAppState;
files: BinaryFiles;
};
@@ -102,6 +101,8 @@ const restoreElementWithProperties = <
boundElementIds?: readonly ExcalidrawElement["id"][];
/** @deprecated */
strokeSharpness?: StrokeRoundness;
/** metadata that may be present in elements during collaboration */
[PRECEDING_ELEMENT_KEY]?: string;
},
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
>(
@@ -114,13 +115,14 @@ const restoreElementWithProperties = <
> &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => {
const base: Pick<T, keyof ExcalidrawElement> = {
const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string;
} = {
type: extra.type || element.type,
// all elements must have version > 0 so getSceneVersion() will pick up
// newly added elements
version: element.version || 1,
versionNonce: element.versionNonce ?? 0,
index: element.index ?? null,
isDeleted: element.isDeleted ?? false,
id: element.id || randomId(),
fillStyle: element.fillStyle || DEFAULT_ELEMENT_PROPS.fillStyle,
@@ -164,6 +166,10 @@ const restoreElementWithProperties = <
"customData" in extra ? extra.customData : element.customData;
}
if (PRECEDING_ELEMENT_KEY in element) {
base[PRECEDING_ELEMENT_KEY] = element[PRECEDING_ELEMENT_KEY];
}
return {
...base,
...getNormalizedDimensions(base),
@@ -294,7 +300,7 @@ const restoreElement = (
};
/**
* Repairs container element's boundElements array by removing duplicates and
* Repairs contaienr element's boundElements array by removing duplicates and
* fixing containerId of bound elements if not present. Also removes any
* bound elements that do not exist in the elements array.
*
@@ -401,35 +407,30 @@ export const restoreElements = (
/** NOTE doesn't serve for reconciliation */
localElements: readonly ExcalidrawElement[] | null | undefined,
opts?: { refreshDimensions?: boolean; repairBindings?: boolean } | undefined,
): OrderedExcalidrawElement[] => {
): ExcalidrawElement[] => {
// used to detect duplicate top-level element ids
const existingIds = new Set<string>();
const localElementsMap = localElements ? arrayToMap(localElements) : null;
const restoredElements = syncInvalidIndices(
(elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(
migratedElement,
localElement.version,
);
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
const restoredElements = (elements || []).reduce((elements, element) => {
// filtering out selection, which is legacy, no longer kept in elements,
// and causing issues if retained
if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
let migratedElement: ExcalidrawElement | null = restoreElement(element);
if (migratedElement) {
const localElement = localElementsMap?.get(element.id);
if (localElement && localElement.version > migratedElement.version) {
migratedElement = bumpVersion(migratedElement, localElement.version);
}
if (existingIds.has(migratedElement.id)) {
migratedElement = { ...migratedElement, id: randomId() };
}
existingIds.add(migratedElement.id);
elements.push(migratedElement);
}
return elements;
}, [] as ExcalidrawElement[]),
);
}
return elements;
}, [] as ExcalidrawElement[]);
if (!opts?.repairBindings) {
return restoredElements;
+37 -163
View File
@@ -152,14 +152,14 @@ describe("Test Transform", () => {
strokeStyle: "dotted",
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(4);
expect(excaldrawElements.length).toBe(4);
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -235,14 +235,14 @@ describe("Test Transform", () => {
},
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(12);
expect(excaldrawElements.length).toBe(12);
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -293,14 +293,14 @@ describe("Test Transform", () => {
},
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(8);
expect(excaldrawElements.length).toBe(8);
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -338,13 +338,13 @@ describe("Test Transform", () => {
name: "My frame",
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
expect(excalidrawElements.length).toBe(4);
expect(excaldrawElements.length).toBe(4);
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchObject({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -383,11 +383,11 @@ describe("Test Transform", () => {
height: 100,
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elementsSkeleton,
opts,
);
const frame = excalidrawElements.find((ele) => ele.type === "frame")!;
const frame = excaldrawElements.find((ele) => ele.type === "frame")!;
expect(frame.width).toBe(800);
expect(frame.height).toBe(126);
});
@@ -411,13 +411,13 @@ describe("Test Transform", () => {
},
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(4);
const [arrow, text, rectangle, ellipse] = excalidrawElements;
expect(excaldrawElements.length).toBe(4);
const [arrow, text, rectangle, ellipse] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
@@ -466,7 +466,7 @@ describe("Test Transform", () => {
],
});
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -495,13 +495,13 @@ describe("Test Transform", () => {
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excalidrawElements;
expect(excaldrawElements.length).toBe(4);
const [arrow, text1, text2, text3] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
@@ -551,7 +551,7 @@ describe("Test Transform", () => {
],
});
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -611,14 +611,14 @@ describe("Test Transform", () => {
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(5);
expect(excaldrawElements.length).toBe(5);
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -660,14 +660,14 @@ describe("Test Transform", () => {
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(4);
expect(excaldrawElements.length).toBe(4);
excalidrawElements.forEach((ele) => {
excaldrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
@@ -714,13 +714,13 @@ describe("Test Transform", () => {
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(4);
const [, , arrow, text] = excalidrawElements;
expect(excaldrawElements.length).toBe(4);
const [, , arrow, text] = excaldrawElements;
expect(arrow).toMatchObject({
type: "arrow",
x: 255,
@@ -765,12 +765,12 @@ describe("Test Transform", () => {
backgroundColor: "#bac8ff",
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(2);
const [arrow, rect] = excalidrawElements;
expect(excaldrawElements.length).toBe(2);
const [arrow, rect] = excaldrawElements;
expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
elementId: "rect-1",
focus: 0,
@@ -808,13 +808,13 @@ describe("Test Transform", () => {
height: 200,
},
];
const excalidrawElements = convertToExcalidrawElements(
const excaldrawElements = convertToExcalidrawElements(
elements as ExcalidrawElementSkeleton[],
opts,
);
expect(excalidrawElements.length).toBe(1);
expect(excalidrawElements[0]).toMatchSnapshot({
expect(excaldrawElements.length).toBe(1);
expect(excaldrawElements[0]).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
});
@@ -840,130 +840,4 @@ describe("Test Transform", () => {
createdBy: "user01",
});
});
it("should transform the elements correctly when linear elements have single point", () => {
const elements: ExcalidrawElementSkeleton[] = [
{
id: "B",
type: "rectangle",
groupIds: ["subgraph_group_B"],
x: 0,
y: 0,
width: 166.03125,
height: 163,
label: {
groupIds: ["subgraph_group_B"],
text: "B",
fontSize: 20,
verticalAlign: "top",
},
},
{
id: "A",
type: "rectangle",
groupIds: ["subgraph_group_A"],
x: 364.546875,
y: 0,
width: 120.265625,
height: 114,
label: {
groupIds: ["subgraph_group_A"],
text: "A",
fontSize: 20,
verticalAlign: "top",
},
},
{
id: "Alice",
type: "rectangle",
groupIds: ["subgraph_group_A"],
x: 389.546875,
y: 35,
width: 70.265625,
height: 44,
strokeWidth: 2,
label: {
groupIds: ["subgraph_group_A"],
text: "Alice",
fontSize: 20,
},
link: null,
},
{
id: "Bob",
type: "rectangle",
groupIds: ["subgraph_group_B"],
x: 54.76953125,
y: 35,
width: 56.4921875,
height: 44,
strokeWidth: 2,
label: {
groupIds: ["subgraph_group_B"],
text: "Bob",
fontSize: 20,
},
link: null,
},
{
id: "Bob_Alice",
type: "arrow",
groupIds: [],
x: 111.262,
y: 57,
strokeWidth: 2,
points: [
[0, 0],
[272.985, 0],
],
label: {
text: "How are you?",
fontSize: 20,
groupIds: [],
},
roundness: {
type: 2,
},
start: {
id: "Bob",
},
end: {
id: "Alice",
},
},
{
id: "Bob_B",
type: "arrow",
groupIds: [],
x: 77.017,
y: 79,
strokeWidth: 2,
points: [[0, 0]],
label: {
text: "Friendship",
fontSize: 20,
groupIds: [],
},
roundness: {
type: 2,
},
start: {
id: "Bob",
},
end: {
id: "B",
},
},
];
const excalidrawElements = convertToExcalidrawElements(elements, opts);
expect(excalidrawElements.length).toBe(12);
excalidrawElements.forEach((ele) => {
expect(ele).toMatchSnapshot({
seed: expect.any(Number),
versionNonce: expect.any(Number),
id: expect.any(String),
});
});
});
});
+3 -23
View File
@@ -44,16 +44,9 @@ import {
VerticalAlign,
} from "../element/types";
import { MarkOptional } from "../utility-types";
import {
arrayToMap,
assertNever,
cloneJSON,
getFontString,
toBrandedType,
} from "../utils";
import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils";
import { getSizeFromPoints } from "../points";
import { randomId } from "../random";
import { syncInvalidIndices } from "../fractionalIndex";
export type ValidLinearElement = {
type: "arrow" | "line";
@@ -405,21 +398,11 @@ const bindLinearElementToElement = (
}
}
// Safe check to early return for single point
if (linearElement.points.length < 2) {
return {
linearElement,
startBoundElement,
endBoundElement,
};
}
// Update start/end points by 0.5 so bindings don't overlap with start/end bound element coordinates.
const endPointIndex = linearElement.points.length - 1;
const delta = 0.5;
const newPoints = cloneJSON(linearElement.points) as [number, number][];
// left to right so shift the arrow towards right
if (
linearElement.points[endPointIndex][0] >
@@ -474,15 +457,12 @@ class ElementStore {
this.excalidrawElements.set(ele.id, ele);
};
getElements = () => {
return syncInvalidIndices(Array.from(this.excalidrawElements.values()));
return Array.from(this.excalidrawElements.values());
};
getElementsMap = () => {
return toBrandedType<NonDeletedSceneElementsMap>(
arrayToMap(this.getElements()),
);
return toBrandedType<NonDeletedSceneElementsMap>(this.excalidrawElements);
};
getElement = (id: string) => {
+1 -5
View File
@@ -1,15 +1,11 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(sanitizeHTMLAttribute(link));
return sanitizeUrl(link);
};
export const isLocalLink = (link: string | null) => {
File diff suppressed because it is too large Load Diff
+7
View File
@@ -299,6 +299,13 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
];
};
export const pointRelativeTo = (
element: ExcalidrawElement,
absoluteCoords: Point,
): Point => {
return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
};
export const getDiamondPoints = (element: ExcalidrawElement) => {
// Here we add +1 to avoid these numbers to be 0
// otherwise rough.js will throw an error complaining about it
File diff suppressed because it is too large Load Diff
+71 -121
View File
@@ -11,33 +11,27 @@ import {
ExcalidrawIframeLikeElement,
IframeData,
} from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import { MarkRequired } from "../utility-types";
import { StoreAction } from "../store";
type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
const embeddedLinkCache = new Map<string, IframeData>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
// not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:twitter|x).com/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
/^<blockquote[\s\S]*?\shref=["'](https:\/\/(?:twitter|x).com\/[^"']*)/i;
const RE_VALTOWN =
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
@@ -59,18 +53,7 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com",
"val.town",
"giphy.com",
]);
const ALLOW_SAME_ORIGIN = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"figma.com",
"twitter.com",
"x.com",
"*.simplepdf.eu",
"stackblitz.com",
"dddice.com",
]);
export const createSrcDoc = (body: string) => {
@@ -79,7 +62,7 @@ export const createSrcDoc = (body: string) => {
export const getEmbedLink = (
link: string | null | undefined,
): IframeDataWithSandbox | null => {
): IframeData | null => {
if (!link) {
return null;
}
@@ -90,10 +73,6 @@ export const getEmbedLink = (
const originalLink = link;
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
);
let type: "video" | "generic" = "generic";
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
@@ -120,14 +99,8 @@ export const getEmbedLink = (
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
return { link, intrinsicSize: aspectRatio, type };
}
const vimeoLink = link.match(RE_VIMEO);
@@ -145,15 +118,8 @@ export const getEmbedLink = (
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
error,
sandbox: { allowSameOrigin },
};
return { link, intrinsicSize: aspectRatio, type, error };
}
const figmaLink = link.match(RE_FIGMA);
@@ -167,14 +133,8 @@ export const getEmbedLink = (
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
return { link, intrinsicSize: aspectRatio, type };
}
const valLink = link.match(RE_VALTOWN);
@@ -185,74 +145,70 @@ export const getEmbedLink = (
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
return { link, intrinsicSize: aspectRatio, type };
}
if (RE_TWITTER.test(link)) {
const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only.
// Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed.
const safeURL = sanitizeHTMLAttribute(
`https://twitter.com/x/status/${postId}`,
);
// the embed srcdoc still supports twitter.com domain only
link = link.replace(/\bx.com\b/, "twitter.com");
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
let ret: IframeData;
// assume embed code
if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
intrinsicSize: { w: 480, h: 480 },
};
// assume regular tweet url
} else {
ret = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
intrinsicSize: { w: 480, h: 480 },
};
}
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute(
`https://gist.github.com/${user}/${gistId}`,
);
const ret: IframeDataWithSandbox = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${safeURL}.js"></script>
let ret: IframeData;
// assume embed code
if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
intrinsicSize: { w: 550, h: 720 },
};
// assume regular url
} else {
ret = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${link}.js"></script>
<style type="text/css">
* { margin: 0px; }
table, .gist { height: 100%; }
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
intrinsicSize: { w: 550, h: 720 },
sandbox: { allowSameOrigin },
};
intrinsicSize: { w: 550, h: 720 },
};
}
embeddedLinkCache.set(link, ret);
return ret;
}
embeddedLinkCache.set(link, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
return { link, intrinsicSize: aspectRatio, type };
};
export const createPlaceholderEmbeddableLabel = (
@@ -315,44 +271,39 @@ export const actionSetEmbeddableAsActiveTool = register({
type: "embeddable",
}),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
});
const matchHostname = (
const validateHostname = (
url: string,
/** using a Set assumes it already contains normalized bare domains */
allowedHostnames: Set<string> | string,
): string | null => {
): boolean => {
try {
const { hostname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, "");
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (allowedHostnames instanceof Set) {
if (ALLOWED_DOMAINS.has(bareDomain)) {
return bareDomain;
}
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
return (
ALLOWED_DOMAINS.has(bareDomain) ||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
);
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
return bareDomainWithFirstSubdomainWildcarded;
}
return null;
}
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
if (bareDomain === bareAllowedHostname) {
return bareAllowedHostname;
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
return true;
}
} catch (error) {
// ignore
}
return null;
return false;
};
export const maybeParseEmbedSrc = (str: string): string => {
@@ -374,7 +325,6 @@ export const maybeParseEmbedSrc = (str: string): string => {
if (match && match.length === 2) {
return match[1];
}
return str;
};
@@ -402,7 +352,7 @@ export const embeddableURLValidator = (
if (url.match(domain)) {
return true;
}
} else if (matchHostname(url, domain)) {
} else if (validateHostname(url, domain)) {
return true;
}
}
@@ -410,5 +360,5 @@ export const embeddableURLValidator = (
}
}
return !!matchHostname(url, ALLOWED_DOMAINS);
return validateHostname(url, ALLOWED_DOMAINS);
};
+4
View File
@@ -29,6 +29,10 @@ export {
getTransformHandlesFromCoords,
getTransformHandles,
} from "./transformHandles";
export {
hitTest,
isHittingElementBoundingBoxWithoutHittingElement,
} from "./collision";
export {
resizeTest,
getCursorForResizingElement,
@@ -6,6 +6,7 @@ import {
ExcalidrawBindableElement,
ExcalidrawTextElementWithContainer,
ElementsMap,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "./types";
import {
@@ -33,9 +34,9 @@ import {
AppState,
PointerCoords,
InteractiveCanvasAppState,
AppClassProperties,
} from "../types";
import { mutateElement } from "./mutateElement";
import History from "../history";
import {
bindOrUnbindLinearElement,
@@ -49,7 +50,6 @@ import { getBoundTextElement, handleBindTextResize } from "./textElement";
import { DRAGGING_THRESHOLD } from "../constants";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
import { Store } from "../store";
const editorMidPointsCache: {
version: number | null;
@@ -334,10 +334,9 @@ export class LinearElementEditor {
event: PointerEvent,
editingLinearElement: LinearElementEditor,
appState: AppState,
app: AppClassProperties,
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
): LinearElementEditor {
const elementsMap = app.scene.getNonDeletedElementsMap();
const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
@@ -381,7 +380,8 @@ export class LinearElementEditor {
elementsMap,
),
),
app,
elements,
elementsMap,
)
: null;
@@ -642,17 +642,16 @@ export class LinearElementEditor {
static handlePointerDown(
event: React.PointerEvent<HTMLElement>,
appState: AppState,
store: Store,
history: History,
scenePointer: { x: number; y: number },
linearElementEditor: LinearElementEditor,
app: AppClassProperties,
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
): {
didAddPoint: boolean;
hitElement: NonDeleted<ExcalidrawElement> | null;
linearElementEditor: LinearElementEditor | null;
} {
const elementsMap = app.scene.getNonDeletedElementsMap();
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
didAddPoint: false,
hitElement: null,
@@ -700,7 +699,7 @@ export class LinearElementEditor {
});
ret.didAddPoint = true;
}
store.shouldCaptureIncrement();
history.resumeRecording();
ret.linearElementEditor = {
...linearElementEditor,
pointerDownState: {
@@ -715,7 +714,11 @@ export class LinearElementEditor {
},
selectedPointsIndices: [element.points.length - 1],
lastUncommittedPoint: null,
endBindingElement: getHoveredElementForBinding(scenePointer, app),
endBindingElement: getHoveredElementForBinding(
scenePointer,
elements,
elementsMap,
),
};
ret.didAddPoint = true;
+2 -3
View File
@@ -7,9 +7,9 @@ import { getUpdatedTimestamp } from "../utils";
import { Mutable } from "../utility-types";
import { ShapeCache } from "../scene/ShapeCache";
export type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
Partial<TElement>,
"id" | "version" | "versionNonce" | "updated"
"id" | "version" | "versionNonce"
>;
// This function tracks updates of text elements for the purposes for collaboration.
@@ -79,7 +79,6 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
didChange = true;
}
}
if (!didChange) {
return element;
}
@@ -55,7 +55,6 @@ export type ElementConstructorOpts = MarkOptional<
| "angle"
| "groupIds"
| "frameId"
| "index"
| "boundElements"
| "seed"
| "version"
@@ -90,7 +89,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
angle = 0,
groupIds = [],
frameId = null,
index = null,
roundness = null,
boundElements = null,
link = null,
@@ -116,7 +114,6 @@ const _newElementBase = <T extends ExcalidrawElement>(
opacity,
groupIds,
frameId,
index,
roundness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
+142 -139
View File
@@ -1,7 +1,12 @@
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
import { rescalePoints } from "../points";
import { rotate, centerPoint, rotatePoint } from "../math";
import {
rotate,
adjustXYWithRotation,
centerPoint,
rotatePoint,
} from "../math";
import {
ExcalidrawLinearElement,
ExcalidrawTextElement,
@@ -18,6 +23,7 @@ import {
getCommonBounds,
getResizedElementAbsoluteCoords,
getCommonBoundingBox,
getElementPointsCoords,
} from "./bounds";
import {
isArrowElement,
@@ -32,6 +38,7 @@ import { mutateElement } from "./mutateElement";
import { getFontString } from "../utils";
import { updateBoundElements } from "./binding";
import {
TransformHandleType,
MaybeTransformHandleType,
TransformHandleDirection,
} from "./transformHandles";
@@ -47,7 +54,6 @@ import {
getApproxMinLineHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
import { isInGroup } from "../groups";
export const normalizeAngle = (angle: number): number => {
if (angle < 0) {
@@ -127,14 +133,18 @@ export const transformElements = (
centerY,
);
return true;
} else if (transformHandleType) {
} else if (
transformHandleType === "nw" ||
transformHandleType === "ne" ||
transformHandleType === "sw" ||
transformHandleType === "se"
) {
resizeMultipleElements(
originalElements,
selectedElements,
elementsMap,
transformHandleType,
shouldResizeFromCenter,
shouldMaintainAspectRatio,
pointerX,
pointerY,
);
@@ -222,6 +232,26 @@ const measureFontSizeFromWidth = (
};
};
const getSidesForTransformHandle = (
transformHandleType: TransformHandleType,
shouldResizeFromCenter: boolean,
) => {
return {
n:
/^(n|ne|nw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
s:
/^(s|se|sw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
w:
/^(w|nw|sw)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
e:
/^(e|ne|se)$/.test(transformHandleType) ||
(shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
};
};
const resizeSingleTextElement = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
@@ -230,10 +260,9 @@ const resizeSingleTextElement = (
pointerX: number,
pointerY: number,
) => {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// rotation pointer with reverse angle
const [rotatedX, rotatedY] = rotate(
pointerX,
@@ -242,24 +271,33 @@ const resizeSingleTextElement = (
cy,
-element.angle,
);
let scaleX = 0;
let scaleY = 0;
if (transformHandleType.includes("e")) {
scaleX = (rotatedX - x1) / (x2 - x1);
let scale: number;
switch (transformHandleType) {
case "se":
scale = Math.max(
(rotatedX - x1) / (x2 - x1),
(rotatedY - y1) / (y2 - y1),
);
break;
case "nw":
scale = Math.max(
(x2 - rotatedX) / (x2 - x1),
(y2 - rotatedY) / (y2 - y1),
);
break;
case "ne":
scale = Math.max(
(rotatedX - x1) / (x2 - x1),
(y2 - rotatedY) / (y2 - y1),
);
break;
case "sw":
scale = Math.max(
(x2 - rotatedX) / (x2 - x1),
(rotatedY - y1) / (y2 - y1),
);
break;
}
if (transformHandleType.includes("w")) {
scaleX = (x2 - rotatedX) / (x2 - x1);
}
if (transformHandleType.includes("n")) {
scaleY = (y2 - rotatedY) / (y2 - y1);
}
if (transformHandleType.includes("s")) {
scaleY = (rotatedY - y1) / (y2 - y1);
}
const scale = Math.max(scaleX, scaleY);
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
@@ -267,55 +305,32 @@ const resizeSingleTextElement = (
if (metrics === null) {
return;
}
const startTopLeft = [x1, y1];
const startBottomRight = [x2, y2];
const startCenter = [cx, cy];
let newTopLeft = [x1, y1] as [number, number];
if (["n", "w", "nw"].includes(transformHandleType)) {
newTopLeft = [
startBottomRight[0] - Math.abs(nextWidth),
startBottomRight[1] - Math.abs(nextHeight),
];
}
if (transformHandleType === "ne") {
const bottomLeft = [startTopLeft[0], startBottomRight[1]];
newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
}
if (transformHandleType === "sw") {
const topRight = [startBottomRight[0], startTopLeft[1]];
newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
}
if (["s", "n"].includes(transformHandleType)) {
newTopLeft[0] = startCenter[0] - nextWidth / 2;
}
if (["e", "w"].includes(transformHandleType)) {
newTopLeft[1] = startCenter[1] - nextHeight / 2;
}
if (shouldResizeFromCenter) {
newTopLeft[0] = startCenter[0] - Math.abs(nextWidth) / 2;
newTopLeft[1] = startCenter[1] - Math.abs(nextHeight) / 2;
}
const angle = element.angle;
const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
const newCenter: Point = [
newTopLeft[0] + Math.abs(nextWidth) / 2,
newTopLeft[1] + Math.abs(nextHeight) / 2,
];
const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
const [nextX, nextY] = newTopLeft;
const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords(
element,
nextWidth,
nextHeight,
false,
);
const deltaX1 = (x1 - nextX1) / 2;
const deltaY1 = (y1 - nextY1) / 2;
const deltaX2 = (x2 - nextX2) / 2;
const deltaY2 = (y2 - nextY2) / 2;
const [nextElementX, nextElementY] = adjustXYWithRotation(
getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
element.x,
element.y,
element.angle,
deltaX1,
deltaY1,
deltaX2,
deltaY2,
);
mutateElement(element, {
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
x: nextX,
y: nextY,
x: nextElementX,
y: nextElementY,
});
}
};
@@ -621,9 +636,8 @@ export const resizeMultipleElements = (
originalElements: PointerDownState["originalElements"],
selectedElements: readonly NonDeletedExcalidrawElement[],
elementsMap: ElementsMap,
transformHandleType: TransformHandleDirection,
transformHandleType: "nw" | "ne" | "sw" | "se",
shouldResizeFromCenter: boolean,
shouldMaintainAspectRatio: boolean,
pointerX: number,
pointerY: number,
) => {
@@ -677,80 +691,43 @@ export const resizeMultipleElements = (
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
targetElements.map(({ orig }) => orig).concat(boundTextElements),
);
const width = maxX - minX;
const height = maxY - minY;
// const originalHeight = maxY - minY;
// const originalWidth = maxX - minX;
const direction = transformHandleType;
const anchorsMap: Record<TransformHandleDirection, Point> = {
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
ne: [minX, maxY],
se: [minX, minY],
sw: [maxX, minY],
nw: [maxX, maxY],
e: [minX, minY + height / 2],
w: [maxX, minY + height / 2],
n: [minX + width / 2, maxY],
s: [minX + width / 2, minY],
};
// anchor point must be on the opposite side of the dragged selection handle
// or be the center of the selection if shouldResizeFromCenter
const [anchorX, anchorY]: Point = shouldResizeFromCenter
? [midX, midY]
: anchorsMap[direction];
const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
: mapDirectionsToAnchors[direction];
const scale =
Math.max(
Math.abs(pointerX - anchorX) / width || 0,
Math.abs(pointerY - anchorY) / height || 0,
) * resizeFromCenterScale;
Math.abs(pointerX - anchorX) / (maxX - minX) || 0,
Math.abs(pointerY - anchorY) / (maxY - minY) || 0,
) * (shouldResizeFromCenter ? 2 : 1);
if (scale === 0) {
return;
}
let scaleX =
direction.includes("e") || direction.includes("w")
? (Math.abs(pointerX - anchorX) / width) * resizeFromCenterScale
: 1;
let scaleY =
direction.includes("n") || direction.includes("s")
? (Math.abs(pointerY - anchorY) / height) * resizeFromCenterScale
: 1;
const keepAspectRatio =
shouldMaintainAspectRatio ||
targetElements.some(
(item) =>
item.latest.angle !== 0 ||
isTextElement(item.latest) ||
isInGroup(item.latest),
);
if (keepAspectRatio) {
scaleX = scale;
scaleY = scale;
}
const flipConditionsMap: Record<
TransformHandleDirection,
// Condition for which we should flip or not flip the selected elements
// - when evaluated to `true`, we flip
// - therefore, setting it to always `false` means we do not flip (in that direction) at all
const mapDirectionsToPointerPositions: Record<
typeof direction,
[x: boolean, y: boolean]
> = {
ne: [pointerX < anchorX, pointerY > anchorY],
se: [pointerX < anchorX, pointerY < anchorY],
sw: [pointerX > anchorX, pointerY < anchorY],
nw: [pointerX > anchorX, pointerY > anchorY],
// e.g. when resizing from the "e" side, we do not need to consider changes in the `y` direction
// and therefore, we do not need to flip in the `y` direction at all
e: [pointerX < anchorX, false],
w: [pointerX > anchorX, false],
n: [false, pointerY > anchorY],
s: [false, pointerY < anchorY],
ne: [pointerX >= anchorX, pointerY <= anchorY],
se: [pointerX >= anchorX, pointerY >= anchorY],
sw: [pointerX <= anchorX, pointerY >= anchorY],
nw: [pointerX <= anchorX, pointerY <= anchorY],
};
/**
@@ -761,9 +738,9 @@ export const resizeMultipleElements = (
* mirror points in the case of linear & freedraw elemenets
* 3. adjust element angle
*/
const [flipFactorX, flipFactorY] = flipConditionsMap[direction].map(
(condition) => (condition ? -1 : 1),
);
const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[
direction
].map((condition) => (condition ? 1 : -1));
const isFlippedByX = flipFactorX < 0;
const isFlippedByY = flipFactorY < 0;
@@ -785,8 +762,8 @@ export const resizeMultipleElements = (
continue;
}
const width = orig.width * scaleX;
const height = orig.height * scaleY;
const width = orig.width * scale;
const height = orig.height * scale;
const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
@@ -794,8 +771,8 @@ export const resizeMultipleElements = (
const offsetY = orig.y - anchorY;
const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0;
const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0;
const x = anchorX + flipFactorX * (offsetX * scaleX + shiftX);
const y = anchorY + flipFactorY * (offsetY * scaleY + shiftY);
const x = anchorX + flipFactorX * (offsetX * scale + shiftX);
const y = anchorY + flipFactorY * (offsetY * scale + shiftY);
const rescaledPoints = rescalePointsInElement(
orig,
@@ -813,10 +790,40 @@ export const resizeMultipleElements = (
...rescaledPoints,
};
if (isImageElement(orig)) {
if (isImageElement(orig) && targetElements.length === 1) {
update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY];
}
if (isLinearElement(orig) && (isFlippedByX || isFlippedByY)) {
const origBounds = getElementPointsCoords(orig, orig.points);
const newBounds = getElementPointsCoords(
{ ...orig, x, y },
rescaledPoints.points!,
);
const origXY = [orig.x, orig.y];
const newXY = [x, y];
const linearShift = (axis: "x" | "y") => {
const i = axis === "x" ? 0 : 1;
return (
(newBounds[i + 2] -
newXY[i] -
(origXY[i] - origBounds[i]) * scale +
(origBounds[i + 2] - origXY[i]) * scale -
(newXY[i] - newBounds[i])) /
2
);
};
if (isFlippedByX) {
update.x -= linearShift("x");
}
if (isFlippedByY) {
update.y -= linearShift("y");
}
}
if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
if (!metrics) {
@@ -830,15 +837,11 @@ export const resizeMultipleElements = (
) as ExcalidrawTextElementWithContainer | undefined;
if (boundTextElement) {
if (keepAspectRatio) {
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
return;
}
update.boundTextFontSize = newFontSize;
} else {
update.boundTextFontSize = boundTextElement.fontSize;
const newFontSize = boundTextElement.fontSize * scale;
if (newFontSize < MIN_FONT_SIZE) {
return;
}
update.boundTextFontSize = newFontSize;
}
elementsAndUpdates.push({
+6 -99
View File
@@ -6,24 +6,15 @@ import {
} from "./types";
import {
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
getTransformHandlesFromCoords,
getTransformHandles,
TransformHandleType,
TransformHandle,
MaybeTransformHandleType,
getOmitSidesForDevice,
canResizeFromSides,
} from "./transformHandles";
import { AppState, Device, Zoom } from "../types";
import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { SIDE_RESIZING_THRESHOLD } from "../constants";
import {
angleToDegrees,
pointOnLine,
pointRotate,
} from "../../utils/geometry/geometry";
import { Line, Point } from "../../utils/geometry/shape";
import { isLinearElement } from "./typeChecks";
import { AppState, Zoom } from "../types";
import { Bounds } from "./bounds";
const isInsideTransformHandle = (
transformHandle: TransformHandle,
@@ -43,20 +34,13 @@ export const resizeTest = (
y: number,
zoom: Zoom,
pointerType: PointerType,
device: Device,
): MaybeTransformHandleType => {
if (!appState.selectedElementIds[element.id]) {
return false;
}
const { rotation: rotationTransformHandle, ...transformHandles } =
getTransformHandles(
element,
zoom,
elementsMap,
pointerType,
getOmitSidesForDevice(device),
);
getTransformHandles(element, zoom, elementsMap, pointerType);
if (
rotationTransformHandle &&
@@ -78,35 +62,6 @@ export const resizeTest = (
return filter[0] as TransformHandleType;
}
if (canResizeFromSides(device)) {
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
element,
elementsMap,
);
// Note that for a text element, when "resized" from the side
// we should make it wrap/unwrap
if (
element.type !== "text" &&
!(isLinearElement(element) && element.points.length <= 2)
) {
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(element.angle),
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([x, y], side as Line, SPACING)) {
return dir as TransformHandleType;
}
}
}
}
return false;
};
@@ -118,7 +73,6 @@ export const getElementWithTransformHandleType = (
zoom: Zoom,
pointerType: PointerType,
elementsMap: ElementsMap,
device: Device,
) => {
return elements.reduce((result, element) => {
if (result) {
@@ -132,7 +86,6 @@ export const getElementWithTransformHandleType = (
scenePointerY,
zoom,
pointerType,
device,
);
return transformHandleType ? { element, transformHandleType } : null;
}, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
@@ -144,14 +97,13 @@ export const getTransformHandleTypeFromCoords = (
scenePointerY: number,
zoom: Zoom,
pointerType: PointerType,
device: Device,
): MaybeTransformHandleType => {
const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0,
zoom,
pointerType,
getOmitSidesForDevice(device),
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
);
const found = Object.keys(transformHandles).find((key) => {
@@ -162,33 +114,7 @@ export const getTransformHandleTypeFromCoords = (
isInsideTransformHandle(transformHandle, scenePointerX, scenePointerY)
);
});
if (found) {
return found as MaybeTransformHandleType;
}
if (canResizeFromSides(device)) {
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
const sides = getSelectionBorders(
[x1 - SPACING, y1 - SPACING],
[x2 + SPACING, y2 + SPACING],
[cx, cy],
angleToDegrees(0),
);
for (const [dir, side] of Object.entries(sides)) {
// test to see if x, y are on the line segment
if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
return dir as TransformHandleType;
}
}
}
return false;
return (found || false) as MaybeTransformHandleType;
};
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
@@ -248,22 +174,3 @@ export const getCursorForResizingElement = (resizingElement: {
return cursor ? `${cursor}-resize` : "";
};
const getSelectionBorders = (
[x1, y1]: Point,
[x2, y2]: Point,
center: Point,
angleInDegrees: number,
) => {
const topLeft = pointRotate([x1, y1], angleInDegrees, center);
const topRight = pointRotate([x2, y1], angleInDegrees, center);
const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
return {
n: [topLeft, topRight],
e: [topRight, bottomRight],
s: [bottomRight, bottomLeft],
w: [bottomLeft, topLeft],
};
};
@@ -6,9 +6,6 @@ import { AppState, Zoom } from "../types";
import { getElementBounds } from "./bounds";
import { viewportCoordsToSceneCoords } from "../utils";
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
// - could also be part of `_clearElements`
export const isInvisiblySmallElement = (
element: ExcalidrawElement,
): boolean => {
+53 -6
View File
@@ -26,11 +26,16 @@ import { isTextElement } from ".";
import { isBoundToContainer, isArrowElement } from "./typeChecks";
import { LinearElementEditor } from "./linearElementEditor";
import { AppState } from "../types";
import { isTextBindableContainer } from "./typeChecks";
import { getElementAbsoluteCoords } from ".";
import { getSelectedElements } from "../scene";
import { isHittingElementNotConsideringBoundingBox } from "./collision";
import { ExtractSetType, MakeBrand } from "../utility-types";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { ExtractSetType, MakeBrand } from "../utility-types";
export const normalizeText = (text: string) => {
return (
@@ -48,7 +53,6 @@ export const redrawTextBoundingBox = (
textElement: ExcalidrawTextElement,
container: ExcalidrawElement | null,
elementsMap: ElementsMap,
informMutation: boolean = true,
) => {
let maxWidth = undefined;
const boundTextUpdates = {
@@ -57,7 +61,6 @@ export const redrawTextBoundingBox = (
text: textElement.text,
width: textElement.width,
height: textElement.height,
angle: container?.angle ?? textElement.angle,
};
boundTextUpdates.text = textElement.text;
@@ -91,7 +94,7 @@ export const redrawTextBoundingBox = (
metrics.height,
container.type,
);
mutateElement(container, { height: nextHeight }, informMutation);
mutateElement(container, { height: nextHeight });
updateOriginalContainerCache(container.id, nextHeight);
}
if (metrics.width > maxContainerWidth) {
@@ -99,7 +102,7 @@ export const redrawTextBoundingBox = (
metrics.width,
container.type,
);
mutateElement(container, { width: nextWidth }, informMutation);
mutateElement(container, { width: nextWidth });
}
const updatedTextElement = {
...textElement,
@@ -114,7 +117,7 @@ export const redrawTextBoundingBox = (
boundTextUpdates.y = y;
}
mutateElement(textElement, boundTextUpdates, informMutation);
mutateElement(textElement, boundTextUpdates);
};
export const bindTextToShapeAfterDuplication = (
@@ -768,6 +771,50 @@ export const suppportsHorizontalAlign = (
});
};
export const getTextBindableContainerAtPosition = (
elements: readonly ExcalidrawElement[],
appState: AppState,
x: number,
y: number,
elementsMap: ElementsMap,
): ExcalidrawTextContainer | null => {
const selectedElements = getSelectedElements(elements, appState);
if (selectedElements.length === 1) {
return isTextBindableContainer(selectedElements[0], false)
? selectedElements[0]
: null;
}
let hitElement = null;
// We need to to hit testing from front (end of the array) to back (beginning of the array)
for (let index = elements.length - 1; index >= 0; --index) {
if (elements[index].isDeleted) {
continue;
}
const [x1, y1, x2, y2] = getElementAbsoluteCoords(
elements[index],
elementsMap,
);
if (
isArrowElement(elements[index]) &&
isHittingElementNotConsideringBoundingBox(
elements[index],
appState,
null,
[x, y],
elementsMap,
)
) {
hitElement = elements[index];
break;
} else if (x1 < x && x < x2 && y1 < y && y < y2) {
hitElement = elements[index];
break;
}
}
return isTextBindableContainer(hitElement, false) ? hitElement : null;
};
const VALID_CONTAINER_TYPES = new Set([
"rectangle",
"ellipse",
@@ -1454,7 +1454,7 @@ describe("textWysiwyg", () => {
strokeWidth: 2,
type: "rectangle",
updated: 1,
version: 2,
version: 1,
width: 610,
x: 15,
y: 25,
+2 -2
View File
@@ -235,7 +235,7 @@ export const textWysiwyg = ({
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight: updatedTextElement.lineHeight,
width: `${textElementWidth}px`,
width: `${Math.ceil(textElementWidth)}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
top: `${viewportY}px`,
@@ -333,7 +333,7 @@ export const textWysiwyg = ({
getBoundTextMaxWidth(container, boundTextElement),
);
const width = getTextWidth(wrappedText, font);
editable.style.width = `${width}px`;
editable.style.width = `${Math.ceil(width)}px`;
}
};
@@ -7,14 +7,10 @@ import {
import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math";
import { Device, InteractiveCanvasAppState, Zoom } from "../types";
import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from ".";
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import {
DEFAULT_TRANSFORM_HANDLE_SPACING,
isAndroid,
isIOS,
} from "../constants";
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "../constants";
export type TransformHandleDirection =
| "n"
@@ -42,13 +38,6 @@ const transformHandleSizes: { [k in PointerType]: number } = {
const ROTATION_RESIZE_HANDLE_GAP = 16;
export const DEFAULT_OMIT_SIDES = {
e: true,
s: true,
n: true,
w: true,
};
export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
e: true,
s: true,
@@ -100,26 +89,6 @@ const generateTransformHandle = (
return [xx - width / 2, yy - height / 2, width, height];
};
export const canResizeFromSides = (device: Device) => {
if (device.viewport.isMobile) {
return false;
}
if (device.isTouchScreen && (isAndroid || isIOS)) {
return false;
}
return true;
};
export const getOmitSidesForDevice = (device: Device) => {
if (canResizeFromSides(device)) {
return DEFAULT_OMIT_SIDES;
}
return {};
};
export const getTransformHandlesFromCoords = (
[x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
angle: number,
@@ -263,8 +232,8 @@ export const getTransformHandles = (
element: ExcalidrawElement,
zoom: Zoom,
elementsMap: ElementsMap,
pointerType: PointerType = "mouse",
omitSides: { [T in TransformHandleType]?: boolean } = DEFAULT_OMIT_SIDES,
): TransformHandles => {
// so that when locked element is selected (especially when you toggle lock
// via keyboard) the locked element is visually distinct, indicating
@@ -273,6 +242,7 @@ export const getTransformHandles = (
return {};
}
let omitSides: { [T in TransformHandleType]?: boolean } = {};
if (element.type === "freedraw" || isLinearElement(element)) {
if (element.points.length === 2) {
// only check the last point because starting point is always (0,0)
@@ -293,7 +263,6 @@ export const getTransformHandles = (
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameLikeElement(element)) {
omitSides = {
...omitSides,
rotation: true,
};
}
+1 -2
View File
@@ -20,7 +20,6 @@ import {
ExcalidrawIframeElement,
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
ExcalidrawArrowElement,
} from "./types";
export const isInitializedImageElement = (
@@ -102,7 +101,7 @@ export const isLinearElement = (
export const isArrowElement = (
element?: ExcalidrawElement | null,
): element is ExcalidrawArrowElement => {
): element is ExcalidrawLinearElement => {
return element != null && element.type === "arrow";
};
+8 -24
View File
@@ -24,12 +24,6 @@ export type TextAlign = typeof TEXT_ALIGN[keyof typeof TEXT_ALIGN];
type VerticalAlignKeys = keyof typeof VERTICAL_ALIGN;
export type VerticalAlign = typeof VERTICAL_ALIGN[VerticalAlignKeys];
export type FractionalIndex = string & { _brand: "franctionalIndex" };
export type BoundElement = Readonly<{
id: ExcalidrawLinearElement["id"];
type: "arrow" | "text";
}>;
type _ExcalidrawElementBase = Readonly<{
id: string;
@@ -56,18 +50,18 @@ type _ExcalidrawElementBase = Readonly<{
Used for deterministic reconciliation of updates during collaboration,
in case the versions (see above) are identical. */
versionNonce: number;
/** String in a fractional form defined by https://github.com/rocicorp/fractional-indexing.
Used for ordering in multiplayer scenarios, such as during reconciliation or undo / redo.
Always kept in sync with the array order by `syncMovedIndices` and `syncInvalidIndices`.
Could be null, i.e. for new elements which were not yet assigned to the scene. */
index: FractionalIndex | null;
isDeleted: boolean;
/** List of groups the element belongs to.
Ordered from deepest to shallowest. */
groupIds: readonly GroupId[];
frameId: string | null;
/** other elements that are bound to this element */
boundElements: readonly BoundElement[] | null;
boundElements:
| readonly Readonly<{
id: ExcalidrawLinearElement["id"];
type: "arrow" | "text";
}>[]
| null;
/** epoch (ms) timestamp of last element update */
updated: number;
link: string | null;
@@ -111,7 +105,6 @@ export type IframeData =
| {
intrinsicSize: { w: number; h: number };
error?: Error;
sandbox?: { allowSameOrigin?: boolean };
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
@@ -171,12 +164,6 @@ export type ExcalidrawElement =
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement;
export type Ordered<TElement extends ExcalidrawElement> = TElement & {
index: FractionalIndex;
};
export type OrderedExcalidrawElement = Ordered<ExcalidrawElement>;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
};
@@ -288,10 +275,7 @@ export type NonDeletedElementsMap = Map<
* Map of all excalidraw Scene elements, including deleted.
* Not a subset. Use this type when you need access to current Scene elements.
*/
export type SceneElementsMap = Map<
ExcalidrawElement["id"],
Ordered<ExcalidrawElement>
> &
export type SceneElementsMap = Map<ExcalidrawElement["id"], ExcalidrawElement> &
MakeBrand<"SceneElementsMap">;
/**
@@ -300,7 +284,7 @@ export type SceneElementsMap = Map<
*/
export type NonDeletedSceneElementsMap = Map<
ExcalidrawElement["id"],
Ordered<NonDeletedExcalidrawElement>
NonDeletedExcalidrawElement
> &
MakeBrand<"NonDeletedSceneElementsMap">;
-4
View File
@@ -32,7 +32,3 @@ export class ImageSceneDataError extends Error {
this.code = code;
}
}
export class InvalidFractionalIndexError extends Error {
public code = "ELEMENT_HAS_INVALID_INDEX" as const;
}
-348
View File
@@ -1,348 +0,0 @@
import { generateNKeysBetween } from "fractional-indexing";
import { mutateElement } from "./element/mutateElement";
import {
ExcalidrawElement,
FractionalIndex,
OrderedExcalidrawElement,
} from "./element/types";
import { InvalidFractionalIndexError } from "./errors";
/**
* Envisioned relation between array order and fractional indices:
*
* 1) Array (or array-like ordered data structure) should be used as a cache of elements order, hiding the internal fractional indices implementation.
* - it's undesirable to to perform reorder for each related operation, thefeore it's necessary to cache the order defined by fractional indices into an ordered data structure
* - it's easy enough to define the order of the elements from the outside (boundaries), without worrying about the underlying structure of fractional indices (especially for the host apps)
* - it's necessary to always keep the array support for backwards compatibility (restore) - old scenes, old libraries, supporting multiple excalidraw versions etc.
* - it's necessary to always keep the fractional indices in sync with the array order
* - elements with invalid indices should be detected and synced, without altering the already valid indices
*
* 2) Fractional indices should be used to reorder the elements, whenever the cached order is expected to be invalidated.
* - as the fractional indices are encoded as part of the elements, it opens up possibilties for incremental-like APIs
* - re-order based on fractional indices should be part of (multiplayer) operations such as reconcillitation & undo/redo
* - technically all the z-index actions could perform also re-order based on fractional indices,but in current state it would not bring much benefits,
* as it's faster & more efficient to perform re-order based on array manipulation and later synchronisation of moved indices with the array order
*/
/**
* Ensure that all elements have valid fractional indices.
*
* @throws `InvalidFractionalIndexError` if invalid index is detected.
*/
export const validateFractionalIndices = (
indices: (ExcalidrawElement["index"] | undefined)[],
) => {
for (const [i, index] of indices.entries()) {
const predecessorIndex = indices[i - 1];
const successorIndex = indices[i + 1];
if (!isValidFractionalIndex(index, predecessorIndex, successorIndex)) {
throw new InvalidFractionalIndexError(
`Fractional indices invariant for element has been compromised - ["${predecessorIndex}", "${index}", "${successorIndex}"] [predecessor, current, successor]`,
);
}
}
};
/**
* Order the elements based on the fractional indices.
* - when fractional indices are identical, break the tie based on the element id
* - when there is no fractional index in one of the elements, respect the order of the array
*/
export const orderByFractionalIndex = (
elements: OrderedExcalidrawElement[],
) => {
return elements.sort((a, b) => {
// in case the indices are not the defined at runtime
if (isOrderedElement(a) && isOrderedElement(b)) {
if (a.index < b.index) {
return -1;
} else if (a.index > b.index) {
return 1;
}
// break ties based on the element id
return a.id < b.id ? -1 : 1;
}
// defensively keep the array order
return 1;
});
};
/**
* Synchronizes invalid fractional indices of moved elements with the array order by mutating passed elements.
* If the synchronization fails or the result is invalid, it fallbacks to `syncInvalidIndices`.
*/
export const syncMovedIndices = (
elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>,
): OrderedExcalidrawElement[] => {
try {
const indicesGroups = getMovedIndicesGroups(elements, movedElements);
// try generatating indices, throws on invalid movedElements
const elementsUpdates = generateIndices(elements, indicesGroups);
// ensure next indices are valid before mutation, throws on invalid ones
validateFractionalIndices(
elements.map((x) => elementsUpdates.get(x)?.index || x.index),
);
// split mutation so we don't end up in an incosistent state
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
}
} catch (e) {
// fallback to default sync
syncInvalidIndices(elements);
}
return elements as OrderedExcalidrawElement[];
};
/**
* Synchronizes all invalid fractional indices with the array order by mutating passed elements.
*
* WARN: in edge cases it could modify the elements which were not moved, as it's impossible to guess the actually moved elements from the elements array itself.
*/
export const syncInvalidIndices = (
elements: readonly ExcalidrawElement[],
): OrderedExcalidrawElement[] => {
const indicesGroups = getInvalidIndicesGroups(elements);
const elementsUpdates = generateIndices(elements, indicesGroups);
for (const [element, update] of elementsUpdates) {
mutateElement(element, update, false);
}
return elements as OrderedExcalidrawElement[];
};
/**
* Get contiguous groups of indices of passed moved elements.
*
* NOTE: First and last elements within the groups are indices of lower and upper bounds.
*/
const getMovedIndicesGroups = (
elements: readonly ExcalidrawElement[],
movedElements: Map<string, ExcalidrawElement>,
) => {
const indicesGroups: number[][] = [];
let i = 0;
while (i < elements.length) {
if (
movedElements.has(elements[i].id) &&
!isValidFractionalIndex(
elements[i]?.index,
elements[i - 1]?.index,
elements[i + 1]?.index,
)
) {
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
while (++i < elements.length) {
if (
!(
movedElements.has(elements[i].id) &&
!isValidFractionalIndex(
elements[i]?.index,
elements[i - 1]?.index,
elements[i + 1]?.index,
)
)
) {
break;
}
indicesGroup.push(i);
}
indicesGroup.push(i); // push the upper bound index as the last item
indicesGroups.push(indicesGroup);
} else {
i++;
}
}
return indicesGroups;
};
/**
* Gets contiguous groups of all invalid indices automatically detected inside the elements array.
*
* WARN: First and last items within the groups do NOT have to be contiguous, those are the found lower and upper bounds!
*/
const getInvalidIndicesGroups = (elements: readonly ExcalidrawElement[]) => {
const indicesGroups: number[][] = [];
// once we find lowerBound / upperBound, it cannot be lower than that, so we cache it for better perf.
let lowerBound: ExcalidrawElement["index"] | undefined = undefined;
let upperBound: ExcalidrawElement["index"] | undefined = undefined;
let lowerBoundIndex: number = -1;
let upperBoundIndex: number = 0;
/** @returns maybe valid lowerBound */
const getLowerBound = (
index: number,
): [ExcalidrawElement["index"] | undefined, number] => {
const lowerBound = elements[lowerBoundIndex]
? elements[lowerBoundIndex].index
: undefined;
// we are already iterating left to right, therefore there is no need for additional looping
const candidate = elements[index - 1]?.index;
if (
(!lowerBound && candidate) || // first lowerBound
(lowerBound && candidate && candidate > lowerBound) // next lowerBound
) {
// WARN: candidate's index could be higher or same as the current element's index
return [candidate, index - 1];
}
// cache hit! take the last lower bound
return [lowerBound, lowerBoundIndex];
};
/** @returns always valid upperBound */
const getUpperBound = (
index: number,
): [ExcalidrawElement["index"] | undefined, number] => {
const upperBound = elements[upperBoundIndex]
? elements[upperBoundIndex].index
: undefined;
// cache hit! don't let it find the upper bound again
if (upperBound && index < upperBoundIndex) {
return [upperBound, upperBoundIndex];
}
// set the current upperBoundIndex as the starting point
let i = upperBoundIndex;
while (++i < elements.length) {
const candidate = elements[i]?.index;
if (
(!upperBound && candidate) || // first upperBound
(upperBound && candidate && candidate > upperBound) // next upperBound
) {
return [candidate, i];
}
}
// we reached the end, sky is the limit
return [undefined, i];
};
let i = 0;
while (i < elements.length) {
const current = elements[i].index;
[lowerBound, lowerBoundIndex] = getLowerBound(i);
[upperBound, upperBoundIndex] = getUpperBound(i);
if (!isValidFractionalIndex(current, lowerBound, upperBound)) {
// push the lower bound index as the first item
const indicesGroup = [lowerBoundIndex, i];
while (++i < elements.length) {
const current = elements[i].index;
const [nextLowerBound, nextLowerBoundIndex] = getLowerBound(i);
const [nextUpperBound, nextUpperBoundIndex] = getUpperBound(i);
if (isValidFractionalIndex(current, nextLowerBound, nextUpperBound)) {
break;
}
// assign bounds only for the moved elements
[lowerBound, lowerBoundIndex] = [nextLowerBound, nextLowerBoundIndex];
[upperBound, upperBoundIndex] = [nextUpperBound, nextUpperBoundIndex];
indicesGroup.push(i);
}
// push the upper bound index as the last item
indicesGroup.push(upperBoundIndex);
indicesGroups.push(indicesGroup);
} else {
i++;
}
}
return indicesGroups;
};
const isValidFractionalIndex = (
index: ExcalidrawElement["index"] | undefined,
predecessor: ExcalidrawElement["index"] | undefined,
successor: ExcalidrawElement["index"] | undefined,
) => {
if (!index) {
return false;
}
if (predecessor && successor) {
return predecessor < index && index < successor;
}
if (!predecessor && successor) {
// first element
return index < successor;
}
if (predecessor && !successor) {
// last element
return predecessor < index;
}
// only element in the array
return !!index;
};
const generateIndices = (
elements: readonly ExcalidrawElement[],
indicesGroups: number[][],
) => {
const elementsUpdates = new Map<
ExcalidrawElement,
{ index: FractionalIndex }
>();
for (const indices of indicesGroups) {
const lowerBoundIndex = indices.shift()!;
const upperBoundIndex = indices.pop()!;
const fractionalIndices = generateNKeysBetween(
elements[lowerBoundIndex]?.index,
elements[upperBoundIndex]?.index,
indices.length,
) as FractionalIndex[];
for (let i = 0; i < indices.length; i++) {
const element = elements[indices[i]];
elementsUpdates.set(element, {
index: fractionalIndices[i],
});
}
}
return elementsUpdates;
};
const isOrderedElement = (
element: ExcalidrawElement,
): element is OrderedExcalidrawElement => {
// for now it's sufficient whether the index is there
// meaning, the element was already ordered in the past
// meaning, it is not a newly inserted element, not an unrestored element, etc.
// it does not have to mean that the index itself is valid
if (element.index) {
return true;
}
return false;
};
+1 -1
View File
@@ -29,7 +29,7 @@ import { ReadonlySetLike } from "./utility-types";
// --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = (
nextElements: readonly ExcalidrawElement[],
nextElements: ExcalidrawElement[],
oldElements: readonly ExcalidrawElement[],
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
) => {
-22
View File
@@ -355,24 +355,6 @@ export const getMaximumGroups = (
return Array.from(groups.values());
};
export const getNonDeletedGroupIds = (elements: ElementsMap) => {
const nonDeletedGroupIds = new Set<string>();
for (const [, element] of elements) {
// defensive check
if (element.isDeleted) {
continue;
}
// defensive fallback
for (const groupId of element.groupIds ?? []) {
nonDeletedGroupIds.add(groupId);
}
}
return nonDeletedGroupIds;
};
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
const allGroups = elements.flatMap((element) => element.groupIds);
const groupCount = new Map<string, number>();
@@ -387,7 +369,3 @@ export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
return maxGroup === elements.length;
};
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
return element.groupIds.length > 0;
};

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