Compare commits

..

1 Commits

439 changed files with 26303 additions and 70759 deletions

View File

@ -4,15 +4,8 @@
!.eslintrc.json
!.npmrc
!.prettierrc
!excalidraw-app/
!package.json
!public/
!packages/
!tsconfig.json
!yarn.lock
# keep (sub)sub directories at the end to exclude from explicit included
# e.g. ./packages/excalidraw/{dist,node_modules}
**/build
**/dist
**/node_modules

View File

@ -22,7 +22,7 @@ VITE_APP_DEV_ENABLE_SW=
# whether to disable live reload / HMR. Usuaully what you want to do when
# debugging Service Workers.
VITE_APP_DEV_DISABLE_LIVE_RELOAD=
VITE_APP_ENABLE_TRACKING=true
VITE_APP_DISABLE_TRACKING=true
FAST_REFRESH=false

View File

@ -14,4 +14,4 @@ VITE_APP_WS_SERVER_URL=https://oss-collab.excalidraw.com
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_ENABLE_TRACKING=false
VITE_APP_DISABLE_TRACKING=

View File

@ -2,7 +2,6 @@
"extends": ["@excalidraw/eslint-config", "react-app"],
"rules": {
"import/no-anonymous-default-export": "off",
"no-restricted-globals": "off",
"@typescript-eslint/consistent-type-imports": ["error", { "prefer": "type-imports", "disallowTypeAnnotations": false, "fixStyle": "separate-type-imports" }]
"no-restricted-globals": "off"
}
}

View File

@ -1,17 +1,14 @@
name: Tests
on:
pull_request:
push:
branches: master
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Setup Node.js 18.x
uses: actions/setup-node@v4
uses: actions/setup-node@v2
with:
node-version: 18.x
- name: Install and test

3
.gitignore vendored
View File

@ -25,5 +25,4 @@ packages/excalidraw/types
coverage
dev-dist
html
examples/**/bundle.*
meta*.json
examples/**/bundle.*

View File

@ -2,18 +2,16 @@ FROM node:18 AS build
WORKDIR /opt/node_app
COPY . .
# do not ignore optional dependencies:
# Error: Cannot find module @rollup/rollup-linux-x64-gnu
RUN yarn --network-timeout 600000
COPY package.json yarn.lock ./
RUN yarn --ignore-optional --network-timeout 600000
ARG NODE_ENV=production
COPY . .
RUN yarn build:app:docker
FROM nginx:1.24-alpine
FROM nginx:1.21-alpine
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
COPY --from=build /opt/node_app/build /usr/share/nginx/html
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1

View File

@ -13,7 +13,7 @@ Once the callback is triggered, you will need to store the api in state to acces
```jsx showLineNumbers
export default function App() {
const [excalidrawAPI, setExcalidrawAPI] = useState(null);
return <Excalidraw excalidrawAPI={(api)=> setExcalidrawAPI(api)} />;
return <Excalidraw excalidrawAPI={{(api)=> setExcalidrawAPI(api)}} />;
}
```
@ -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,

View File

@ -90,7 +90,7 @@ function App() {
<img src={canvasUrl} alt="" />
</div>
<div style={{ height: "400px" }}>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)}
<Excalidraw ref={(api) => setExcalidrawAPI(api)}
/>
</div>
</>

View File

@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
```jsx showLineNumbers
"use client";
import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw";
import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw";
import "@excalidraw/excalidraw/index.css";
@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor
height: 141.9765625,
},]));
return (
<div style={{height:"500px", width:"500px"}}>
<div style={{height:"500px", width:"500px"}}
<Excalidraw />
</div>
);

View File

@ -23,8 +23,8 @@
"clsx": "^1.2.1",
"docusaurus-plugin-sass": "0.2.3",
"prism-react-renderer": "^1.3.5",
"react": "18.2.0",
"react-dom": "18.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"sass": "1.57.1"
},
"devDependencies": {

View File

@ -59,7 +59,7 @@ pre a {
padding: 5px;
background: #70b1ec;
color: white;
font-weight: 700;
font-weight: bold;
border: none;
}

View File

@ -12,9 +12,9 @@ import type * as TExcalidraw from "@excalidraw/excalidraw";
import { nanoid } from "nanoid";
import type { ResolvablePromise } from "../utils";
import {
resolvablePromise,
ResolvablePromise,
distance2d,
fileOpen,
withBatchedUpdates,
@ -872,7 +872,7 @@ export default function App({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}
@ -893,7 +893,7 @@ export default function App({
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Excalifont";
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
}}

View File

@ -1,4 +1,4 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/dist/excalidraw/types";
import CustomFooter from "./CustomFooter";
import type * as TExcalidraw from "@excalidraw/excalidraw";

View File

@ -46,7 +46,7 @@ const elements: ExcalidrawElementSkeleton[] = [
];
export default {
elements,
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 5 },
appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
scrollToContent: true,
libraryItems: [
[

View File

@ -1,6 +1,6 @@
import { unstable_batchedUpdates } from "react-dom";
import { fileOpen as _fileOpen } from "browser-fs-access";
import { MIME_TYPES } from "@excalidraw/excalidraw";
import type { MIME_TYPES } from "@excalidraw/excalidraw";
import { AbortError } from "../../packages/excalidraw/errors";
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;

View File

@ -34,6 +34,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# copied assets
public/*.woff2

View File

@ -3,8 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm",
"dev": "yarn build:workspace && next dev -p 3005",
"build": "yarn build:workspace && next build",
"start": "next start -p 3006",
@ -13,13 +12,13 @@
"dependencies": {
"@excalidraw/excalidraw": "*",
"next": "14.1",
"react": "18.2.0",
"react-dom": "18.2.0"
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"path2d-polyfill": "2.0.1",
"typescript": "^5"
}

View File

@ -1,5 +1,4 @@
import dynamic from "next/dynamic";
import Script from "next/script";
import "../common.scss";
// Since client components get prerenderd on server as well hence importing the excalidraw stuff dynamically
@ -16,9 +15,7 @@ export default function Page() {
<>
<a href="/excalidraw-in-pages">Switch to Pages router</a>
<h1 className="page-title">App Router</h1>
<Script id="load-env-variables" strategy="beforeInteractive">
{`window["EXCALIDRAW_ASSET_PATH"] = window.origin;`}
</Script>
{/* @ts-expect-error - https://github.com/vercel/next.js/issues/42292 */}
<ExcalidrawWithClientOnly />
</>

View File

@ -7,7 +7,7 @@ a {
color: #1c7ed6;
font-size: 20px;
text-decoration: none;
font-weight: 500;
font-weight: 550;
}
.page-title {

View File

@ -1,2 +0,0 @@
# copied assets
public/*.woff2

View File

@ -11,7 +11,6 @@
<title>React App</title>
<script>
window.name = "codesandbox";
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<link rel="stylesheet" href="/dist/browser/dev/index.css" />
</head>

View File

@ -12,10 +12,8 @@
"typescript": "^5"
},
"scripts": {
"build:workspace": "yarn workspace @excalidraw/excalidraw run build:esm && yarn copy:assets",
"copy:assets": "cp ../../../packages/excalidraw/dist/browser/prod/excalidraw-assets/*.woff2 ./public",
"start": "yarn build:workspace && vite",
"build": "yarn build:workspace && vite build",
"start": "yarn workspace @excalidraw/excalidraw run build:esm && vite",
"build": "yarn workspace @excalidraw/excalidraw run build:esm && vite build",
"build:preview": "yarn build && vite preview --port 5002"
}
}

View File

@ -1,4 +1,5 @@
import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState } from "react";
import { trackEvent } from "../packages/excalidraw/analytics";
import { getDefaultAppState } from "../packages/excalidraw/appState";
@ -12,46 +13,46 @@ import {
VERSION_TIMEOUT,
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import type {
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
OrderedExcalidrawElement,
Theme,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
StoreAction,
reconcileElements,
} from "../packages/excalidraw";
import type {
} from "../packages/excalidraw/index";
import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../packages/excalidraw/types";
import type { ResolvablePromise } from "../packages/excalidraw/utils";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../packages/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
isExcalidrawPlusSignedUser,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
} from "./app_constants";
import type { CollabAPI } from "./collab/Collab";
import Collab, {
CollabAPI,
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
@ -63,12 +64,16 @@ import {
loadScene,
} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import type { RestoredDataState } from "../packages/excalidraw/data/restore";
import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
import {
ExportToExcalidrawPlus,
exportToExcalidrawPlus,
@ -77,13 +82,10 @@ import { updateStaleImageStatuses } from "./data/FileManager";
import { newElementWith } from "../packages/excalidraw/element/mutateElement";
import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
import { loadFilesFromFirebase } from "./data/firebase";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { LocalData } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
@ -91,73 +93,22 @@ import {
import { AppMainMenu } from "./components/AppMainMenu";
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
import { AppFooter } from "./components/AppFooter";
import { Provider, useAtom, useAtomValue } from "jotai";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import "./index.scss";
import type { ResolutionType } from "../packages/excalidraw/utility-types";
import { ResolutionType } from "../packages/excalidraw/utility-types";
import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
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,
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
import {
GithubIcon,
XBrandIcon,
DiscordIcon,
ExcalLogo,
usersIcon,
exportToPlus,
share,
youtubeIcon,
} from "../packages/excalidraw/components/icons";
import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
import { getPreferredLanguage } from "./app-language/language-detector";
import { useAppLangCode } from "./app-language/language-state";
polyfill();
window.EXCALIDRAW_THROTTLE_RENDER = true;
declare global {
interface BeforeInstallPromptEventChoiceResult {
outcome: "accepted" | "dismissed";
}
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
}
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
let pwaEvent: BeforeInstallPromptEvent | null = null;
// Adding a listener outside of the component as it may (?) need to be
// subscribed early to catch the event.
//
// Also note that it will fire only if certain heuristics are met (user has
// used the app for some time, etc.)
window.addEventListener(
"beforeinstallprompt",
(event: BeforeInstallPromptEvent) => {
// prevent Chrome <= 67 from automatically showing the prompt
event.preventDefault();
// cache for later use
pwaEvent = event;
},
);
let isSelfEmbedding = false;
if (window.self !== window.top) {
@ -172,6 +123,11 @@ if (window.self !== window.top) {
}
}
const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
const shareableLinkConfirmDialog = {
title: t("overwriteConfirm.modal.shareableLink.title"),
description: (
@ -296,7 +252,7 @@ const initializeScene = async (opts: {
},
elements: reconcileElements(
scene?.elements || [],
excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
excalidrawAPI.getSceneElementsIncludingDeleted(),
excalidrawAPI.getAppState(),
),
},
@ -317,15 +273,16 @@ const initializeScene = async (opts: {
return { scene: null, isExternalScene: false };
};
const detectedLangCode = languageDetector.detect() || defaultLang.code;
export const appLangCodeAtom = atom(
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
);
const ExcalidrawWrapper = () => {
const [errorMessage, setErrorMessage] = useState("");
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
const isCollabDisabled = isRunningInIframe();
const [appTheme, setAppTheme] = useAtom(appThemeAtom);
const { editorTheme } = useHandleAppTheme();
const [langCode, setLangCode] = useAppLangCode();
// initial state
// ---------------------------------------------------------------------------
@ -353,13 +310,10 @@ const ExcalidrawWrapper = () => {
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
useHandleLibrary({
excalidrawAPI,
adapter: LibraryIndexedDBAdapter,
// TODO maybe remove this in several months (shipped: 24-03-11)
migrationAdapter: LibraryLocalStorageMigrationAdapter,
getInitialLibraryItems: getLibraryItemsFromStorage,
});
useEffect(() => {
@ -457,7 +411,7 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
});
}
});
@ -481,17 +435,16 @@ const ExcalidrawWrapper = () => {
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
const localDataState = importFromLocalStorage();
const username = importUsernameFromLocalStorage();
setLangCode(getPreferredLanguage());
let langCode = languageDetector.detect() || defaultLang.code;
if (Array.isArray(langCode)) {
langCode = langCode[0];
}
setLangCode(langCode);
excalidrawAPI.updateScene({
...localDataState,
storeAction: StoreAction.UPDATE,
});
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
excalidrawAPI.updateLibrary({
libraryItems: data.libraryItems,
});
}
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
});
collabAPI?.setUsername(username || "");
}
@ -582,8 +535,29 @@ const ExcalidrawWrapper = () => {
};
}, [excalidrawAPI]);
useEffect(() => {
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,
) => {
@ -591,6 +565,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()) {
@ -616,7 +592,6 @@ const ExcalidrawWrapper = () => {
if (didChange) {
excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
});
}
}
@ -681,6 +656,15 @@ const ExcalidrawWrapper = () => {
);
};
const onLibraryChange = async (items: LibraryItems) => {
if (!items.length) {
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
return;
}
const serializedItems = JSON.stringify(items);
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
};
const isOffline = useAtomValue(isOfflineAtom);
const onCollabDialogOpen = useCallback(
@ -707,45 +691,6 @@ const ExcalidrawWrapper = () => {
);
}
const ExcalidrawPlusCommand = {
label: "Excalidraw+",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: ["plus", "cloud", "server"],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_LP
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
const ExcalidrawPlusAppCommand = {
label: "Sign up",
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
keywords: [
"excalidraw",
"plus",
"cloud",
"server",
"signin",
"login",
"signup",
],
perform: () => {
window.open(
`${
import.meta.env.VITE_APP_PLUS_APP
}?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
"_blank",
);
},
};
return (
<div
style={{ height: "100%" }}
@ -764,30 +709,27 @@ const ExcalidrawWrapper = () => {
toggleTheme: true,
export: {
onExportToBackend,
renderCustomUI: excalidrawAPI
? (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
name={excalidrawAPI.getName()}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI.updateScene({
appState: { openDialog: null },
});
}}
/>
);
}
: undefined,
renderCustomUI: (elements, appState, files) => {
return (
<ExportToExcalidrawPlus
elements={elements}
appState={appState}
files={files}
onError={(error) => {
excalidrawAPI?.updateScene({
appState: {
errorMessage: error.message,
},
});
}}
onSuccess={() => {
excalidrawAPI?.updateScene({
appState: { openDialog: null },
});
}}
/>
);
},
},
},
}}
@ -795,22 +737,20 @@ const ExcalidrawWrapper = () => {
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={editorTheme}
theme={theme}
renderTopRightUI={(isMobile) => {
if (isMobile || !collabAPI || isCollabDisabled) {
return null;
}
return (
<div className="top-right-ui">
{collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
</div>
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
);
}}
>
@ -818,8 +758,6 @@ const ExcalidrawWrapper = () => {
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
theme={appTheme}
setTheme={(theme) => setAppTheme(theme)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
@ -837,7 +775,6 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}}
>
@ -942,196 +879,6 @@ const ExcalidrawWrapper = () => {
{errorMessage}
</ErrorDialog>
)}
<CommandPalette
customCommandPaletteItems={[
{
label: t("labels.liveCollaboration"),
category: DEFAULT_CATEGORIES.app,
keywords: [
"team",
"multiplayer",
"share",
"public",
"session",
"invite",
],
icon: usersIcon,
perform: () => {
setShareDialogState({
isOpen: true,
type: "collaborationOnly",
});
},
},
{
label: t("roomDialog.button_stopSession"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!collabAPI?.isCollaborating(),
keywords: [
"stop",
"session",
"end",
"leave",
"close",
"exit",
"collaboration",
],
perform: () => {
if (collabAPI) {
collabAPI.stopCollaboration();
if (!collabAPI.isCollaborating()) {
setShareDialogState({ isOpen: false });
}
}
},
},
{
label: t("labels.share"),
category: DEFAULT_CATEGORIES.app,
predicate: true,
icon: share,
keywords: [
"link",
"shareable",
"readonly",
"export",
"publish",
"snapshot",
"url",
"collaborate",
"invite",
],
perform: async () => {
setShareDialogState({ isOpen: true, type: "share" });
},
},
{
label: "GitHub",
icon: GithubIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: [
"issues",
"bugs",
"requests",
"report",
"features",
"social",
"community",
],
perform: () => {
window.open(
"https://github.com/excalidraw/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.followUs"),
icon: XBrandIcon,
category: DEFAULT_CATEGORIES.links,
predicate: true,
keywords: ["twitter", "contact", "social", "community"],
perform: () => {
window.open(
"https://x.com/excalidraw",
"_blank",
"noopener noreferrer",
);
},
},
{
label: t("labels.discordChat"),
category: DEFAULT_CATEGORIES.links,
predicate: true,
icon: DiscordIcon,
keywords: [
"chat",
"talk",
"contact",
"bugs",
"requests",
"report",
"feedback",
"suggestions",
"social",
"community",
],
perform: () => {
window.open(
"https://discord.gg/UexuTaE",
"_blank",
"noopener noreferrer",
);
},
},
{
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
? [
{
...ExcalidrawPlusAppCommand,
label: "Sign in / Go to Excalidraw+",
},
]
: [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
{
label: t("overwriteConfirm.action.excalidrawPlus.button"),
category: DEFAULT_CATEGORIES.export,
icon: exportToPlus,
predicate: true,
keywords: ["plus", "export", "save", "backup"],
perform: () => {
if (excalidrawAPI) {
exportToExcalidrawPlus(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
excalidrawAPI.getName(),
);
}
},
},
{
...CommandPalette.defaultItems.toggleTheme,
perform: () => {
setAppTheme(
editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
);
},
},
{
label: t("labels.installPWA"),
category: DEFAULT_CATEGORIES.app,
predicate: () => !!pwaEvent,
perform: () => {
if (pwaEvent) {
pwaEvent.prompt();
pwaEvent.userChoice.then(() => {
// event cannot be reused, but we'll hopefully
// grab new one as the event should be fired again
pwaEvent = null;
});
}
},
},
]}
/>
</Excalidraw>
</div>
);

View File

@ -7,8 +7,8 @@ import {
import { DEFAULT_VERSION } from "../packages/excalidraw/constants";
import { t } from "../packages/excalidraw/i18n";
import { copyTextToSystemClipboard } from "../packages/excalidraw/clipboard";
import type { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import type { UIAppState } from "../packages/excalidraw/types";
import { NonDeletedExcalidrawElement } from "../packages/excalidraw/element/types";
import { UIAppState } from "../packages/excalidraw/types";
type StorageSizes = { scene: number; total: number };

View File

@ -1,25 +0,0 @@
import LanguageDetector from "i18next-browser-languagedetector";
import { defaultLang, languages } from "../../packages/excalidraw";
export const languageDetector = new LanguageDetector();
languageDetector.init({
languageUtils: {},
});
export const getPreferredLanguage = () => {
const detectedLanguages = languageDetector.detect();
const detectedLanguage = Array.isArray(detectedLanguages)
? detectedLanguages[0]
: detectedLanguages;
const initialLanguage =
(detectedLanguage
? // region code may not be defined if user uses generic preferred language
// (e.g. chinese vs instead of chinese-simplified)
languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
: null) || defaultLang.code;
return initialLanguage;
};

View File

@ -1,15 +0,0 @@
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
import { getPreferredLanguage, languageDetector } from "./language-detector";
export const appLangCodeAtom = atom(getPreferredLanguage());
export const useAppLangCode = () => {
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
useEffect(() => {
languageDetector.cacheUserLanguage(langCode);
}, [langCode]);
return [langCode, setLangCode] as const;
};

View File

@ -39,14 +39,10 @@ export const STORAGE_KEYS = {
LOCAL_STORAGE_ELEMENTS: "excalidraw",
LOCAL_STORAGE_APP_STATE: "excalidraw-state",
LOCAL_STORAGE_COLLAB: "excalidraw-collab",
LOCAL_STORAGE_LIBRARY: "excalidraw-library",
LOCAL_STORAGE_THEME: "excalidraw-theme",
VERSION_DATA_STATE: "version-dataState",
VERSION_FILES: "version-files",
IDB_LIBRARY: "excalidraw-library",
// do not use apart from migrations
__LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
} as const;
export const COOKIES = {

View File

@ -1,25 +1,22 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import type {
import {
ExcalidrawImperativeAPI,
SocketId,
} from "../../packages/excalidraw/types";
import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type {
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";
import type { Collaborator, Gesture } from "../../packages/excalidraw/types";
} from "../../packages/excalidraw/index";
import { Collaborator, Gesture } from "../../packages/excalidraw/types";
import {
assertNever,
preventUnload,
@ -36,14 +33,12 @@ import {
SYNC_FULL_SCENE_INTERVAL_MS,
WS_EVENTS,
} from "../app_constants";
import type {
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
generateCollaborationLinkData,
getCollaborationLink,
getSyncableElements,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import {
isSavedToFirebase,
@ -74,19 +69,18 @@ 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";
import { atom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
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);
@ -94,8 +88,6 @@ export const isOfflineAtom = atom(false);
interface CollabState {
errorMessage: string | null;
/** errors related to saving */
dialogNotifiedErrors: Record<string, boolean>;
username: string;
activeRoomLink: string | null;
}
@ -115,7 +107,7 @@ export interface CollabAPI {
setUsername: CollabInstance["setUsername"];
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setCollabError: CollabInstance["setErrorDialog"];
setErrorMessage: CollabInstance["setErrorMessage"];
}
interface CollabProps {
@ -137,7 +129,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
super(props);
this.state = {
errorMessage: null,
dialogNotifiedErrors: {},
username: importUsernameFromLocalStorage() || "",
activeRoomLink: null,
};
@ -206,7 +197,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
setUsername: this.setUsername,
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setCollabError: this.setErrorDialog,
setErrorMessage: this.setErrorMessage,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@ -279,39 +270,24 @@ class Collab extends PureComponent<CollabProps, CollabState> {
syncableElements: readonly SyncableExcalidrawElement[],
) => {
try {
const storedElements = await saveToFirebase(
const savedData = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
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)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed");
if (
!this.state.dialogNotifiedErrors[errorMessage] ||
!this.isCollaborating()
) {
this.setErrorDialog(errorMessage);
this.setState({
dialogNotifiedErrors: {
...this.state.dialogNotifiedErrors,
[errorMessage]: true,
},
});
}
if (this.isCollaborating()) {
this.setErrorIndicator(errorMessage);
}
this.setState({
// firestore doesn't return a specific error code when size exceeded
errorMessage: /is longer than.*?bytes/.test(error.message)
? t("errors.collabSaveFailed_sizeExceeded")
: t("errors.collabSaveFailed"),
});
console.error(error);
}
};
@ -320,7 +296,6 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
this.resetErrorIndicator(true);
this.saveCollabRoomToFirebase(
getSyncableElements(
@ -359,7 +334,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.updateScene({
elements,
storeAction: StoreAction.UPDATE,
commitToHistory: false,
});
}
};
@ -432,7 +407,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();
@ -458,11 +433,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");
@ -493,7 +464,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setErrorDialog(error.message);
this.setState({ errorMessage: error.message });
return null;
}
@ -504,13 +475,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));
@ -544,9 +516,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,
@ -557,7 +530,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: {
@ -705,15 +678,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,
);
@ -744,13 +719,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();
};
@ -883,7 +865,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
@ -894,7 +876,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
}
};
syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
syncElements = (elements: readonly ExcalidrawElement[]) => {
this.broadcastElements(elements);
this.queueSaveToFirebase();
};
@ -941,26 +923,8 @@ class Collab extends PureComponent<CollabProps, CollabState> {
getActiveRoomLink = () => this.state.activeRoomLink;
setErrorIndicator = (errorMessage: string | null) => {
appJotaiStore.set(collabErrorIndicatorAtom, {
message: errorMessage,
nonce: Date.now(),
});
};
resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
if (resetDialogNotifiedErrors) {
this.setState({
dialogNotifiedErrors: {},
});
}
};
setErrorDialog = (errorMessage: string | null) => {
this.setState({
errorMessage,
});
setErrorMessage = (errorMessage: string | null) => {
this.setState({ errorMessage });
};
render() {
@ -969,7 +933,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return (
<>
{errorMessage != null && (
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
{errorMessage}
</ErrorDialog>
)}

View File

@ -1,35 +0,0 @@
@import "../../packages/excalidraw/css/variables.module.scss";
.excalidraw {
.collab-errors-button {
width: 26px;
height: 26px;
margin-inline-end: 1rem;
color: var(--color-danger);
flex-shrink: 0;
}
.collab-errors-button-shake {
animation: strong-shake 0.15s 6;
}
@keyframes strong-shake {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
75% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
}

View File

@ -1,54 +0,0 @@
import { Tooltip } from "../../packages/excalidraw/components/Tooltip";
import { warning } from "../../packages/excalidraw/components/icons";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import "./CollabError.scss";
import { atom } from "jotai";
type ErrorIndicator = {
message: string | null;
/** used to rerun the useEffect responsible for animation */
nonce: number;
};
export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
message: null,
nonce: 0,
});
const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
const [isAnimating, setIsAnimating] = useState(false);
const clearAnimationRef = useRef<string | number | NodeJS.Timeout>();
useEffect(() => {
setIsAnimating(true);
clearAnimationRef.current = setTimeout(() => {
setIsAnimating(false);
}, 1000);
return () => {
clearTimeout(clearAnimationRef.current);
};
}, [collabError.message, collabError.nonce]);
if (!collabError.message) {
return null;
}
return (
<Tooltip label={collabError.message} long={true}>
<div
className={clsx("collab-errors-button", {
"collab-errors-button-shake": isAnimating,
})}
>
{warning}
</div>
</Tooltip>
);
};
CollabError.displayName = "CollabError";
export default CollabError;

View File

@ -1,15 +1,14 @@
import type {
import {
isSyncableElement,
SocketUpdateData,
SocketUpdateDataSource,
SyncableExcalidrawElement,
} from "../data";
import { isSyncableElement } from "../data";
import type { TCollabClass } from "./Collab";
import { TCollabClass } from "./Collab";
import type { 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 type {
import {
OnUserFollowedPayload,
SocketId,
UserIdleState,
@ -17,9 +16,10 @@ import type {
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,

View File

@ -65,18 +65,19 @@ export const RoomModal = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
setJustCopied(true);
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
}
ref.current?.select();
};

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

View File

@ -1,19 +1,12 @@
import React from "react";
import {
loginIcon,
ExcalLogo,
} from "../../packages/excalidraw/components/icons";
import type { 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 "../app-language/LanguageList";
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.Help />
<MainMenu.DefaultItems.ClearCanvas />
<MainMenu.Separator />
<MainMenu.ItemLink
icon={ExcalLogo}
icon={PlusPromoIcon}
href={`${
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={loginIcon}
href={`${import.meta.env.VITE_APP_PLUS_APP}${
isExcalidrawPlusSignedUser ? "" : "/sign-up"
}?utm_source=signin&utm_medium=app&utm_content=hamburger`}
className="highlighted"
>
{isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
</MainMenu.ItemLink>
<MainMenu.Separator />
<MainMenu.DefaultItems.ToggleTheme
allowSystemTheme
theme={props.theme}
onSelect={props.setTheme}
/>
<MainMenu.DefaultItems.ToggleTheme />
<MainMenu.ItemCustom>
<LanguageList style={{ width: "100%" }} />
</MainMenu.ItemCustom>

View File

@ -1,5 +1,5 @@
import React from "react";
import { loginIcon } 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={loginIcon}
icon={PlusPromoIcon}
>
Sign up
Try Excalidraw Plus!
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>

View File

@ -3,11 +3,11 @@ import { Card } from "../../packages/excalidraw/components/Card";
import { ToolButton } from "../../packages/excalidraw/components/ToolButton";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
import type {
import {
FileId,
NonDeletedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import type {
import {
AppState,
BinaryFileData,
BinaryFiles,
@ -30,7 +30,6 @@ export const exportToExcalidrawPlus = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: Partial<AppState>,
files: BinaryFiles,
name: string,
) => {
const firebase = await loadFirebaseStorage();
@ -54,7 +53,7 @@ export const exportToExcalidrawPlus = async (
.ref(`/migrations/scenes/${id}`)
.put(blob, {
customMetadata: {
data: JSON.stringify({ version: 2, name }),
data: JSON.stringify({ version: 2, name: appState.name }),
created: Date.now().toString(),
},
});
@ -90,10 +89,9 @@ export const ExportToExcalidrawPlus: React.FC<{
elements: readonly NonDeletedExcalidrawElement[];
appState: Partial<AppState>;
files: BinaryFiles;
name: string;
onError: (error: Error) => void;
onSuccess: () => void;
}> = ({ elements, appState, files, name, onError, onSuccess }) => {
}> = ({ elements, appState, files, onError, onSuccess }) => {
const { t } = useI18n();
return (
<Card color="primary">
@ -119,7 +117,7 @@ export const ExportToExcalidrawPlus: React.FC<{
onClick={async () => {
try {
trackEvent("export", "eplus", `ui (${getFrame()})`);
await exportToExcalidrawPlus(elements, appState, files, name);
await exportToExcalidrawPlus(elements, appState, files);
onSuccess();
} catch (error: any) {
console.error(error);

View File

@ -1,7 +1,7 @@
import oc from "open-color";
import React from "react";
import { THEME } from "../../packages/excalidraw/constants";
import type { Theme } from "../../packages/excalidraw/element/types";
import { Theme } from "../../packages/excalidraw/element/types";
// https://github.com/tholman/github-corners
export const GitHubCorner = React.memo(

View File

@ -1,7 +1,8 @@
import { useSetAtom } from "jotai";
import React from "react";
import { useI18n, languages } from "../../packages/excalidraw/i18n";
import { appLangCodeAtom } from "./language-state";
import { appLangCodeAtom } from "../App";
import { useI18n } from "../../packages/excalidraw/i18n";
import { languages } from "../../packages/excalidraw/i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const { t, langCode } = useI18n();

View File

@ -67,8 +67,6 @@ export class TopErrorBoundary extends React.Component<
window.open(
`https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
"_blank",
"noopener noreferrer",
);
}

View File

@ -1,15 +1,14 @@
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";
import type {
import {
ExcalidrawElement,
ExcalidrawImageElement,
FileId,
InitializedExcalidrawImageElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import type {
import {
BinaryFileData,
BinaryFileMetadata,
ExcalidrawImperativeAPI,
@ -239,6 +238,5 @@ export const updateStaleImageStatuses = (params: {
}
return element;
}),
storeAction: StoreAction.UPDATE,
});
};

View File

@ -10,29 +10,18 @@
* (localStorage, indexedDB).
*/
import {
createStore,
entries,
del,
getMany,
set,
setMany,
get,
} from "idb-keyval";
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import type { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import type {
import {
ExcalidrawElement,
FileId,
} from "../../packages/excalidraw/element/types";
import type {
import {
AppState,
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import type { MaybePromise } from "../../packages/excalidraw/utility-types";
import { debounce } from "../../packages/excalidraw/utils";
import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
import { FileManager } from "./FileManager";
@ -194,52 +183,3 @@ export class LocalData {
},
});
}
export class LibraryIndexedDBAdapter {
/** IndexedDB database and store name */
private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
/** library data store key */
private static key = "libraryData";
private static store = createStore(
`${LibraryIndexedDBAdapter.idb_name}-db`,
`${LibraryIndexedDBAdapter.idb_name}-store`,
);
static async load() {
const IDBData = await get<LibraryPersistedData>(
LibraryIndexedDBAdapter.key,
LibraryIndexedDBAdapter.store,
);
return IDBData || null;
}
static save(data: LibraryPersistedData): MaybePromise<void> {
return set(
LibraryIndexedDBAdapter.key,
data,
LibraryIndexedDBAdapter.store,
);
}
}
/** LS Adapter used only for migrating LS library data
* to indexedDB */
export class LibraryLocalStorageMigrationAdapter {
static load() {
const LSData = localStorage.getItem(
STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
);
if (LSData != null) {
const libraryItems: ImportedDataState["libraryItems"] =
JSON.parse(LSData);
if (libraryItems) {
return { libraryItems };
}
}
return null;
}
static clear() {
localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
}
}

View File

@ -1,13 +1,11 @@
import { reconcileElements } from "../../packages/excalidraw";
import type {
import {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { getSceneVersion } from "../../packages/excalidraw/element";
import type Portal from "../collab/Portal";
import Portal from "../collab/Portal";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import type {
import {
AppState,
BinaryFileData,
BinaryFileMetadata,
@ -20,11 +18,10 @@ import {
decryptData,
} from "../../packages/excalidraw/data/encryption";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import type { SyncableExcalidrawElement } from ".";
import { getSyncableElements } from ".";
import type { ResolutionType } from "../../packages/excalidraw/utility-types";
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
// -----------------------------------------------------------------------------
@ -233,7 +230,7 @@ export const saveToFirebase = async (
!socket ||
isSavedToFirebase(portal, elements)
) {
return null;
return false;
}
const firebase = await loadFirestore();
@ -241,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();
@ -304,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 (

View File

@ -9,39 +9,38 @@ import {
} from "../../packages/excalidraw/data/encryption";
import { serializeAsJSON } from "../../packages/excalidraw/data/json";
import { restore } from "../../packages/excalidraw/data/restore";
import type { ImportedDataState } from "../../packages/excalidraw/data/types";
import type { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { SceneBounds } from "../../packages/excalidraw/element/bounds";
import { isInvisiblySmallElement } from "../../packages/excalidraw/element/sizeHelpers";
import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks";
import type {
import {
ExcalidrawElement,
FileId,
OrderedExcalidrawElement,
} from "../../packages/excalidraw/element/types";
import { t } from "../../packages/excalidraw/i18n";
import type {
import {
AppState,
BinaryFileData,
BinaryFiles,
SocketId,
UserIdleState,
} from "../../packages/excalidraw/types";
import type { MakeBrand } from "../../packages/excalidraw/utility-types";
import { bytesToHexString } from "../../packages/excalidraw/utils";
import type { WS_SUBTYPES } from "../app_constants";
import {
DELETED_ELEMENT_TIMEOUT,
FILE_UPLOAD_MAX_BYTES,
ROOM_ID_BYTES,
WS_SUBTYPES,
} from "../app_constants";
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,
};
};

View File

@ -1,11 +1,12 @@
import type { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import type { AppState } from "../../packages/excalidraw/types";
import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
import { AppState } from "../../packages/excalidraw/types";
import {
clearAppStateForLocalStorage,
getDefaultAppState,
} from "../../packages/excalidraw/appState";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import { STORAGE_KEYS } from "../app_constants";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
export const saveUsernameToLocalStorage = (username: string) => {
try {
@ -87,13 +88,28 @@ export const getTotalStorageSize = () => {
try {
const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
const appStateSize = appState?.length || 0;
const collabSize = collab?.length || 0;
const librarySize = library?.length || 0;
return appStateSize + collabSize + getElementsStorageSize();
return appStateSize + collabSize + librarySize + getElementsStorageSize();
} catch (error: any) {
console.error(error);
return 0;
}
};
export const getLibraryItemsFromStorage = () => {
try {
const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
);
return libraryItems || [];
} catch (error) {
console.error(error);
return [];
}
};

View File

@ -20,7 +20,7 @@
name="description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
<!-- Open Graph / Facebook -->
<meta property="og:site_name" content="Excalidraw" />
@ -35,7 +35,7 @@
property="og:description"
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
/>
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
@ -51,7 +51,7 @@
/>
<meta
property="twitter:image"
content="https://excalidraw.com/og-image-3.png"
content="https://excalidraw.com/og-twitter-v2.png"
/>
<!-- General tags -->
@ -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 {
@ -95,13 +77,8 @@
color: #fff;
}
</style>
<!-- Warmup the connection for Google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!------------------------------------------------------------------------->
<% if (typeof PROD != 'undefined' && PROD == true) { %>
<% if ("%PROD%" === "true") { %>
<script>
// Redirect Excalidraw+ users which have auto-redirect enabled.
//
@ -120,55 +97,8 @@
window.location.href = "https://app.excalidraw.com";
}
</script>
<!-- Following placeholder is replaced during the build step -->
<!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
<% } else { %>
<script>
window.EXCALIDRAW_ASSET_PATH = window.origin;
</script>
<!-- in DEV we need to preload from the local server and without the hash -->
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Excalifont-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/Virgil-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="../packages/excalidraw/fonts/assets/ComicShanns-Regular.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<% } %>
<!-- For Nunito only preload the latin range, which should be good enough for now -->
<link
rel="preload"
href="https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhdTQ3j6zbXWjgeg.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<!-- Register Assistant as the UI font, before the scene inits -->
<link
rel="stylesheet"
href="../packages/excalidraw/fonts/assets/fonts.css"
type="text/css"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@ -176,8 +106,23 @@
<!-- Excalidraw version -->
<meta name="version" content="{version}" />
<% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
<link
rel="preload"
href="/Virgil.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link
rel="preload"
href="/Cascadia.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="/fonts/fonts.css" type="text/css" />
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%"==="true" ) { %>
<script>
{
const _WebSocket = window.WebSocket;
@ -194,6 +139,7 @@
</script>
<% } %>
<script>
window.EXCALIDRAW_ASSET_PATH = "/";
// setting this so that libraries installation reuses this window tab.
window.name = "_excalidraw";
</script>
@ -250,6 +196,7 @@
</header>
<div id="root"></div>
<script type="module" src="index.tsx"></script>
<% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>
<!-- 100% privacy friendly analytics -->
<script>
// need to load this script dynamically bcs. of iframe embed tracking
@ -282,5 +229,6 @@
}
</script>
<!-- end LEGACY GOOGLE ANALYTICS -->
<% } %>
</body>
</html>

View File

@ -4,13 +4,6 @@
&.theme--dark {
--color-primary-contrast-offset: #726dff; // to offset Chubb illusion
}
.top-right-ui {
display: flex;
justify-content: center;
align-items: flex-start;
}
.footer-center {
justify-content: flex-end;
margin-top: auto;
@ -25,7 +18,6 @@
margin-bottom: auto;
margin-inline-start: auto;
margin-inline-end: 0.6em;
z-index: var(--zIndex-layerUI);
svg {
width: 1.2rem;
@ -39,12 +31,8 @@
background-color: #ecfdf5;
color: #064e3c;
}
&.highlighted {
&.ExcalidrawPlus {
color: var(--color-promo);
font-weight: 700;
.dropdown-menu-item__icon g {
stroke-width: 2;
}
}
}
}

View File

@ -25,29 +25,16 @@
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"firebase": "8.3.3",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"vite-plugin-html": "3.2.2",
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"i18next-browser-languagedetector": "6.1.4",
"socket.io-client": "4.7.2"
},
"dependencies": {},
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",
"start": "yarn && vite",
"start:production": "yarn build && yarn serve",
"serve": "npx http-server build -a localhost -p 5001 -o",
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
"build:preview": "yarn build && vite preview --port 5000"
}
}

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { copyTextToSystemClipboard } from "../../packages/excalidraw/clipboard";
import { trackEvent } from "../../packages/excalidraw/analytics";
@ -18,12 +18,10 @@ import {
} from "../../packages/excalidraw/components/icons";
import { TextField } from "../../packages/excalidraw/components/TextField";
import { FilledButton } from "../../packages/excalidraw/components/FilledButton";
import type { CollabAPI } from "../collab/Collab";
import { activeRoomLinkAtom } from "../collab/Collab";
import { activeRoomLinkAtom, CollabAPI } from "../collab/Collab";
import { atom, useAtom, useAtomValue } from "jotai";
import "./ShareDialog.scss";
import { useUIAppState } from "../../packages/excalidraw/context/ui-appState";
type OnExportToBackend = () => void;
type ShareDialogType = "share" | "collaborationOnly";
@ -71,20 +69,20 @@ const ActiveRoomDialog = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
} catch (e) {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
collabAPI.setErrorMessage(error.message);
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
@ -277,14 +275,6 @@ export const ShareDialog = (props: {
}) => {
const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
const { openDialog } = useUIAppState();
useEffect(() => {
if (openDialog) {
setShareDialogState({ isOpen: false });
}
}, [openDialog, setShareDialogState]);
if (!shareDialogState.isOpen) {
return null;
}
@ -295,6 +285,6 @@ export const ShareDialog = (props: {
collabAPI={props.collabAPI}
onExportToBackend={props.onExportToBackend}
type={shareDialogState.type}
/>
></ShareDialogInner>
);
};

View File

@ -5,7 +5,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
class="welcome-screen-center"
>
<div
class="welcome-screen-center__logo excalifont welcome-screen-decor"
class="welcome-screen-center__logo virgil welcome-screen-decor"
>
<div
class="ExcalidrawLogo is-small"
@ -48,7 +48,7 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
</div>
</div>
<div
class="welcome-screen-center__heading welcome-screen-decor excalifont"
class="welcome-screen-center__heading welcome-screen-decor virgil"
>
All your data is saved locally in your browser.
</div>
@ -224,14 +224,24 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
fill="none"
stroke="none"
/>
<path
d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
<rect
height="4"
rx="1"
width="18"
x="3"
y="8"
/>
<line
x1="12"
x2="12"
y1="8"
y2="21"
/>
<path
d="M21 12h-13l3 -3"
d="M19 12v7a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-7"
/>
<path
d="M11 15l-3 -3"
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>
@ -239,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>

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

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

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 type { 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 };
};

View File

@ -4,8 +4,6 @@ import svgrPlugin from "vite-plugin-svgr";
import { ViteEjsPlugin } from "vite-plugin-ejs";
import { VitePWA } from "vite-plugin-pwa";
import checker from "vite-plugin-checker";
import { createHtmlPlugin } from "vite-plugin-html";
import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
// To load .env.local variables
const envVars = loadEnv("", `../`);
@ -23,14 +21,6 @@ export default defineConfig({
outDir: "build",
rollupOptions: {
output: {
assetFileNames(chunkInfo) {
if (chunkInfo?.name?.endsWith(".woff2")) {
// put on root so we are flexible about the CDN path
return '[name]-[hash][extname]';
}
return 'assets/[name]-[hash][extname]';
},
// Creating separate chunk for locales except for en and percentages.json so they
// can be cached at runtime and not merged with
// app precache. en.json and percentages.json are needed for first load
@ -44,13 +34,12 @@ export default defineConfig({
// Taking the substring after "locales/"
return `locales/${id.substring(index + 8)}`;
}
}
},
},
},
sourcemap: true,
},
plugins: [
woff2BrowserPlugin(),
react(),
checker({
typescript: true,
@ -200,9 +189,6 @@ export default defineConfig({
],
},
}),
createHtmlPlugin({
minify: true,
}),
],
publicDir: "../public",
});

View File

@ -1,7 +1,6 @@
{
"private": true,
"name": "excalidraw-monorepo",
"packageManager": "yarn@1.22.22",
"workspaces": [
"excalidraw-app",
"packages/excalidraw",
@ -9,15 +8,26 @@
"examples/excalidraw",
"examples/excalidraw/*"
],
"dependencies": {
"@excalidraw/random-username": "1.0.0",
"@sentry/browser": "6.2.5",
"@sentry/integrations": "6.2.5",
"firebase": "8.3.3",
"i18next-browser-languagedetector": "6.1.4",
"idb-keyval": "6.0.3",
"jotai": "1.13.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "7.21.11",
"@excalidraw/eslint-config": "1.0.3",
"@excalidraw/prettier-config": "1.0.2",
"@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",
@ -40,7 +50,7 @@
"vite-plugin-ejs": "1.7.0",
"vite-plugin-pwa": "0.17.4",
"vite-plugin-svgr": "2.4.0",
"vitest": "1.6.0",
"vitest": "1.0.1",
"vitest-canvas-mock": "0.3.2"
},
"engines": {
@ -50,9 +60,9 @@
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js",
"build": "yarn --cwd ./excalidraw-app build",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",
@ -77,8 +87,5 @@
"prerelease:excalidraw": "node scripts/prerelease.js",
"build:preview": "yarn build && vite preview --port 5000",
"release:excalidraw": "node scripts/release.js"
},
"resolutions": {
"@types/react": "18.2.0"
}
}

View File

@ -15,42 +15,14 @@ Please add the latest change on the top under the correct section.
### Features
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
- 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)
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
- `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).
- Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450)
### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
### 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.
- Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed
#### Bundler
@ -85,12 +57,10 @@ Please add the latest change on the top under the correct section.
- `appState.openDialog` type was changed from `null | string` to `null | { name: string }`. [#7336](https://github.com/excalidraw/excalidraw/pull/7336)
## 0.17.3 (2024-02-09)
## 0.17.1 (2023-11-28)
### Fixes
- Keep customData when converting to ExcalidrawElement. [#7656](https://github.com/excalidraw/excalidraw/pull/7656)
- Umd build for browser since it was breaking in v0.17.0 [#7349](https://github.com/excalidraw/excalidraw/pull/7349). Also make sure that when using `Vite`, the `process.env.IS_PREACT` is set as `"true"` (string) and not a boolean.
```
@ -99,16 +69,14 @@ define: {
}
```
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
- Bounds cached prematurely resulting in incorrectly rendered labels [#7339](https://github.com/excalidraw/excalidraw/pull/7339)
## Excalidraw Library
### Fixes
- Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343)
---
## 0.17.0 (2023-11-14)
### Features

View File

@ -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,
@ -59,5 +58,5 @@ export const actionAddToLibrary = register({
};
});
},
label: "labels.addToLibrary",
contextItemLabel: "labels.addToLibrary",
});

View File

@ -1,5 +1,4 @@
import type { Alignment } from "../align";
import { alignElements } from "../align";
import { alignElements, Alignment } from "../align";
import {
AlignBottomIcon,
AlignLeftIcon,
@ -11,19 +10,18 @@ import {
import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import type { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
const alignActionsPredicate = (
elements: readonly ExcalidrawElement[],
appState: UIAppState,
appState: AppState,
_: unknown,
app: AppClassProperties,
) => {
@ -61,8 +59,6 @@ const alignSelectedElements = (
export const actionAlignTop = register({
name: "alignTop",
label: "labels.alignTop",
icon: AlignTopIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -72,7 +68,7 @@ export const actionAlignTop = register({
position: "start",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@ -94,8 +90,6 @@ export const actionAlignTop = register({
export const actionAlignBottom = register({
name: "alignBottom",
label: "labels.alignBottom",
icon: AlignBottomIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -105,7 +99,7 @@ export const actionAlignBottom = register({
position: "end",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@ -127,8 +121,6 @@ export const actionAlignBottom = register({
export const actionAlignLeft = register({
name: "alignLeft",
label: "labels.alignLeft",
icon: AlignLeftIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -138,7 +130,7 @@ export const actionAlignLeft = register({
position: "start",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@ -160,8 +152,6 @@ export const actionAlignLeft = register({
export const actionAlignRight = register({
name: "alignRight",
label: "labels.alignRight",
icon: AlignRightIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -171,7 +161,7 @@ export const actionAlignRight = register({
position: "end",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@ -193,8 +183,6 @@ export const actionAlignRight = register({
export const actionAlignVerticallyCentered = register({
name: "alignVerticallyCentered",
label: "labels.centerVertically",
icon: CenterVerticallyIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -204,7 +192,7 @@ export const actionAlignVerticallyCentered = register({
position: "center",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (
@ -222,8 +210,6 @@ export const actionAlignVerticallyCentered = register({
export const actionAlignHorizontallyCentered = register({
name: "alignHorizontallyCentered",
label: "labels.centerHorizontally",
icon: CenterHorizontallyIcon,
trackEvent: { category: "element" },
predicate: alignActionsPredicate,
perform: (elements, appState, _, app) => {
@ -233,7 +219,7 @@ export const actionAlignHorizontallyCentered = register({
position: "center",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData, app }) => (

View File

@ -1,8 +1,8 @@
import {
BOUND_TEXT_PADDING,
ROUNDNESS,
TEXT_ALIGN,
VERTICAL_ALIGN,
TEXT_ALIGN,
} from "../constants";
import { isTextElement, newElement } from "../element";
import { mutateElement } from "../element/mutateElement";
@ -23,22 +23,20 @@ import {
isTextBindableContainer,
isUsingAdaptiveRadius,
} from "../element/typeChecks";
import type {
import {
ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextContainer,
ExcalidrawTextElement,
} from "../element/types";
import type { AppState } from "../types";
import type { Mutable } from "../utility-types";
import { arrayToMap, getFontString } from "../utils";
import { AppState } from "../types";
import { Mutable } from "../utility-types";
import { getFontString } from "../utils";
import { register } from "./register";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionUnbindText = register({
name: "unbindText",
label: "labels.unbindText",
contextItemLabel: "labels.unbindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -51,7 +49,7 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { width, height } = measureText(
const { width, height, baseline } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
@ -60,15 +58,12 @@ export const actionUnbindText = register({
element.id,
);
resetOriginalContainerCache(element.id);
const { x, y } = computeBoundTextPosition(
element,
boundTextElement,
elementsMap,
);
const { x, y } = computeBoundTextPosition(element, boundTextElement);
mutateElement(boundTextElement as ExcalidrawTextElement, {
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
x,
y,
@ -86,14 +81,14 @@ export const actionUnbindText = register({
return {
elements,
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
export const actionBindText = register({
name: "bindText",
label: "labels.bindText",
contextItemLabel: "labels.bindText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -142,7 +137,6 @@ export const actionBindText = register({
containerId: container.id,
verticalAlign: VERTICAL_ALIGN.MIDDLE,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
});
mutateElement(container, {
boundElements: (container.boundElements || []).concat({
@ -151,11 +145,7 @@ export const actionBindText = register({
}),
});
const originalContainerHeight = container.height;
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(textElement, container);
// overwritting the cache with original container height so
// it can be restored when unbind
updateOriginalContainerCache(container.id, originalContainerHeight);
@ -163,7 +153,7 @@ export const actionBindText = register({
return {
elements: pushTextAboveContainer(elements, container, textElement),
appState: { ...appState, selectedElementIds: { [container.id]: true } },
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
@ -183,8 +173,6 @@ const pushTextAboveContainer = (
(ele) => ele.id === container.id,
);
updatedElements.splice(containerIndex + 1, 0, textElement);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
@ -203,14 +191,12 @@ const pushContainerBelowText = (
(ele) => ele.id === textElement.id,
);
updatedElements.splice(textElementIndex, 0, container);
syncMovedIndices(updatedElements, arrayToMap([container, textElement]));
return updatedElements;
};
export const actionWrapTextInContainer = register({
name: "wrapTextInContainer",
label: "labels.createContainerFromText",
contextItemLabel: "labels.createContainerFromText",
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -297,22 +283,16 @@ export const actionWrapTextInContainer = register({
verticalAlign: VERTICAL_ALIGN.MIDDLE,
boundElements: null,
textAlign: TEXT_ALIGN.CENTER,
autoResize: true,
},
false,
);
redrawTextBoundingBox(
textElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(textElement, container);
updatedElements = pushContainerBelowText(
[...updatedElements, container],
container,
textElement,
);
containerIds[container.id] = true;
}
}
@ -323,7 +303,7 @@ export const actionWrapTextInContainer = register({
...appState,
selectedElementIds: containerIds,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});

View File

@ -1,30 +1,15 @@
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import {
handIcon,
MoonIcon,
SunIcon,
TrashIcon,
zoomAreaIcon,
ZoomInIcon,
ZoomOutIcon,
ZoomResetIcon,
} from "../components/icons";
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import {
CURSOR_TYPE,
MAX_ZOOM,
MIN_ZOOM,
THEME,
ZOOM_STEP,
} from "../constants";
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
import { getCommonBounds, getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { getNormalizedZoom } from "../scene";
import { centerScrollOn } from "../scene/scroll";
import { getStateForZoom } from "../scene/zoom";
import type { AppState, NormalizedZoomValue } from "../types";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey, updateActiveTool } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
@ -35,14 +20,11 @@ import {
isHandToolActive,
} from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import type { SceneBounds } from "../element/bounds";
import { SceneBounds } from "../element/bounds";
import { setCursor } from "../cursor";
import { StoreAction } from "../store";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
label: "labels.canvasBackground",
paletteName: "Change canvas background color",
trackEvent: false,
predicate: (elements, appState, props, app) => {
return (
@ -53,9 +35,7 @@ export const actionChangeViewBackgroundColor = register({
perform: (_, appState, value) => {
return {
appState: { ...appState, ...value },
storeAction: !!value.viewBackgroundColor
? StoreAction.CAPTURE
: StoreAction.NONE,
commitToHistory: !!value.viewBackgroundColor,
};
},
PanelComponent: ({ elements, appState, updateData, appProps }) => {
@ -79,9 +59,6 @@ export const actionChangeViewBackgroundColor = register({
export const actionClearCanvas = register({
name: "clearCanvas",
label: "labels.clearCanvas",
paletteName: "Clear canvas",
icon: TrashIcon,
trackEvent: { category: "canvas" },
predicate: (elements, appState, props, app) => {
return (
@ -104,23 +81,21 @@ export const actionClearCanvas = register({
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
stats: appState.stats,
showStats: appState.showStats,
pasteDialog: appState.pasteDialog,
activeTool:
appState.activeTool.type === "image"
? { ...appState.activeTool, type: "selection" }
: appState.activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
});
export const actionZoomIn = register({
name: "zoomIn",
label: "buttons.zoomIn",
viewMode: true,
icon: ZoomInIcon,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
return {
@ -136,17 +111,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);
}}
@ -159,8 +133,6 @@ export const actionZoomIn = register({
export const actionZoomOut = register({
name: "zoomOut",
label: "buttons.zoomOut",
icon: ZoomOutIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
@ -177,17 +149,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);
}}
@ -200,8 +171,6 @@ export const actionZoomOut = register({
export const actionResetZoom = register({
name: "resetZoom",
label: "buttons.resetZoom",
icon: ZoomResetIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_elements, appState, _, app) => {
@ -218,7 +187,7 @@ export const actionResetZoom = register({
),
userToFollow: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, appState }) => (
@ -293,8 +262,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 +308,7 @@ export const zoomToFitBounds = ({
scrollY,
zoom: { value: newZoomValue },
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
};
@ -371,8 +340,6 @@ export const zoomToFit = ({
// size, it won't be zoomed in.
export const actionZoomToFitSelectionInViewport = register({
name: "zoomToFitSelectionInViewport",
label: "labels.zoomToFitViewport",
icon: zoomAreaIcon,
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -396,8 +363,6 @@ export const actionZoomToFitSelectionInViewport = register({
export const actionZoomToFitSelection = register({
name: "zoomToFitSelection",
label: "helpDialog.zoomToSelection",
icon: zoomAreaIcon,
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
@ -420,8 +385,6 @@ export const actionZoomToFitSelection = register({
export const actionZoomToFit = register({
name: "zoomToFit",
label: "helpDialog.zoomToFit",
icon: zoomAreaIcon,
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) =>
@ -442,13 +405,6 @@ export const actionZoomToFit = register({
export const actionToggleTheme = register({
name: "toggleTheme",
label: (_, appState) => {
return appState.theme === THEME.DARK
? "buttons.lightMode"
: "buttons.darkMode";
},
keywords: ["toggle", "dark", "light", "mode", "theme"],
icon: (appState) => (appState.theme === THEME.LIGHT ? MoonIcon : SunIcon),
viewMode: true,
trackEvent: { category: "canvas" },
perform: (_, appState, value) => {
@ -458,7 +414,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,
@ -469,7 +425,6 @@ export const actionToggleTheme = register({
export const actionToggleEraserTool = register({
name: "toggleEraserTool",
label: "toolBar.eraser",
trackEvent: { category: "toolbar" },
perform: (elements, appState) => {
let activeTool: AppState["activeTool"];
@ -496,7 +451,7 @@ export const actionToggleEraserTool = register({
activeEmbeddable: null,
activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) => event.key === KEYS.E,
@ -504,11 +459,7 @@ export const actionToggleEraserTool = register({
export const actionToggleHandTool = register({
name: "toggleHandTool",
label: "toolBar.hand",
paletteName: "Toggle hand tool",
trackEvent: { category: "toolbar" },
icon: handIcon,
viewMode: false,
perform: (elements, appState, _, app) => {
let activeTool: AppState["activeTool"];
@ -535,7 +486,7 @@ export const actionToggleHandTool = register({
activeEmbeddable: null,
activeTool,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>

View File

@ -13,13 +13,9 @@ import { exportCanvas, prepareElementsForExport } from "../data/index";
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",
label: "labels.copy",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({
@ -32,7 +28,7 @@ export const actionCopy = register({
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
@ -41,16 +37,16 @@ export const actionCopy = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionPaste = register({
name: "paste",
label: "labels.paste",
trackEvent: { category: "element" },
perform: async (elements, appState, data, app) => {
let types;
@ -67,7 +63,7 @@ export const actionPaste = register({
if (isFirefox) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
@ -76,7 +72,7 @@ export const actionPaste = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
@ -89,7 +85,7 @@ export const actionPaste = register({
} catch (error: any) {
console.error(error);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
@ -98,34 +94,32 @@ export const actionPaste = register({
}
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined,
});
export const actionCut = register({
name: "cut",
label: "labels.cut",
icon: cutIcon,
trackEvent: { category: "element" },
perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState, null, app);
return actionDeleteSelected.perform(elements, appState);
},
contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
});
export const actionCopyAsSvg = register({
name: "copyAsSvg",
label: "labels.copyAsSvg",
icon: svgIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
@ -144,11 +138,10 @@ export const actionCopyAsSvg = register({
{
...appState,
exportingFrame,
name: app.getName(),
},
);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
} catch (error: any) {
console.error(error);
@ -157,25 +150,23 @@ export const actionCopyAsSvg = register({
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
predicate: (elements) => {
return probablySupportsClipboardWriteText && elements.length > 0;
},
keywords: ["svg", "clipboard", "copy"],
contextItemLabel: "labels.copyAsSvg",
});
export const actionCopyAsPng = register({
name: "copyAsPng",
label: "labels.copyAsPng",
icon: pngIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _data, app) => {
if (!app.canvas) {
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
const selectedElements = app.scene.getSelectedElements({
@ -193,7 +184,6 @@ export const actionCopyAsPng = register({
await exportCanvas("clipboard", exportedElements, appState, app.files, {
...appState,
exportingFrame,
name: app.getName(),
});
return {
appState: {
@ -209,7 +199,7 @@ export const actionCopyAsPng = register({
}),
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
} catch (error: any) {
console.error(error);
@ -218,20 +208,19 @@ export const actionCopyAsPng = register({
...appState,
errorMessage: error.message,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
predicate: (elements) => {
return probablySupportsClipboardBlob && elements.length > 0;
},
contextItemLabel: "labels.copyAsPng",
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
keywords: ["png", "clipboard", "copy"],
});
export const copyText = register({
name: "copyText",
label: "labels.copyText",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
@ -247,13 +236,9 @@ export const copyText = register({
return acc;
}, [])
.join("\n\n");
try {
copyTextToSystemClipboard(text);
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
copyTextToSystemClipboard(text);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
predicate: (elements, appState, _, app) => {
@ -267,5 +252,5 @@ export const copyText = register({
.some(isTextElement)
);
},
keywords: ["text", "clipboard", "copy"],
contextItemLabel: "labels.copyText",
});

View File

@ -4,26 +4,19 @@ import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { register } from "./register";
import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding";
import {
isBoundToContainer,
isElbowArrow,
isFrameLikeElement,
} from "../element/typeChecks";
import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons";
import { StoreAction } from "../store";
import { mutateElbowArrow } from "../element/routing";
const deleteSelectedElements = (
elements: readonly ExcalidrawElement[],
appState: AppState,
app: AppClassProperties,
) => {
const framesToBeDeleted = new Set(
getSelectedElements(
@ -35,26 +28,6 @@ const deleteSelectedElements = (
return {
elements: elements.map((el) => {
if (appState.selectedElementIds[el.id]) {
if (el.boundElements) {
el.boundElements.forEach((candidate) => {
const bound = app.scene
.getNonDeletedElementsMap()
.get(candidate.id);
if (bound && isElbowArrow(bound)) {
mutateElement(bound, {
startBinding:
el.id === bound.startBinding?.elementId
? null
: bound.startBinding,
endBinding:
el.id === bound.endBinding?.elementId
? null
: bound.endBinding,
});
mutateElbowArrow(bound, app.scene, bound.points);
}
});
}
return newElementWith(el, { isDeleted: true });
}
@ -99,10 +72,8 @@ const handleGroupEditingState = (
export const actionDeleteSelected = register({
name: "deleteSelectedElements",
label: "labels.delete",
icon: TrashIcon,
trackEvent: { category: "element", action: "delete" },
perform: (elements, appState, formData, app) => {
perform: (elements, appState) => {
if (appState.editingLinearElement) {
const {
elementId,
@ -110,8 +81,7 @@ export const actionDeleteSelected = register({
startBindingElement,
endBindingElement,
} = appState.editingLinearElement;
const elementsMap = app.scene.getNonDeletedElementsMap();
const element = LinearElementEditor.getElement(elementId, elementsMap);
const element = LinearElementEditor.getElement(elementId);
if (!element) {
return false;
}
@ -139,7 +109,7 @@ export const actionDeleteSelected = register({
...nextAppState,
editingLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
}
@ -156,11 +126,7 @@ export const actionDeleteSelected = register({
: endBindingElement,
};
LinearElementEditor.deletePoints(
element,
selectedPointsIndices,
app.scene,
);
LinearElementEditor.deletePoints(element, selectedPointsIndices);
return {
elements,
@ -175,11 +141,11 @@ export const actionDeleteSelected = register({
: [0],
},
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
let { elements: nextElements, appState: nextAppState } =
deleteSelectedElements(elements, appState, app);
deleteSelectedElements(elements, appState);
fixBindingsAfterDeletion(
nextElements,
elements.filter(({ id }) => appState.selectedElementIds[id]),
@ -195,14 +161,13 @@ export const actionDeleteSelected = register({
multiElement: null,
activeEmbeddable: null,
},
storeAction: isSomeElementSelected(
commitToHistory: isSomeElementSelected(
getNonDeletedElements(elements),
appState,
)
? StoreAction.CAPTURE
: StoreAction.NONE,
),
};
},
contextItemLabel: "labels.delete",
keyTest: (event, appState, elements) =>
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) &&
!event[KEYS.CTRL_OR_CMD],

View File

@ -3,17 +3,15 @@ import {
DistributeVerticallyIcon,
} from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import type { Distribution } from "../distribute";
import { distributeElements } from "../distribute";
import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import type { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n";
import { CODES, KEYS } from "../keys";
import { isSomeElementSelected } from "../scene";
import { StoreAction } from "../store";
import type { AppClassProperties, AppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { arrayToMap, getShortcutKey } from "../utils";
import { register } from "./register";
@ -51,7 +49,6 @@ const distributeSelectedElements = (
export const distributeHorizontally = register({
name: "distributeHorizontally",
label: "labels.distributeHorizontally",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -60,7 +57,7 @@ export const distributeHorizontally = register({
space: "between",
axis: "x",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
@ -82,7 +79,6 @@ export const distributeHorizontally = register({
export const distributeVertically = register({
name: "distributeVertically",
label: "labels.distributeVertically",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -91,7 +87,7 @@ export const distributeVertically = register({
space: "between",
axis: "y",
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>

View File

@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { register } from "./register";
import type { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { duplicateElement, getNonDeletedElements } from "../element";
import { isSomeElementSelected } from "../scene";
import { ToolButton } from "../components/ToolButton";
@ -12,9 +12,9 @@ import {
getSelectedGroupForElement,
getElementsInGroup,
} from "../groups";
import type { AppState } from "../types";
import { AppState } from "../types";
import { fixBindingsAfterDuplication } from "../element/binding";
import type { ActionResult } from "./types";
import { ActionResult } from "./types";
import { GRID_SIZE } from "../constants";
import {
bindTextToShapeAfterDuplication,
@ -31,21 +31,14 @@ import {
excludeElementsInFramesFromSelection,
getSelectedElements,
} from "../scene/selection";
import { syncMovedIndices } from "../fractionalIndex";
import { StoreAction } from "../store";
export const actionDuplicateSelection = register({
name: "duplicateSelection",
label: "labels.duplicateSelection",
icon: DuplicateIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
perform: (elements, appState) => {
// duplicate selected point(s) if editing a line
if (appState.editingLinearElement) {
const ret = LinearElementEditor.duplicateSelectedPoints(
appState,
app.scene,
);
const ret = LinearElementEditor.duplicateSelectedPoints(appState);
if (!ret) {
return false;
@ -54,15 +47,16 @@ export const actionDuplicateSelection = register({
return {
elements,
appState: ret.appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
return {
...duplicateElements(elements, appState),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.duplicateSelection",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D,
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@ -91,7 +85,6 @@ const duplicateElements = (
const newElements: ExcalidrawElement[] = [];
const oldElements: ExcalidrawElement[] = [];
const oldIdToDuplicatedId = new Map();
const duplicatedElementsMap = new Map<string, ExcalidrawElement>();
const duplicateAndOffsetElement = (element: ExcalidrawElement) => {
const newElement = duplicateElement(
@ -103,7 +96,6 @@ const duplicateElements = (
y: element.y + GRID_SIZE / 2,
},
);
duplicatedElementsMap.set(newElement.id, newElement);
oldIdToDuplicatedId.set(element.id, newElement.id);
oldElements.push(element);
newElements.push(newElement);
@ -241,10 +233,8 @@ const duplicateElements = (
}
// step (3)
const finalElements = syncMovedIndices(
finalElementsReversed.reverse(),
arrayToMap(newElements),
);
const finalElements = finalElementsReversed.reverse();
// ---------------------------------------------------------------------------

View File

@ -1,10 +1,7 @@
import { LockedIcon, UnlockedIcon } from "../components/icons";
import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import type { ExcalidrawElement } from "../element/types";
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";
@ -13,31 +10,11 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
export const actionToggleElementLock = register({
name: "toggleElementLock",
label: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
return shouldLock(selected)
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
icon: (appState, elements) => {
const selectedElements = getSelectedElements(elements, appState);
return shouldLock(selectedElements) ? LockedIcon : UnlockedIcon;
},
trackEvent: { category: "element" },
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
selectedElements.length > 0 &&
!selectedElements.some((element) => element.locked && element.frameId)
return !selectedElements.some(
(element) => element.locked && element.frameId,
);
},
perform: (elements, appState, _, app) => {
@ -67,9 +44,24 @@ export const actionToggleElementLock = register({
? null
: appState.selectedLinearElement,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: (elements, appState, app) => {
const selected = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false,
});
if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked
? "labels.elementLock.unlock"
: "labels.elementLock.lock";
}
return shouldLock(selected)
? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll";
},
keyTest: (event, appState, elements, app) => {
return (
event.key.toLocaleLowerCase() === KEYS.L &&
@ -85,16 +77,10 @@ export const actionToggleElementLock = register({
export const actionUnlockAllElements = register({
name: "unlockAllElements",
paletteName: "Unlock all elements",
trackEvent: { category: "canvas" },
viewMode: false,
icon: UnlockedIcon,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 0 &&
elements.some((element) => element.locked)
);
predicate: (elements) => {
return elements.some((element) => element.locked);
},
perform: (elements, appState) => {
const lockedElements = elements.filter((el) => el.locked);
@ -112,8 +98,8 @@ export const actionUnlockAllElements = register({
lockedElements.map((el) => [el.id, true]),
),
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
label: "labels.elementLock.unlockAll",
contextItemLabel: "labels.elementLock.unlockAll",
});

View File

@ -1,4 +1,4 @@
import { ExportIcon, questionCircle, saveAs } from "../components/icons";
import { questionCircle, saveAs } from "../components/icons";
import { ProjectName } from "../components/ProjectName";
import { ToolButton } from "../components/ToolButton";
import { Tooltip } from "../components/Tooltip";
@ -16,26 +16,24 @@ import { getSelectedElements, isSomeElementSelected } from "../scene";
import { getNonDeletedElements } from "../element";
import { isImageFileHandle } from "../data/blob";
import { nativeFileSystemSupported } from "../data/filesystem";
import type { Theme } from "../element/types";
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 }) => (
PanelComponent: ({ appState, updateData, appProps, data }) => (
<ProjectName
label={t("labels.fileTitle")}
value={app.getName()}
value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)}
isNameEditable={
typeof appProps.name === "undefined" && !appState.viewModeEnabled
}
ignoreFocus={data?.ignoreFocus ?? false}
/>
),
@ -43,12 +41,11 @@ export const actionChangeProjectName = register({
export const actionChangeExportScale = register({
name: "changeExportScale",
label: "imageExportDialog.scale",
trackEvent: { category: "export", action: "scale" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportScale: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ elements: allElements, appState, updateData }) => {
@ -93,12 +90,11 @@ export const actionChangeExportScale = register({
export const actionChangeExportBackground = register({
name: "changeExportBackground",
label: "imageExportDialog.label.withBackground",
trackEvent: { category: "export", action: "toggleBackground" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportBackground: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -113,12 +109,11 @@ export const actionChangeExportBackground = register({
export const actionChangeExportEmbedScene = register({
name: "changeExportEmbedScene",
label: "imageExportDialog.tooltip.embedScene",
trackEvent: { category: "export", action: "embedScene" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportEmbedScene: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (
@ -136,8 +131,6 @@ export const actionChangeExportEmbedScene = register({
export const actionSaveToActiveFile = register({
name: "saveToActiveFile",
label: "buttons.save",
icon: ExportIcon,
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
@ -151,16 +144,11 @@ export const actionSaveToActiveFile = register({
try {
const { fileHandle } = isImageFileHandle(appState.fileHandle)
? await resaveAsImageWithScene(
elements,
appState,
app.files,
app.getName(),
)
: await saveAsJSON(elements, appState, app.files, app.getName());
? await resaveAsImageWithScene(elements, appState, app.files)
: await saveAsJSON(elements, appState, app.files);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
fileHandle,
@ -182,7 +170,7 @@ export const actionSaveToActiveFile = register({
} else {
console.warn(error);
}
return { storeAction: StoreAction.NONE };
return { commitToHistory: false };
}
},
keyTest: (event) =>
@ -191,8 +179,6 @@ export const actionSaveToActiveFile = register({
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
label: "exportDialog.disk_title",
icon: ExportIcon,
viewMode: true,
trackEvent: { category: "export" },
perform: async (elements, appState, value, app) => {
@ -204,10 +190,9 @@ export const actionSaveFileToDisk = register({
fileHandle: null,
},
app.files,
app.getName(),
);
return {
storeAction: StoreAction.NONE,
commitToHistory: false,
appState: {
...appState,
openDialog: null,
@ -221,7 +206,7 @@ export const actionSaveFileToDisk = register({
} else {
console.warn(error);
}
return { storeAction: StoreAction.NONE };
return { commitToHistory: false };
}
},
keyTest: (event) =>
@ -242,7 +227,6 @@ export const actionSaveFileToDisk = register({
export const actionLoadScene = register({
name: "loadScene",
label: "buttons.load",
trackEvent: { category: "export" },
predicate: (elements, appState, props, app) => {
return (
@ -260,7 +244,7 @@ export const actionLoadScene = register({
elements: loadedElements,
appState: loadedAppState,
files,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
} catch (error: any) {
if (error?.name === "AbortError") {
@ -271,7 +255,7 @@ export const actionLoadScene = register({
elements,
appState: { ...appState, errorMessage: error.message },
files: app.files,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
},
@ -280,12 +264,11 @@ export const actionLoadScene = register({
export const actionExportWithDarkMode = register({
name: "exportWithDarkMode",
label: "imageExportDialog.label.darkMode",
trackEvent: { category: "export", action: "toggleTheme" },
perform: (_elements, appState, value) => {
return {
appState: { ...appState, exportWithDarkMode: value },
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData }) => (

View File

@ -1,6 +1,6 @@
import { KEYS } from "../keys";
import { isInvisiblySmallElement } from "../element";
import { arrayToMap, updateActiveTool } from "../utils";
import { updateActiveTool } from "../utils";
import { ToolButton } from "../components/ToolButton";
import { done } from "../components/icons";
import { t } from "../i18n";
@ -8,28 +8,28 @@ 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,
} from "../element/binding";
import { isBindingElement, isLinearElement } from "../element/typeChecks";
import type { AppState } from "../types";
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;
const elementsMap = scene.getNonDeletedElementsMap();
perform: (
elements,
appState,
_,
{ interactiveCanvas, focusContainer, scene },
) => {
if (appState.editingLinearElement) {
const { elementId, startBindingElement, endBindingElement } =
appState.editingLinearElement;
const element = LinearElementEditor.getElement(elementId, elementsMap);
const element = LinearElementEditor.getElement(elementId);
if (element) {
if (isBindingElement(element)) {
@ -37,8 +37,6 @@ export const actionFinalize = register({
element,
startBindingElement,
endBindingElement,
elementsMap,
scene,
);
}
return {
@ -50,9 +48,8 @@ export const actionFinalize = register({
...appState,
cursorButton: "up",
editingLinearElement: null,
selectedLinearElement: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
}
@ -93,9 +90,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,
);
@ -130,14 +125,12 @@ export const actionFinalize = register({
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
multiPointElement,
-1,
arrayToMap(elements),
);
maybeBindLinearElement(
multiPointElement,
appState,
Scene.getScene(multiPointElement)!,
{ x, y },
elementsMap,
elements,
);
}
}
@ -193,12 +186,11 @@ export const actionFinalize = register({
// To select the linear element when user has finished mutipoint editing
selectedLinearElement:
multiPointElement && isLinearElement(multiPointElement)
? new LinearElementEditor(multiPointElement)
? new LinearElementEditor(multiPointElement, scene)
: 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) =>

View File

@ -1,29 +1,26 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import type {
import {
ExcalidrawElement,
NonDeleted,
NonDeletedElementsMap,
NonDeletedSceneElementsMap,
} from "../element/types";
import { resizeMultipleElements } from "../element/resizeElements";
import type { 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",
label: "labels.flipHorizontal",
icon: flipHorizontal,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -33,22 +30,20 @@ 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,
contextItemLabel: "labels.flipHorizontal",
});
export const actionFlipVertical = register({
name: "flipVertical",
label: "labels.flipVertical",
icon: flipVertical,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
return {
@ -58,25 +53,24 @@ export const actionFlipVertical = register({
app.scene.getNonDeletedElementsMap(),
appState,
"vertical",
app,
),
appState,
app,
),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD],
contextItemLabel: "labels.flipVertical",
});
const flipSelectedElements = (
elements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
appState: Readonly<AppState>,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
@ -92,7 +86,6 @@ const flipSelectedElements = (
elementsMap,
appState,
flipDirection,
app,
);
const updatedElementsMap = arrayToMap(updatedElements);
@ -104,10 +97,9 @@ const flipSelectedElements = (
const flipElements = (
selectedElements: NonDeleted<ExcalidrawElement>[],
elementsMap: NonDeletedSceneElementsMap,
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap,
appState: AppState,
flipDirection: "horizontal" | "vertical",
app: AppClassProperties,
): ExcalidrawElement[] => {
const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
@ -117,20 +109,13 @@ const flipElements = (
elementsMap,
"nw",
true,
true,
flipDirection === "horizontal" ? maxX : minX,
flipDirection === "horizontal" ? minY : maxY,
app.scene,
);
bindOrUnbindLinearElements(
selectedElements.filter(isLinearElement),
elementsMap,
app.scene.getNonDeletedElements(),
app.scene,
isBindingEnabled(appState),
[],
);
(isBindingEnabled(appState)
? bindOrUnbindSelectedElements
: unbindLinearElements)(selectedElements);
return selectedElements;
};

View File

@ -1,20 +1,15 @@
import { getNonDeletedElements } from "../element";
import type { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
import { removeAllElementsFromFrame } from "../frame";
import { getFrameChildren } from "../frame";
import { KEYS } from "../keys";
import type { AppClassProperties, AppState, UIAppState } from "../types";
import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils";
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,
app: AppClassProperties,
) => {
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState);
return (
@ -24,7 +19,6 @@ const isSingleFrameSelected = (
export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame",
label: "labels.selectAllElementsInFrame",
trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => {
const selectedElement =
@ -45,23 +39,23 @@ export const actionSelectAllElementsInFrame = register({
return acc;
}, {} as Record<ExcalidrawElement["id"], true>),
},
storeAction: StoreAction.CAPTURE,
commitToHistory: false,
};
}
return {
elements,
appState,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.selectAllElementsInFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame",
label: "labels.removeAllElementsFromFrame",
trackEvent: { category: "history" },
perform: (elements, appState, _, app) => {
const selectedElement =
@ -76,23 +70,23 @@ export const actionRemoveAllElementsFromFrame = register({
[selectedElement.id]: true,
},
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
}
return {
elements,
appState,
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.removeAllElementsFromFrame",
predicate: (elements, appState, _, app) =>
isSingleFrameSelected(appState, app),
});
export const actionupdateFrameRendering = register({
name: "updateFrameRendering",
label: "labels.updateFrameRendering",
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) => {
@ -105,18 +99,16 @@ export const actionupdateFrameRendering = register({
enabled: !appState.frameRendering.enabled,
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.updateFrameRendering",
checked: (appState: AppState) => appState.frameRendering.enabled,
});
export const actionSetFrameAsActiveTool = register({
name: "setFrameAsActiveTool",
label: "toolBar.frame",
trackEvent: { category: "toolbar" },
icon: frameToolIcon,
viewMode: false,
perform: (elements, appState, _, app) => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
@ -135,7 +127,7 @@ export const actionSetFrameAsActiveTool = register({
type: "frame",
}),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) =>

View File

@ -17,12 +17,8 @@ import {
import { getNonDeletedElements } from "../element";
import { randomId } from "../random";
import { ToolButton } from "../components/ToolButton";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
OrderedExcalidrawElement,
} from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks";
import {
getElementsInResizingFrame,
@ -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) {
@ -67,8 +61,6 @@ const enableActionGroup = (
export const actionGroup = register({
name: "group",
label: "labels.group",
icon: (appState) => <GroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements({
@ -77,7 +69,7 @@ export const actionGroup = register({
});
if (selectedElements.length < 2) {
// nothing to group
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
// if everything is already grouped into 1 group, there is nothing to do
const selectedGroupIds = getSelectedGroupIds(appState);
@ -97,7 +89,7 @@ export const actionGroup = register({
]);
if (combinedSet.size === elementIdsInGroup.size) {
// no incremental ids in the selected ids
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
}
@ -139,19 +131,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,10 +153,11 @@ export const actionGroup = register({
getNonDeletedElements(nextElements),
),
},
elements: reorderedElements,
storeAction: StoreAction.CAPTURE,
elements: nextElements,
commitToHistory: true,
};
},
contextItemLabel: "labels.group",
predicate: (elements, appState, _, app) =>
enableActionGroup(elements, appState, app),
keyTest: (event) =>
@ -185,15 +177,11 @@ export const actionGroup = register({
export const actionUngroup = register({
name: "ungroup",
label: "labels.ungroup",
icon: (appState) => <UngroupIcon theme={appState.theme} />,
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
const groupIds = getSelectedGroupIds(appState);
const elementsMap = arrayToMap(elements);
if (groupIds.length === 0) {
return { appState, elements, storeAction: StoreAction.NONE };
return { appState, elements, commitToHistory: false };
}
let nextElements = [...elements];
@ -238,12 +226,7 @@ export const actionUngroup = register({
if (frame) {
nextElements = replaceAllElementsInFrame(
nextElements,
getElementsInResizingFrame(
nextElements,
frame,
appState,
elementsMap,
),
getElementsInResizingFrame(nextElements, frame, appState),
frame,
app,
);
@ -266,13 +249,14 @@ export const actionUngroup = register({
return {
appState: { ...appState, ...updateAppState },
elements: nextElements,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
keyTest: (event) =>
event.shiftKey &&
event[KEYS.CTRL_OR_CMD] &&
event.key === KEYS.G.toUpperCase(),
contextItemLabel: "labels.ungroup",
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
PanelComponent: ({ elements, appState, updateData }) => (

View File

@ -1,130 +1,105 @@
import type { Action, ActionResult } from "./types";
import { Action, ActionResult } from "./types";
import { UndoIcon, RedoIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import type { History } from "../history";
import { HistoryChangedEvent } from "../history";
import type { AppState } from "../types";
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 type { SceneElementsMap } from "../element/types";
import type { Store } from "../store";
import { 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, value, app) =>
writeData(appState, () =>
history.undo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
app.scene,
),
),
perform: (elements, appState) =>
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(
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
);
return (
<ToolButton
type="button"
icon={UndoIcon}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isUndoStackEmpty}
data-testid="button-undo"
/>
);
},
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, _, app) =>
writeData(appState, () =>
history.redo(
arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap`
appState,
store.snapshot,
app.scene,
),
),
perform: (elements, appState) =>
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(
history.isUndoStackEmpty,
history.isRedoStackEmpty,
),
);
return (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
disabled={isRedoStackEmpty}
data-testid="button-redo"
/>
);
},
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={RedoIcon}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
});

View File

@ -0,0 +1,45 @@
import { LinearElementEditor } from "../element/linearElementEditor";
import { isLinearElement } from "../element/typeChecks";
import { ExcalidrawLinearElement } from "../element/types";
import { register } from "./register";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement, app.scene);
return {
appState: {
...appState,
editingLinearElement,
},
commitToHistory: false,
};
},
contextItemLabel: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
return appState.editingLinearElement?.elementId === selectedElement.id
? "labels.lineEditor.exit"
: "labels.lineEditor.edit";
},
});

View File

@ -1,77 +0,0 @@
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
import { LinearElementEditor } from "../element/linearElementEditor";
import { isElbowArrow, isLinearElement } from "../element/typeChecks";
import type { ExcalidrawLinearElement } from "../element/types";
import { StoreAction } from "../store";
import { register } from "./register";
import { ToolButton } from "../components/ToolButton";
import { t } from "../i18n";
import { lineEditorIcon } from "../components/icons";
export const actionToggleLinearEditor = register({
name: "toggleLinearEditor",
category: DEFAULT_CATEGORIES.elements,
label: (elements, appState, app) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement | undefined;
return selectedElement?.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit";
},
keywords: ["line"],
trackEvent: {
category: "element",
},
predicate: (elements, appState, _, app) => {
const selectedElements = app.scene.getSelectedElements(appState);
if (
!appState.editingLinearElement &&
selectedElements.length === 1 &&
isLinearElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0])
) {
return true;
}
return false;
},
perform(elements, appState, _, app) {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
})[0] as ExcalidrawLinearElement;
const editingLinearElement =
appState.editingLinearElement?.elementId === selectedElement.id
? null
: new LinearElementEditor(selectedElement);
return {
appState: {
...appState,
editingLinearElement,
},
storeAction: StoreAction.CAPTURE,
};
},
PanelComponent: ({ appState, updateData, app }) => {
const selectedElement = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
})[0] as ExcalidrawLinearElement;
const label = t(
selectedElement.type === "arrow"
? "labels.lineEditor.editArrow"
: "labels.lineEditor.edit",
);
return (
<ToolButton
type="button"
icon={lineEditorIcon}
title={label}
aria-label={label}
onClick={() => updateData(null)}
/>
);
},
});

View File

@ -1,55 +0,0 @@
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
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";
export const actionLink = register({
name: "hyperlink",
label: (elements, appState) => getContextMenuLabel(elements, appState),
icon: LinkIcon,
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
openMenu: null,
},
storeAction: StoreAction.CAPTURE,
};
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
predicate: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState);
return selectedElements.length === 1;
},
PanelComponent: ({ elements, appState, updateData }) => {
const selectedElements = getSelectedElements(elements, appState);
return (
<ToolButton
type="button"
icon={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});

View File

@ -1,21 +1,19 @@
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
import { HamburgerMenuIcon, palette } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
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",
label: "buttons.menu",
trackEvent: { category: "menu" },
perform: (_, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "canvas" ? null : "canvas",
},
storeAction: StoreAction.NONE,
commitToHistory: false,
}),
PanelComponent: ({ appState, updateData }) => (
<ToolButton
@ -30,14 +28,13 @@ export const actionToggleCanvasMenu = register({
export const actionToggleEditMenu = register({
name: "toggleEditMenu",
label: "buttons.edit",
trackEvent: { category: "menu" },
perform: (_elements, appState) => ({
appState: {
...appState,
openMenu: appState.openMenu === "shape" ? null : "shape",
},
storeAction: StoreAction.NONE,
commitToHistory: false,
}),
PanelComponent: ({ elements, appState, updateData }) => (
<ToolButton
@ -56,8 +53,6 @@ export const actionToggleEditMenu = register({
export const actionShortcuts = register({
name: "toggleShortcuts",
label: "welcomeScreen.defaults.helpHint",
icon: HelpIconThin,
viewMode: true,
trackEvent: { category: "menu", action: "toggleHelpDialog" },
perform: (_elements, appState, _, { focusContainer }) => {
@ -74,7 +69,7 @@ export const actionShortcuts = register({
name: "help",
},
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
keyTest: (event) => event.key === KEYS.QUESTION_MARK,

View File

@ -1,20 +1,13 @@
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import type { GoToCollaboratorComponentProps } from "../components/UserList";
import {
eyeIcon,
microphoneIcon,
microphoneMutedIcon,
} from "../components/icons";
import { GoToCollaboratorComponentProps } from "../components/UserList";
import { eyeIcon } from "../components/icons";
import { t } from "../i18n";
import { StoreAction } from "../store";
import type { Collaborator } from "../types";
import { Collaborator } from "../types";
import { register } from "./register";
import clsx from "clsx";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
label: "Go to a collaborator",
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, collaborator: Collaborator) => {
@ -28,7 +21,7 @@ export const actionGoToCollaborator = register({
...appState,
userToFollow: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
}
@ -42,49 +35,18 @@ export const actionGoToCollaborator = register({
// Close mobile menu
openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
PanelComponent: ({ updateData, data, appState }) => {
const { socketId, collaborator, withName, isBeingFollowed } =
const { clientId, collaborator, withName, isBeingFollowed } =
data as GoToCollaboratorComponentProps;
const background = getClientColor(socketId, collaborator);
const statusClassNames = clsx({
"is-followed": isBeingFollowed,
"is-current-user": collaborator.isCurrentUser === true,
"is-speaking": collaborator.isSpeaking,
"is-in-call": collaborator.isInCall,
"is-muted": collaborator.isMuted,
});
const statusIconJSX = collaborator.isInCall ? (
collaborator.isSpeaking ? (
<div
className="UserList__collaborator-status-icon-speaking-indicator"
title={t("userList.hint.isSpeaking")}
>
<div />
<div />
<div />
</div>
) : collaborator.isMuted ? (
<div
className="UserList__collaborator-status-icon-microphone-muted"
title={t("userList.hint.micMuted")}
>
{microphoneMutedIcon}
</div>
) : (
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
)
) : null;
const background = getClientColor(clientId);
return withName ? (
<div
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
style={{ [`--avatar-size` as any]: "1.5rem" }}
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
onClick={() => updateData<Collaborator>(collaborator)}
>
<Avatar
@ -92,42 +54,32 @@ export const actionGoToCollaborator = register({
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
className={statusClassNames}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
/>
<div className="UserList__collaborator-name">
{collaborator.username}
</div>
<div className="UserList__collaborator-status-icons" aria-hidden>
{isBeingFollowed && (
<div
className="UserList__collaborator-status-icon-is-followed"
title={t("userList.hint.followStatus")}
>
{eyeIcon}
</div>
)}
{statusIconJSX}
<div
className="UserList__collaborator-follow-status-icon"
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
aria-hidden
>
{eyeIcon}
</div>
</div>
) : (
<div
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
>
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
className={statusClassNames}
/>
{statusIconJSX && (
<div className="UserList__collaborator-status-icon">
{statusIconJSX}
</div>
)}
</div>
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
/>
);
},
});

View File

@ -155,15 +155,13 @@ describe("element locking", () => {
});
const text = API.createElement({
type: "text",
fontFamily: FONT_FAMILY["Comic Shanns"],
fontFamily: FONT_FAMILY.Cascadia,
});
h.elements = [rect, text];
API.setSelectedElements([rect, text]);
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
"active",
);
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -2,19 +2,14 @@ import { KEYS } from "../keys";
import { register } from "./register";
import { selectGroupsForSelectedElements } from "../groups";
import { getNonDeletedElements, isTextElement } from "../element";
import type { ExcalidrawElement } from "../element/types";
import { ExcalidrawElement } from "../element/types";
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",
label: "labels.selectAll",
icon: selectAllIcon,
trackEvent: { category: "canvas" },
viewMode: false,
perform: (elements, appState, value, app) => {
if (appState.editingLinearElement) {
return false;
@ -48,11 +43,12 @@ export const actionSelectAll = register({
// single linear element selected
Object.keys(selectedElementIds).length === 1 &&
isLinearElement(elements[0])
? new LinearElementEditor(elements[0])
? new LinearElementEditor(elements[0], app.scene)
: null,
},
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.selectAll",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A,
});

View File

@ -12,7 +12,10 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_TEXT_ALIGN,
} from "../constants";
import { getBoundTextElement } from "../element/textElement";
import {
getBoundTextElement,
getDefaultLineHeight,
} from "../element/textElement";
import {
hasBoundTextElement,
canApplyRoundnessTypeToElement,
@ -21,18 +24,13 @@ import {
isArrowElement,
} from "../element/typeChecks";
import { getSelectedElements } from "../scene";
import type { ExcalidrawTextElement } from "../element/types";
import { paintIcon } from "../components/icons";
import { StoreAction } from "../store";
import { getLineHeight } from "../fonts";
import { ExcalidrawTextElement } from "../element/types";
// `copiedStyles` is exported only for tests.
export let copiedStyles: string = "{}";
export const actionCopyStyles = register({
name: "copyStyles",
label: "labels.copyStyles",
icon: paintIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsCopied = [];
@ -53,24 +51,23 @@ export const actionCopyStyles = register({
...appState,
toast: { message: t("toast.copyStyles") },
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
contextItemLabel: "labels.copyStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.C,
});
export const actionPasteStyles = register({
name: "pasteStyles",
label: "labels.pasteStyles",
icon: paintIcon,
trackEvent: { category: "element" },
perform: (elements, appState, formData, app) => {
const elementsCopied = JSON.parse(copiedStyles);
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, {
@ -120,7 +117,7 @@ export const actionPasteStyles = register({
DEFAULT_TEXT_ALIGN,
lineHeight:
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
getLineHeight(fontFamily),
getDefaultLineHeight(fontFamily),
});
let container = null;
if (newElement.containerId) {
@ -131,11 +128,7 @@ export const actionPasteStyles = register({
element.id === newElement.containerId,
) || null;
}
redrawTextBoundingBox(
newElement,
container,
app.scene.getNonDeletedElementsMap(),
);
redrawTextBoundingBox(newElement, container);
}
if (
@ -159,9 +152,10 @@ export const actionPasteStyles = register({
}
return element;
}),
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.pasteStyles",
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
});

View File

@ -1,48 +0,0 @@
import { isTextElement } from "../element";
import { newElementWith } from "../element/mutateElement";
import { measureText } from "../element/textElement";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import type { AppClassProperties } from "../types";
import { getFontString } from "../utils";
import { register } from "./register";
export const actionTextAutoResize = register({
name: "autoResize",
label: "labels.autoResize",
icon: null,
trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
const selectedElements = getSelectedElements(elements, appState);
return (
selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
!selectedElements[0].autoResize
);
},
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
return {
appState,
elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) {
const metrics = measureText(
element.originalText,
getFontString(element),
element.lineHeight,
);
return newElementWith(element, {
autoResize: true,
width: metrics.width,
height: metrics.height,
text: element.originalText,
});
}
return element;
}),
storeAction: StoreAction.CAPTURE,
};
},
});

View File

@ -1,15 +1,10 @@
import { CODES, KEYS } from "../keys";
import { register } from "./register";
import { GRID_SIZE } from "../constants";
import type { AppState } from "../types";
import { gridIcon } from "../components/icons";
import { StoreAction } from "../store";
import { AppState } from "../types";
export const actionToggleGridMode = register({
name: "gridMode",
icon: gridIcon,
keywords: ["snap"],
label: "labels.toggleGrid",
viewMode: true,
trackEvent: {
category: "canvas",
@ -22,12 +17,13 @@ export const actionToggleGridMode = register({
gridSize: this.checked!(appState) ? null : GRID_SIZE,
objectsSnapModeEnabled: false,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState: AppState) => appState.gridSize !== null,
predicate: (element, appState, props) => {
return typeof props.gridModeEnabled === "undefined";
},
contextItemLabel: "labels.showGrid",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
});

View File

@ -1,13 +1,9 @@
import { magnetIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleObjectsSnapMode = register({
name: "objectsSnapMode",
label: "buttons.objectsSnapMode",
icon: magnetIcon,
viewMode: false,
viewMode: true,
trackEvent: {
category: "canvas",
predicate: (appState) => !appState.objectsSnapModeEnabled,
@ -19,13 +15,14 @@ export const actionToggleObjectsSnapMode = register({
objectsSnapModeEnabled: !this.checked!(appState),
gridSize: null,
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.objectsSnapModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.objectsSnapModeEnabled === "undefined";
},
contextItemLabel: "buttons.objectsSnapMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.S,
});

View File

@ -1,26 +1,21 @@
import { register } from "./register";
import { CODES, KEYS } from "../keys";
import { abacusIcon } from "../components/icons";
import { StoreAction } from "../store";
export const actionToggleStats = register({
name: "stats",
label: "stats.fullTitle",
icon: abacusIcon,
paletteName: "Toggle stats",
viewMode: true,
trackEvent: { category: "menu" },
keywords: ["edit", "attributes", "customize"],
perform(elements, appState) {
return {
appState: {
...appState,
stats: { ...appState.stats, open: !this.checked!(appState) },
showStats: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.stats.open,
checked: (appState) => appState.showStats,
contextItemLabel: "stats.title",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
});

View File

@ -1,13 +1,8 @@
import { eyeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleViewMode = register({
name: "viewMode",
label: "labels.viewMode",
paletteName: "Toggle view mode",
icon: eyeIcon,
viewMode: true,
trackEvent: {
category: "canvas",
@ -19,13 +14,14 @@ export const actionToggleViewMode = register({
...appState,
viewModeEnabled: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.viewModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.viewModeEnabled === "undefined";
},
contextItemLabel: "labels.viewMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
});

View File

@ -1,13 +1,8 @@
import { coffeeIcon } from "../components/icons";
import { CODES, KEYS } from "../keys";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionToggleZenMode = register({
name: "zenMode",
label: "buttons.zenMode",
icon: coffeeIcon,
paletteName: "Toggle zen mode",
viewMode: true,
trackEvent: {
category: "canvas",
@ -19,13 +14,14 @@ export const actionToggleZenMode = register({
...appState,
zenModeEnabled: !this.checked!(appState),
},
storeAction: StoreAction.NONE,
commitToHistory: false,
};
},
checked: (appState) => appState.zenModeEnabled,
predicate: (elements, appState, appProps) => {
return typeof appProps.zenModeEnabled === "undefined";
},
contextItemLabel: "buttons.zenMode",
keyTest: (event) =>
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
});

View File

@ -1,3 +1,4 @@
import React from "react";
import {
moveOneLeft,
moveOneRight,
@ -15,21 +16,18 @@ import {
SendToBackIcon,
} from "../components/icons";
import { isDarwin } from "../constants";
import { StoreAction } from "../store";
export const actionSendBackward = register({
name: "sendBackward",
label: "labels.sendBackward",
keywords: ["move down", "zindex", "layer"],
icon: SendBackwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneLeft(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.sendBackward",
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
@ -49,17 +47,15 @@ export const actionSendBackward = register({
export const actionBringForward = register({
name: "bringForward",
label: "labels.bringForward",
keywords: ["move up", "zindex", "layer"],
icon: BringForwardIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveOneRight(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.bringForward",
keyPriority: 40,
keyTest: (event) =>
event[KEYS.CTRL_OR_CMD] &&
@ -79,17 +75,15 @@ export const actionBringForward = register({
export const actionSendToBack = register({
name: "sendToBack",
label: "labels.sendToBack",
keywords: ["move down", "zindex", "layer"],
icon: SendToBackIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllLeft(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.sendToBack",
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&
@ -116,18 +110,16 @@ export const actionSendToBack = register({
export const actionBringToFront = register({
name: "bringToFront",
label: "labels.bringToFront",
keywords: ["move up", "zindex", "layer"],
icon: BringToFrontIcon,
trackEvent: { category: "element" },
perform: (elements, appState) => {
return {
elements: moveAllRight(elements, appState),
appState,
storeAction: StoreAction.CAPTURE,
commitToHistory: true,
};
},
contextItemLabel: "labels.bringToFront",
keyTest: (event) =>
isDarwin
? event[KEYS.CTRL_OR_CMD] &&

View File

@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "./actionLink";
export { actionLink } from "../element/Hyperlink";
export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor";

View File

@ -1,5 +1,5 @@
import React from "react";
import type {
import {
Action,
UpdaterFn,
ActionName,
@ -7,11 +7,8 @@ import type {
PanelComponentProps,
ActionSource,
} from "./types";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "../element/types";
import type { AppClassProperties, AppState } from "../types";
import { ExcalidrawElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { trackEvent } from "../analytics";
import { isPromiseLike } from "../utils";
@ -49,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) => {

View File

@ -1,4 +1,4 @@
import type { Action } from "./types";
import { Action } from "./types";
export let actions: readonly Action[] = [];

View File

@ -1,8 +1,8 @@
import { isDarwin } from "../constants";
import { t } from "../i18n";
import type { SubtypeOf } from "../utility-types";
import { SubtypeOf } from "../utility-types";
import { getShortcutKey } from "../utils";
import type { ActionName } from "./types";
import { ActionName } from "./types";
export type ShortcutName =
| SubtypeOf<
@ -36,22 +36,9 @@ export type ShortcutName =
| "flipVertical"
| "hyperlink"
| "toggleElementLock"
| "resetZoom"
| "zoomOut"
| "zoomIn"
| "zoomToFit"
| "zoomToFitSelectionInViewport"
| "zoomToFitSelection"
| "toggleEraserTool"
| "toggleHandTool"
| "setFrameAsActiveTool"
| "saveFileToDisk"
| "saveToActiveFile"
| "toggleShortcuts"
>
| "saveScene"
| "imageExport"
| "commandPalette";
| "imageExport";
const shortcutMap: Record<ShortcutName, string[]> = {
toggleTheme: [getShortcutKey("Shift+Alt+D")],
@ -59,10 +46,6 @@ const shortcutMap: Record<ShortcutName, string[]> = {
loadScene: [getShortcutKey("CtrlOrCmd+O")],
clearCanvas: [getShortcutKey("CtrlOrCmd+Delete")],
imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")],
commandPalette: [
getShortcutKey("CtrlOrCmd+/"),
getShortcutKey("CtrlOrCmd+Shift+P"),
],
cut: [getShortcutKey("CtrlOrCmd+X")],
copy: [getShortcutKey("CtrlOrCmd+C")],
paste: [getShortcutKey("CtrlOrCmd+V")],
@ -100,24 +83,10 @@ const shortcutMap: Record<ShortcutName, string[]> = {
viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
resetZoom: [getShortcutKey("CtrlOrCmd+0")],
zoomOut: [getShortcutKey("CtrlOrCmd+-")],
zoomIn: [getShortcutKey("CtrlOrCmd++")],
zoomToFitSelection: [getShortcutKey("Shift+3")],
zoomToFit: [getShortcutKey("Shift+1")],
zoomToFitSelectionInViewport: [getShortcutKey("Shift+2")],
toggleEraserTool: [getShortcutKey("E")],
toggleHandTool: [getShortcutKey("H")],
setFrameAsActiveTool: [getShortcutKey("F")],
saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
toggleShortcuts: [getShortcutKey("?")],
};
export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {
export const getShortcutFromShortcutName = (name: ShortcutName) => {
const shortcuts = shortcutMap[name];
// if multiple shortcuts available, take the first one
return shortcuts && shortcuts.length > 0
? shortcuts[idx] || shortcuts[0]
: "";
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
};

View File

@ -1,24 +1,14 @@
import type React from "react";
import type {
ExcalidrawElement,
OrderedExcalidrawElement,
} from "../element/types";
import type {
import React from "react";
import { ExcalidrawElement } from "../element/types";
import {
AppClassProperties,
AppState,
ExcalidrawProps,
BinaryFiles,
UIAppState,
} from "../types";
import type { MarkOptional } from "../utility-types";
import type { StoreActionType } from "../store";
import { MarkOptional } from "../utility-types";
export type ActionSource =
| "ui"
| "keyboard"
| "contextMenu"
| "api"
| "commandPalette";
export type ActionSource = "ui" | "keyboard" | "contextMenu" | "api";
/** if false, the action should be prevented */
export type ActionResult =
@ -29,13 +19,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,
@ -70,7 +61,6 @@ export type ActionName =
| "changeSloppiness"
| "changeStrokeStyle"
| "changeArrowhead"
| "changeArrowType"
| "changeOpacity"
| "changeFontSize"
| "toggleCanvasMenu"
@ -134,10 +124,7 @@ export type ActionName =
| "setFrameAsActiveTool"
| "setEmbeddableAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer"
| "commandPalette"
| "autoResize"
| "elementStats";
| "wrapTextInContainer";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
@ -150,20 +137,6 @@ export type PanelComponentProps = {
export interface Action {
name: ActionName;
label:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
) => string);
keywords?: string[];
icon?:
| React.ReactNode
| ((
appState: UIAppState,
elements: readonly ExcalidrawElement[],
) => React.ReactNode);
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
@ -173,6 +146,13 @@ export interface Action {
elements: readonly ExcalidrawElement[],
app: AppClassProperties,
) => boolean;
contextItemLabel?:
| string
| ((
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
app: AppClassProperties,
) => string);
predicate?: (
elements: readonly ExcalidrawElement[],
appState: AppState,

View File

@ -1,7 +1,6 @@
import type { ElementsMap, ExcalidrawElement } from "./element/types";
import { ElementsMap, ExcalidrawElement } from "./element/types";
import { newElementWith } from "./element/mutateElement";
import type { BoundingBox } from "./element/bounds";
import { getCommonBoundingBox } from "./element/bounds";
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
import { getMaximumGroups } from "./groups";
export interface Alignment {

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 = new Set(["command_palette"]);
const ALLOWED_CATEGORIES_TO_TRACK = ["ai"] as string[];
export const trackEvent = (
category: string,
@ -9,20 +9,17 @@ export const trackEvent = (
value?: number,
) => {
try {
// prettier-ignore
if (
typeof window === "undefined" ||
import.meta.env.VITE_WORKER_ID ||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
typeof window === "undefined"
|| import.meta.env.VITE_WORKER_ID
// comment out to debug locally
|| import.meta.env.PROD
) {
return;
}
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
return;
}
if (import.meta.env.DEV) {
// comment out to debug in dev
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
return;
}

View File

@ -1,7 +1,6 @@
import type { LaserPointerOptions } from "@excalidraw/laser-pointer";
import { LaserPointer } from "@excalidraw/laser-pointer";
import type { AnimationFrameHandler } from "./animation-frame-handler";
import type { AppState } from "./types";
import { LaserPointer, LaserPointerOptions } from "@excalidraw/laser-pointer";
import { AnimationFrameHandler } from "./animation-frame-handler";
import { AppState } from "./types";
import { getSvgPathFromStroke, sceneCoordsToViewportCoords } from "./utils";
import type App from "./components/App";
import { SVG_NS } from "./constants";

View File

@ -1,15 +1,15 @@
import { COLOR_PALETTE } from "./colors";
import {
ARROW_TYPE,
DEFAULT_ELEMENT_PROPS,
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_TEXT_ALIGN,
EXPORT_SCALES,
STATS_PANELS,
THEME,
} from "./constants";
import type { AppState, NormalizedZoomValue } from "./types";
import { t } from "./i18n";
import { AppState, NormalizedZoomValue } from "./types";
import { getDateTime } from "./utils";
const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
? devicePixelRatio
@ -34,11 +34,9 @@ export const getDefaultAppState = (): Omit<
currentItemStartArrowhead: null,
currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
currentItemRoundness: "round",
currentItemArrowType: ARROW_TYPE.round,
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentHoveredFontFamily: null,
cursorButton: "up",
activeEmbeddable: null,
draggingElement: null,
@ -67,7 +65,7 @@ export const getDefaultAppState = (): Omit<
isRotating: false,
lastPointerDownWith: "mouse",
multiElement: null,
name: null,
name: `${t("labels.untitled")}-${getDateTime()}`,
contextMenu: null,
openMenu: null,
openPopup: null,
@ -84,10 +82,7 @@ export const getDefaultAppState = (): Omit<
selectedElementsAreBeingDragged: false,
selectionElement: null,
shouldCacheIgnoreZoom: false,
stats: {
open: false,
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
},
showStats: false,
startBoundElement: null,
suggestedBindings: [],
frameRendering: { enabled: true, clip: true, name: true, outline: true },
@ -145,11 +140,6 @@ const APP_STATE_STORAGE_CONF = (<
export: false,
server: false,
},
currentItemArrowType: {
browser: true,
export: false,
server: false,
},
currentItemOpacity: { browser: true, export: false, server: false },
currentItemRoughness: { browser: true, export: false, server: false },
currentItemStartArrowhead: { browser: true, export: false, server: false },
@ -157,7 +147,6 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false, server: false },
currentItemStrokeWidth: { browser: true, export: false, server: false },
currentItemTextAlign: { browser: true, export: false, server: false },
currentHoveredFontFamily: { browser: false, export: false, server: false },
cursorButton: { browser: true, export: false, server: false },
activeEmbeddable: { browser: false, export: false, server: false },
draggingElement: { browser: false, export: false, server: false },
@ -209,7 +198,7 @@ const APP_STATE_STORAGE_CONF = (<
},
selectionElement: { browser: false, export: false, server: false },
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
stats: { browser: true, export: false, server: false },
showStats: { browser: true, export: false, server: false },
startBoundElement: { browser: false, export: false, server: false },
suggestedBindings: { browser: false, export: false, server: false },
frameRendering: { browser: false, export: false, server: false },

View File

@ -1,105 +0,0 @@
export default class BinaryHeap<T> {
private content: T[] = [];
constructor(private scoreFunction: (node: T) => number) {}
sinkDown(idx: number) {
const node = this.content[idx];
while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent;
idx = parentN; // TODO: Optimize
} else {
break;
}
}
}
bubbleUp(idx: number) {
const length = this.content.length;
const node = this.content[idx];
const score = this.scoreFunction(node);
while (true) {
const child2N = (idx + 1) << 1;
const child1N = child2N - 1;
let swap = null;
let child1Score = 0;
if (child1N < length) {
const child1 = this.content[child1N];
child1Score = this.scoreFunction(child1);
if (child1Score < score) {
swap = child1N;
}
}
if (child2N < length) {
const child2 = this.content[child2N];
const child2Score = this.scoreFunction(child2);
if (child2Score < (swap === null ? score : child1Score)) {
swap = child2N;
}
}
if (swap !== null) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break;
}
}
}
push(node: T) {
this.content.push(node);
this.sinkDown(this.content.length - 1);
}
pop(): T | null {
if (this.content.length === 0) {
return null;
}
const result = this.content[0];
const end = this.content.pop()!;
if (this.content.length > 0) {
this.content[0] = end;
this.bubbleUp(0);
}
return result;
}
remove(node: T) {
if (this.content.length === 0) {
return;
}
const i = this.content.indexOf(node);
const end = this.content.pop()!;
if (i < this.content.length) {
this.content[i] = end;
if (this.scoreFunction(end) < this.scoreFunction(node)) {
this.sinkDown(i);
} else {
this.bubbleUp(i);
}
}
}
size(): number {
return this.content.length;
}
rescoreElement(node: T) {
this.sinkDown(this.content.indexOf(node));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,9 @@
import type { Spreadsheet } from "./charts";
import { tryParseCells, tryParseNumber, VALID_SPREADSHEET } from "./charts";
import {
Spreadsheet,
tryParseCells,
tryParseNumber,
VALID_SPREADSHEET,
} from "./charts";
describe("charts", () => {
describe("tryParseNumber", () => {

View File

@ -9,7 +9,7 @@ import {
VERTICAL_ALIGN,
} from "./constants";
import { newElement, newLinearElement, newTextElement } from "./element";
import type { NonDeletedExcalidrawElement } from "./element/types";
import { NonDeletedExcalidrawElement } from "./element/types";
import { randomId } from "./random";
export type ChartElements = readonly NonDeletedExcalidrawElement[];
@ -257,6 +257,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
width: chartWidth,
points: [
[0, 0],
@ -271,6 +273,8 @@ const chartLines = (
type: "line",
x,
y,
startArrowhead: null,
endArrowhead: null,
height: chartHeight,
points: [
[0, 0],
@ -285,6 +289,8 @@ const chartLines = (
type: "line",
x,
y: y - BAR_HEIGHT - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
strokeStyle: "dotted",
width: chartWidth,
opacity: GRID_OPACITY,
@ -412,6 +418,8 @@ const chartTypeLine = (
type: "line",
x: x + BAR_GAP + BAR_WIDTH / 2,
y: y - BAR_GAP,
startArrowhead: null,
endArrowhead: null,
height: maxY - minY,
width: maxX - minX,
strokeWidth: 2,
@ -445,6 +453,8 @@ const chartTypeLine = (
type: "line",
x: x + cx + BAR_WIDTH / 2 + BAR_GAP / 2,
y: y - cy,
startArrowhead: null,
endArrowhead: null,
height: cy,
strokeStyle: "dotted",
opacity: GRID_OPACITY,

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