Compare commits

...

21 Commits

Author SHA1 Message Date
dependabot[bot] 0bfdda610a build(deps): bump webpack-dev-middleware in /dev-docs
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-23 07:56:35 +00:00
David Luzar 15bfa626b4 feat: support to not render remote cursor & username (#7130) 2024-03-18 10:41:06 +01:00
David Luzar 068895db0e feat: expose more collaborator status icons (#7777) 2024-03-18 10:20:07 +01:00
dwelle b7babe554b feat: load old library if migration fails 2024-03-11 09:57:01 +01:00
dwelle 6a385d6663 feat: change LibraryPersistenceAdapter load() source -> priority
to clarify the semantics
2024-03-11 09:40:51 +01:00
David Luzar 2382fad4f6 feat: store library to IndexedDB & support storage adapters (#7655) 2024-03-08 22:29:19 +01:00
Marcel Mraz 480572f893 fix: correcting Assistant metrics (#7758)
* Changed Assistant metrics to the corrrect ones from OS/2 table

* Adding more information about font metrics

* Adding branded types to avoid future mistakes
2024-03-07 16:54:36 +01:00
David Luzar 68b1fdb20e fix: add missing font metrics for Assistant (#7752) 2024-03-06 10:53:37 +01:00
David Luzar a38e82f999 feat: close dropdown on escape (#7750) 2024-03-05 23:22:34 +01:00
David Luzar a07f6e9e3a feat: show ai badge for discovery (#7749) 2024-03-05 23:22:25 +01:00
Marcel Mraz 7e471b55eb feat: text measurements based on font metrics (#7693)
* Introduced vertical offset based on harcoded font metrics 

* Unified usage of alphabetic baseline for both canvas & svg export

* Removed baseline property

* Removed font-size rounding on Safari

* Removed artificial width offset
2024-03-05 19:33:27 +00:00
Ryan Di 160440b860 feat: improve collab error notification (#7741)
* identify cause

* toast after dialog for error messages in collab

* remove comment

* shake tooltip instead for repeating collab errors

* clear collab error

* empty commit

* simplify & fix reset race condition

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-03-04 20:43:44 +08:00
Aakansha Doshi f207bd0a1c build: export types for @excalidraw/utils (#7736)
* build: export types for @excalidraw/utils

* fix

* add types
2024-02-29 15:43:04 +05:30
Aakansha Doshi 99601baffc build: create ESM build for utils package 🥳 (#7500)
* build: create ESM build for utils package

* add deps, exports and import.meta
2024-02-28 19:33:47 +05:30
Aakansha Doshi af1a3d5b76 fix: export utils from excalidraw package in excalidraw library (#7731)
* fix: export utils from excalidraw package in excalidraw library

* don't export utils utilities

* fix import path

* fix export

* don't export export utilites

* fix export paths

* reexport utils from excalidraw package

* add exports from withinBounds

* fix path
2024-02-28 11:14:57 +05:30
Wabweni Brian 36e56267c9 docs: add missing closing angle bracket in integration.mdx (#7729)
Update integration.mdx: Fix missing closing angle bracket in code sample

 A closing angle bracket was missing in a code sample.

Original code:
<div style={{height:"500px", width:"500px"}}
    <Excalidraw />
</div>

Changes:

<div style={{height:"500px", width:"500px"}}>
    <Excalidraw />
</div>
2024-02-27 07:19:20 +00:00
Aakansha Doshi b09b5cb5f4 fix: split renderScene so that locales aren't imported unnecessarily (#7718)
* fix: split renderScene so that locales aren't imported unnecessarily

* lint

* split export code

* rename renderScene to helpers.ts

* add helpers

* fix typo

* fixes

* move renderElementToSvg to export

* lint

* rename export to staticSvgScene

* fix
2024-02-27 10:37:44 +05:30
Aashman Verma dd8529743a docs: type mistake in integration of excalidraw (#7723) 2024-02-26 10:24:27 +01:00
Aakansha Doshi f639d44a95 fix: remove dependency of t in blob.ts (#7717)
* remove dependency of t in blob.ts

* fix
2024-02-23 15:05:46 +05:30
Aakansha Doshi f5ab3e4e12 fix: remove dependency of t from clipboard and image (#7712)
* fix: remove dependency of t from clipboard and image

* pass errorMessage to copyTextToSystemClipboard where needed

* wrap copyTextToSystemClipboard and rethrow translated error in caller

* review fix

* typo
2024-02-21 19:45:33 +05:30
Aakansha Doshi 361a9449bb fix: remove scene hack from export.ts & remove pass elementsMap to getContainingFrame (#7713)
* fix: remove scene hack from export.ts as its not needed anymore

* remove

* pass elementsMap to getContainingFrame

* remove unused `mapElementIds` param

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2024-02-21 16:34:20 +05:30
84 changed files with 3612 additions and 2303 deletions
+2 -1
View File
@@ -25,4 +25,5 @@ packages/excalidraw/types
coverage
dev-dist
html
examples/**/bundle.*
examples/**/bundle.*
meta*.json
@@ -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>
);
+3 -3
View File
@@ -7614,9 +7614,9 @@ webpack-bundle-analyzer@^4.5.0:
ws "^7.3.1"
webpack-dev-middleware@^5.3.1:
version "5.3.3"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f"
integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==
version "5.3.4"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz#eb7b39281cbce10e104eb2b8bf2b63fce49a3517"
integrity sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==
dependencies:
colorette "^2.0.10"
memfs "^3.4.3"
+25 -22
View File
@@ -30,7 +30,6 @@ import {
} from "../packages/excalidraw/index";
import {
AppState,
LibraryItems,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
@@ -64,7 +63,6 @@ import {
loadScene,
} from "./data";
import {
getLibraryItemsFromStorage,
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
@@ -82,7 +80,11 @@ 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 { LocalData } from "./data/LocalData";
import {
LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter,
LocalData,
} from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync";
import clsx from "clsx";
import { reconcileElements } from "./collab/reconciliation";
@@ -104,6 +106,7 @@ import { openConfirmModal } from "../packages/excalidraw/components/OverwriteCon
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";
polyfill();
@@ -310,10 +313,13 @@ const ExcalidrawWrapper = () => {
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
return isCollaborationLink(window.location.href);
});
const collabError = useAtomValue(collabErrorIndicatorAtom);
useHandleLibrary({
excalidrawAPI,
getInitialLibraryItems: getLibraryItemsFromStorage,
adapter: LibraryIndexedDBAdapter,
// TODO maybe remove this in several months (shipped: 24-03-11)
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
useEffect(() => {
@@ -443,8 +449,12 @@ const ExcalidrawWrapper = () => {
excalidrawAPI.updateScene({
...localDataState,
});
excalidrawAPI.updateLibrary({
libraryItems: getLibraryItemsFromStorage(),
LibraryIndexedDBAdapter.load().then((data) => {
if (data) {
excalidrawAPI.updateLibrary({
libraryItems: data.libraryItems,
});
}
});
collabAPI?.setUsername(username || "");
}
@@ -656,15 +666,6 @@ 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(
@@ -740,7 +741,6 @@ const ExcalidrawWrapper = () => {
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
autoFocus={true}
theme={theme}
renderTopRightUI={(isMobile) => {
@@ -748,12 +748,15 @@ const ExcalidrawWrapper = () => {
return null;
}
return (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
<div className="top-right-ui">
{collabError.message && <CollabError collabError={collabError} />}
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() =>
setShareDialogState({ isOpen: true, type: "share" })
}
/>
</div>
);
}}
>
+5 -1
View File
@@ -39,10 +39,14 @@ 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 = {
+52 -12
View File
@@ -81,6 +81,7 @@ import { appJotaiStore } from "../app-jotai";
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";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const isCollaboratingAtom = atom(false);
@@ -88,6 +89,8 @@ export const isOfflineAtom = atom(false);
interface CollabState {
errorMessage: string | null;
/** errors related to saving */
dialogNotifiedErrors: Record<string, boolean>;
username: string;
activeRoomLink: string | null;
}
@@ -107,7 +110,7 @@ export interface CollabAPI {
setUsername: CollabInstance["setUsername"];
getUsername: CollabInstance["getUsername"];
getActiveRoomLink: CollabInstance["getActiveRoomLink"];
setErrorMessage: CollabInstance["setErrorMessage"];
setCollabError: CollabInstance["setErrorDialog"];
}
interface CollabProps {
@@ -129,6 +132,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
super(props);
this.state = {
errorMessage: null,
dialogNotifiedErrors: {},
username: importUsernameFromLocalStorage() || "",
activeRoomLink: null,
};
@@ -197,7 +201,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
setUsername: this.setUsername,
getUsername: this.getUsername,
getActiveRoomLink: this.getActiveRoomLink,
setErrorMessage: this.setErrorMessage,
setCollabError: this.setErrorDialog,
};
appJotaiStore.set(collabAPIAtom, collabAPI);
@@ -276,18 +280,35 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.excalidrawAPI.getAppState(),
);
this.resetErrorIndicator();
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
this.reconcileElements(savedData.reconciledElements),
);
}
} catch (error: any) {
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"),
});
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);
}
console.error(error);
}
};
@@ -296,6 +317,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.queueBroadcastAllElements.cancel();
this.queueSaveToFirebase.cancel();
this.loadImageFiles.cancel();
this.resetErrorIndicator(true);
this.saveCollabRoomToFirebase(
getSyncableElements(
@@ -464,7 +486,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
this.portal.socket.once("connect_error", fallbackInitializationHandler);
} catch (error: any) {
console.error(error);
this.setState({ errorMessage: error.message });
this.setErrorDialog(error.message);
return null;
}
@@ -923,8 +945,26 @@ class Collab extends PureComponent<CollabProps, CollabState> {
getActiveRoomLink = () => this.state.activeRoomLink;
setErrorMessage = (errorMessage: string | null) => {
this.setState({ errorMessage });
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,
});
};
render() {
@@ -933,7 +973,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
return (
<>
{errorMessage != null && (
<ErrorDialog onClose={() => this.setState({ errorMessage: null })}>
<ErrorDialog onClose={() => this.setErrorDialog(null)}>
{errorMessage}
</ErrorDialog>
)}
+35
View File
@@ -0,0 +1,35 @@
@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(0eg);
}
75% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
}
+54
View File
@@ -0,0 +1,54 @@
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;
+11 -12
View File
@@ -65,19 +65,18 @@ export const RoomModal = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
+61 -1
View File
@@ -10,8 +10,18 @@
* (localStorage, indexedDB).
*/
import { createStore, entries, del, getMany, set, setMany } from "idb-keyval";
import {
createStore,
entries,
del,
getMany,
set,
setMany,
get,
} from "idb-keyval";
import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState";
import { LibraryPersistedData } from "../../packages/excalidraw/data/library";
import { ImportedDataState } from "../../packages/excalidraw/data/types";
import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
import {
ExcalidrawElement,
@@ -22,6 +32,7 @@ import {
BinaryFileData,
BinaryFiles,
} from "../../packages/excalidraw/types";
import { 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";
@@ -183,3 +194,52 @@ 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);
}
}
+1 -17
View File
@@ -6,7 +6,6 @@ import {
} 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 {
@@ -88,28 +87,13 @@ 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 + librarySize + getElementsStorageSize();
return appStateSize + collabSize + 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 [];
}
};
+7
View File
@@ -4,6 +4,13 @@
&.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;
+12 -12
View File
@@ -69,20 +69,20 @@ const ActiveRoomDialog = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
collabAPI.setErrorMessage(error.message);
} catch (e) {
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
+6
View File
@@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section.
### Features
- 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)
@@ -27,6 +31,8 @@ Please add the latest change on the top under the correct section.
- `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
@@ -49,7 +49,7 @@ export const actionUnbindText = register({
selectedElements.forEach((element) => {
const boundTextElement = getBoundTextElement(element, elementsMap);
if (boundTextElement) {
const { width, height, baseline } = measureText(
const { width, height } = measureText(
boundTextElement.originalText,
getFontString(boundTextElement),
boundTextElement.lineHeight,
@@ -67,7 +67,6 @@ export const actionUnbindText = register({
containerId: null,
width,
height,
baseline,
text: boundTextElement.originalText,
x,
y,
@@ -238,7 +238,11 @@ export const copyText = register({
return acc;
}, [])
.join("\n\n");
copyTextToSystemClipboard(text);
try {
copyTextToSystemClipboard(text);
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return {
commitToHistory: false,
};
+69 -23
View File
@@ -1,10 +1,15 @@
import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar";
import { GoToCollaboratorComponentProps } from "../components/UserList";
import { eyeIcon } from "../components/icons";
import {
eyeIcon,
microphoneIcon,
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n";
import { Collaborator } from "../types";
import { register } from "./register";
import clsx from "clsx";
export const actionGoToCollaborator = register({
name: "goToCollaborator",
@@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({
};
},
PanelComponent: ({ updateData, data, appState }) => {
const { clientId, collaborator, withName, isBeingFollowed } =
const { socketId, collaborator, withName, isBeingFollowed } =
data as GoToCollaboratorComponentProps;
const background = getClientColor(clientId);
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;
return withName ? (
<div
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
style={{ [`--avatar-size` as any]: "1.5rem" }}
onClick={() => updateData<Collaborator>(collaborator)}
>
<Avatar
@@ -54,32 +90,42 @@ export const actionGoToCollaborator = register({
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
className={statusClassNames}
/>
<div className="UserList__collaborator-name">
{collaborator.username}
</div>
<div
className="UserList__collaborator-follow-status-icon"
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
aria-hidden
>
{eyeIcon}
<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>
</div>
) : (
<Avatar
color={background}
onClick={() => {
updateData(collaborator);
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed}
isCurrentUser={collaborator.isCurrentUser === true}
/>
<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>
);
},
});
+224 -5
View File
@@ -1,3 +1,18 @@
import {
COLOR_CHARCOAL_BLACK,
COLOR_VOICE_CALL,
COLOR_WHITE,
THEME,
} from "./constants";
import { roundRect } from "./renderer/roundRect";
import { InteractiveCanvasRenderConfig } from "./scene/types";
import {
Collaborator,
InteractiveCanvasAppState,
SocketId,
UserIdleState,
} from "./types";
function hashToInteger(id: string) {
let hash = 0;
if (id.length === 0) {
@@ -11,14 +26,12 @@ function hashToInteger(id: string) {
}
export const getClientColor = (
/**
* any uniquely identifying key, such as user id or socket id
*/
id: string,
socketId: SocketId,
collaborator: Collaborator | undefined,
) => {
// to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it
const hash = Math.abs(hashToInteger(id));
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
// we want to get a multiple of 10 number in the range of 0-360 (in other
// words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10;
@@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
).toUpperCase();
};
export const renderRemoteCursors = ({
context,
renderConfig,
appState,
normalizedWidth,
normalizedHeight,
}: {
context: CanvasRenderingContext2D;
renderConfig: InteractiveCanvasRenderConfig;
appState: InteractiveCanvasAppState;
normalizedWidth: number;
normalizedHeight: number;
}) => {
// Paint remote pointers
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
let { x, y } = pointer;
const collaborator = appState.collaborators.get(socketId);
x -= appState.offsetLeft;
y -= appState.offsetTop;
const width = 11;
const height = 14;
const isOutOfBounds =
x < 0 ||
x > normalizedWidth - width ||
y < 0 ||
y > normalizedHeight - height;
x = Math.max(x, 0);
x = Math.min(x, normalizedWidth - width);
y = Math.max(y, 0);
y = Math.min(y, normalizedHeight - height);
const background = getClientColor(socketId, collaborator);
context.save();
context.strokeStyle = background;
context.fillStyle = background;
const userState = renderConfig.remotePointerUserStates.get(socketId);
const isInactive =
isOutOfBounds ||
userState === UserIdleState.IDLE ||
userState === UserIdleState.AWAY;
if (isInactive) {
context.globalAlpha = 0.3;
}
if (renderConfig.remotePointerButton.get(socketId) === "down") {
context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 3;
context.strokeStyle = "#ffffff88";
context.stroke();
context.closePath();
context.beginPath();
context.arc(x, y, 15, 0, 2 * Math.PI, false);
context.lineWidth = 1;
context.strokeStyle = background;
context.stroke();
context.closePath();
}
// TODO remove the dark theme color after we stop inverting canvas colors
const IS_SPEAKING_COLOR =
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
const isSpeaking = collaborator?.isSpeaking;
if (isSpeaking) {
// cursor outline for currently speaking user
context.fillStyle = IS_SPEAKING_COLOR;
context.strokeStyle = IS_SPEAKING_COLOR;
context.lineWidth = 10;
context.lineJoin = "round";
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.stroke();
context.fill();
}
// Background (white outline) for arrow
context.fillStyle = COLOR_WHITE;
context.strokeStyle = COLOR_WHITE;
context.lineWidth = 6;
context.lineJoin = "round";
context.beginPath();
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.stroke();
context.fill();
// Arrow
context.fillStyle = background;
context.strokeStyle = background;
context.lineWidth = 2;
context.lineJoin = "round";
context.beginPath();
if (isInactive) {
context.moveTo(x - 1, y - 1);
context.lineTo(x - 1, y + 15);
context.lineTo(x + 5, y + 10);
context.lineTo(x + 12, y + 9);
context.closePath();
context.fill();
} else {
context.moveTo(x, y);
context.lineTo(x + 0, y + 14);
context.lineTo(x + 4, y + 9);
context.lineTo(x + 11, y + 8);
context.closePath();
context.fill();
context.stroke();
}
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
if (!isOutOfBounds && username) {
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
const paddingHorizontal = 5;
const paddingVertical = 3;
const measure = context.measureText(username);
const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
const finalHeight = Math.max(measureHeight, 12);
const boxX = offsetX - 1;
const boxY = offsetY - 1;
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
if (context.roundRect) {
context.beginPath();
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
context.fillStyle = background;
context.fill();
context.strokeStyle = COLOR_WHITE;
context.stroke();
if (isSpeaking) {
context.beginPath();
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
context.strokeStyle = IS_SPEAKING_COLOR;
context.stroke();
}
} else {
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
}
context.fillStyle = COLOR_CHARCOAL_BLACK;
context.fillText(
username,
offsetX + paddingHorizontal + 1,
offsetY +
paddingVertical +
measure.actualBoundingBoxAscent +
Math.floor((finalHeight - measureHeight) / 2) +
2,
);
// draw three vertical bars signalling someone is speaking
if (isSpeaking) {
context.fillStyle = IS_SPEAKING_COLOR;
const barheight = 8;
const margin = 8;
const gap = 5;
context.fillRect(
boxX + boxWidth + margin,
boxY + (boxHeight / 2 - barheight / 2),
2,
barheight,
);
context.fillRect(
boxX + boxWidth + margin + gap,
boxY + (boxHeight / 2 - (barheight * 2) / 2),
2,
barheight * 2,
);
context.fillRect(
boxX + boxWidth + margin + gap * 2,
boxY + (boxHeight / 2 - barheight / 2),
2,
barheight,
);
}
}
context.restore();
context.closePath();
}
};
+5 -5
View File
@@ -16,8 +16,7 @@ import {
import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame";
import { isMemberOf, isPromiseLike } from "./utils";
import { t } from "./i18n";
import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -126,6 +125,7 @@ export const serializeAsClipboardJSON = ({
elements: readonly NonDeletedExcalidrawElement[];
files: BinaryFiles | null;
}) => {
const elementsMap = arrayToMap(elements);
const framesToCopy = new Set(
elements.filter((element) => isFrameLikeElement(element)),
);
@@ -152,8 +152,8 @@ export const serializeAsClipboardJSON = ({
type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => {
if (
getContainingFrame(element) &&
!framesToCopy.has(getContainingFrame(element)!)
getContainingFrame(element, elementsMap) &&
!framesToCopy.has(getContainingFrame(element, elementsMap)!)
) {
const copiedElement = deepCopyElement(element);
mutateElement(copiedElement, {
@@ -434,7 +434,7 @@ export const copyTextToSystemClipboard = async (
// (3) if that fails, use document.execCommand
if (!copyTextViaExecCommand(text)) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
throw new Error("Error copying to clipboard.");
}
};
@@ -306,6 +306,25 @@ export const ShapesSwitcher = ({
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
{app.props.aiEnabled !== false && (
<div
style={{
display: "inline-flex",
marginLeft: "auto",
padding: "2px 4px",
borderRadius: 6,
fontSize: 8,
fontFamily: "Cascadia, monospace",
position: "absolute",
background: "pink",
color: "black",
bottom: 3,
right: 4,
}}
>
AI
</div>
)}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
+57 -28
View File
@@ -89,6 +89,7 @@ import {
TOOL_TYPE,
EDITOR_LS_KEYS,
isIOS,
supportsResizeObserver,
} from "../constants";
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
@@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () =>
export const useExcalidrawActionManager = () =>
useContext(ExcalidrawActionManagerContext);
const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
let isHoldingSpace: boolean = false;
@@ -1131,7 +1129,7 @@ class App extends React.Component<AppProps, AppState> {
display: isVisible ? "block" : "none",
opacity: getRenderOpacity(
el,
getContainingFrame(el),
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
this.elementsPendingErasure,
),
["--embeddable-radius" as string]: `${getCornerRadius(
@@ -3214,7 +3212,13 @@ class App extends React.Component<AppProps, AppState> {
try {
return { file: await ImageURLToFile(url) };
} catch (error: any) {
return { errorMessage: error.message as string };
let errorMessage = error.message;
if (error.cause === "FETCH_ERROR") {
errorMessage = t("errors.failedToFetchImage");
} else if (error.cause === "UNSUPPORTED") {
errorMessage = t("errors.unsupportedFileType");
}
return { errorMessage };
}
}),
);
@@ -4399,7 +4403,7 @@ class App extends React.Component<AppProps, AppState> {
),
).filter((element) => {
// hitting a frame's element from outside the frame is not considered a hit
const containingFrame = getContainingFrame(element);
const containingFrame = getContainingFrame(element, elementsMap);
return containingFrame &&
this.state.frameRendering.enabled &&
this.state.frameRendering.clip
@@ -7789,7 +7793,7 @@ class App extends React.Component<AppProps, AppState> {
);
if (linearElement?.frameId) {
const frame = getContainingFrame(linearElement);
const frame = getContainingFrame(linearElement, elementsMap);
if (frame && linearElement) {
if (
@@ -8478,10 +8482,18 @@ class App extends React.Component<AppProps, AppState> {
// mustn't be larger than 128 px
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property
const cursorImageSizePx = 96;
let imagePreview;
const imagePreview = await resizeImageFile(imageFile, {
maxWidthOrHeight: cursorImageSizePx,
});
try {
imagePreview = await resizeImageFile(imageFile, {
maxWidthOrHeight: cursorImageSizePx,
});
} catch (e: any) {
if (e.cause === "UNSUPPORTED") {
throw new Error(t("errors.unsupportedFileType"));
}
throw e;
}
let previewDataURL = await getDataURL(imagePreview);
@@ -8870,8 +8882,9 @@ class App extends React.Component<AppProps, AppState> {
});
return;
} catch (error: any) {
// Don't throw for image scene daa
if (error.name !== "EncodingError") {
throw error;
throw new Error(t("alerts.couldNotLoadInvalidFile"));
}
}
}
@@ -8945,12 +8958,39 @@ class App extends React.Component<AppProps, AppState> {
) => {
file = await normalizeFile(file);
try {
const ret = await loadSceneOrLibraryFromBlob(
file,
this.state,
this.scene.getElementsIncludingDeleted(),
fileHandle,
);
let ret;
try {
ret = await loadSceneOrLibraryFromBlob(
file,
this.state,
this.scene.getElementsIncludingDeleted(),
fileHandle,
);
} catch (error: any) {
const imageSceneDataError = error instanceof ImageSceneDataError;
if (
imageSceneDataError &&
error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" &&
!this.isToolSupported("image")
) {
this.setState({
isLoading: false,
errorMessage: t("errors.imageToolNotSupported"),
});
return;
}
const errorMessage = imageSceneDataError
? t("alerts.cannotRestoreFromImage")
: t("alerts.couldNotLoadInvalidFile");
this.setState({
isLoading: false,
errorMessage,
});
}
if (!ret) {
return;
}
if (ret.type === MIME_TYPES.excalidraw) {
this.setState({ isLoading: true });
this.syncActionResult({
@@ -8975,17 +9015,6 @@ class App extends React.Component<AppProps, AppState> {
});
}
} catch (error: any) {
if (
error instanceof ImageSceneDataError &&
error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" &&
!this.isToolSupported("image")
) {
this.setState({
isLoading: false,
errorMessage: t("errors.imageToolNotSupported"),
});
return;
}
this.setState({ isLoading: false, errorMessage: error.message });
}
};
+3 -12
View File
@@ -9,8 +9,7 @@ type AvatarProps = {
color: string;
name: string;
src?: string;
isBeingFollowed?: boolean;
isCurrentUser: boolean;
className?: string;
};
export const Avatar = ({
@@ -18,22 +17,14 @@ export const Avatar = ({
onClick,
name,
src,
isBeingFollowed,
isCurrentUser,
className,
}: AvatarProps) => {
const shortName = getNameInitial(name);
const [error, setError] = useState(false);
const loadImg = !error && src;
const style = loadImg ? undefined : { background: color };
return (
<div
className={clsx("Avatar", {
"Avatar--is-followed": isBeingFollowed,
"Avatar--is-current-user": isCurrentUser,
})}
style={style}
onClick={onClick}
>
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
{loadImg ? (
<img
className="Avatar-img"
@@ -124,9 +124,16 @@ const ImageExportModal = ({
setRenderError(null);
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
return canvasToBlob(canvas).then(() => {
previewNode.replaceChildren(canvas);
});
return canvasToBlob(canvas)
.then(() => {
previewNode.replaceChildren(canvas);
})
.catch((e) => {
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw e;
});
})
.catch((error) => {
console.error(error);
@@ -19,7 +19,14 @@
&__top-right {
display: flex;
width: 100%;
justify-content: flex-end;
gap: 0.75rem;
pointer-events: none !important;
& > * {
pointer-events: var(--ui-pointerEvents);
}
}
&__footer {
@@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(link);
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
} catch (error: any) {
setErrorMessage(error.message);
} catch (e) {
setErrorMessage(t("errors.copyToSystemClipboardFailed"));
}
setJustCopied(true);
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(() => {
setJustCopied(false);
}, 3000);
ref.current?.select();
};
@@ -10,6 +10,7 @@ import { NonDeletedExcalidrawElement } from "../../element/types";
import { AppClassProperties, BinaryFiles } from "../../types";
import { canvasToBlob } from "../../data/blob";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import { t } from "../../i18n";
const resetPreview = ({
canvasRef,
@@ -108,7 +109,14 @@ export const convertMermaidToExcalidraw = async ({
});
// if converting to blob fails, there's some problem that will
// likely prevent preview and export (e.g. canvas too big)
await canvasToBlob(canvas);
try {
await canvasToBlob(canvas);
} catch (e: any) {
if (e.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw new Error(t("canvasError.canvasTooBig"));
}
throw e;
}
parent.style.background = "var(--default-bg-color)";
canvasNode.replaceChildren(canvas);
} catch (err: any) {
+4 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { CSSProperties, useCallback, useEffect, useRef } from "react";
import { CloseIcon } from "./icons";
import "./Toast.scss";
import { ToolButton } from "./ToolButton";
@@ -11,11 +11,13 @@ export const Toast = ({
closable = false,
// To prevent autoclose, pass duration as Infinity
duration = DEFAULT_TOAST_TIMEOUT,
style,
}: {
message: string;
onClose: () => void;
closable?: boolean;
duration?: number;
style?: CSSProperties;
}) => {
const timerRef = useRef<number>(0);
const shouldAutoClose = duration !== Infinity;
@@ -43,6 +45,7 @@ export const Toast = ({
className="Toast"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={style}
>
<p className="Toast__message">{message}</p>
{closable && (
+94 -14
View File
@@ -1,16 +1,25 @@
@import "../css/variables.module";
.excalidraw {
--avatar-size: 1.75rem;
--avatarList-gap: 0.625rem;
--userList-padding: var(--space-factor);
.UserList-wrapper {
display: flex;
width: 100%;
justify-content: flex-end;
pointer-events: none !important;
}
.UserList {
pointer-events: none;
/*github corner*/
padding: var(--space-factor) var(--space-factor) var(--space-factor)
var(--space-factor);
padding: var(--userList-padding);
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 0.625rem;
gap: var(--avatarList-gap);
&:empty {
display: none;
@@ -18,15 +27,16 @@
box-sizing: border-box;
// can fit max 4 avatars (3 avatars + show more) in a column
max-height: 120px;
--max-size: calc(
var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) *
(var(--max-avatars, 2) - 1) + var(--userList-padding) * 2
);
// can fit max 4 avatars (3 avatars + show more) when there's enough space
max-width: 120px;
// max width & height set to fix the max-avatars
max-height: var(--max-size);
max-width: var(--max-size);
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
overflow: hidden;
}
.UserList > * {
@@ -45,10 +55,11 @@
@include avatarStyles;
background-color: var(--color-gray-20);
border: 0 !important;
font-size: 0.5rem;
font-size: 0.625rem;
font-weight: 400;
flex-shrink: 0;
color: var(--color-gray-100);
font-weight: bold;
}
.UserList__collaborator-name {
@@ -57,13 +68,82 @@
white-space: nowrap;
}
.UserList__collaborator-follow-status-icon {
.UserList__collaborator--avatar-only {
position: relative;
display: flex;
flex: 0 0 auto;
.UserList__collaborator-status-icon {
--size: 14px;
position: absolute;
display: flex;
flex: 0 0 auto;
bottom: -0.25rem;
right: -0.25rem;
width: var(--size);
height: var(--size);
svg {
flex: 0 0 auto;
width: var(--size);
height: var(--size);
}
}
}
.UserList__collaborator-status-icons {
margin-left: auto;
flex: 0 0 auto;
width: 1rem;
min-width: 2.25rem;
gap: 0.25rem;
justify-content: flex-end;
display: flex;
}
.UserList__collaborator.is-muted
.UserList__collaborator-status-icon-microphone-muted {
color: var(--color-danger);
filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5));
}
.UserList__collaborator-status-icon-speaking-indicator {
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
width: 1rem;
padding: 0 3px;
box-sizing: border-box;
div {
width: 0.125rem;
height: 0.4rem;
// keep this in sync with constants.ts
background-color: #a2f1a6;
}
div:nth-of-type(1) {
animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite;
}
div:nth-of-type(2) {
animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite;
}
div:nth-of-type(3) {
animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite;
}
}
@keyframes speaking-indicator-anim {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(2);
}
}
--userlist-hint-bg-color: var(--color-gray-10);
--userlist-hint-heading-color: var(--color-gray-80);
--userlist-hint-text-color: var(--color-gray-60);
@@ -80,7 +160,7 @@
position: static;
top: auto;
margin-top: 0;
max-height: 12rem;
max-height: 50vh;
overflow-y: auto;
padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color);
+137 -91
View File
@@ -1,6 +1,6 @@
import "./UserList.scss";
import React from "react";
import React, { useLayoutEffect } from "react";
import clsx from "clsx";
import { Collaborator, SocketId } from "../types";
import { Tooltip } from "./Tooltip";
@@ -12,9 +12,11 @@ import { Island } from "./Island";
import { searchIcon } from "./icons";
import { t } from "../i18n";
import { isShallowEqual } from "../utils";
import { supportsResizeObserver } from "../constants";
import { MarkRequired } from "../utility-types";
export type GoToCollaboratorComponentProps = {
clientId: ClientId;
socketId: SocketId;
collaborator: Collaborator;
withName: boolean;
isBeingFollowed: boolean;
@@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
/** collaborator user id or socket id (fallback) */
type ClientId = string & { _brand: "UserId" };
const FIRST_N_AVATARS = 3;
const DEFAULT_MAX_AVATARS = 4;
const SHOW_COLLABORATORS_FILTER_AT = 8;
const ConditionalTooltipWrapper = ({
shouldWrap,
children,
clientId,
username,
}: {
shouldWrap: boolean;
children: React.ReactNode;
username?: string | null;
clientId: ClientId;
}) =>
shouldWrap ? (
<Tooltip label={username || "Unknown user"} key={clientId}>
{children}
</Tooltip>
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
) : (
<React.Fragment key={clientId}>{children}</React.Fragment>
<React.Fragment>{children}</React.Fragment>
);
const renderCollaborator = ({
actionManager,
collaborator,
clientId,
socketId,
withName = false,
shouldWrapWithTooltip = false,
isBeingFollowed,
}: {
actionManager: ActionManager;
collaborator: Collaborator;
clientId: ClientId;
socketId: SocketId;
withName?: boolean;
shouldWrapWithTooltip?: boolean;
isBeingFollowed: boolean;
}) => {
const data: GoToCollaboratorComponentProps = {
clientId,
socketId,
collaborator,
withName,
isBeingFollowed,
@@ -70,8 +68,7 @@ const renderCollaborator = ({
return (
<ConditionalTooltipWrapper
key={clientId}
clientId={clientId}
key={socketId}
username={collaborator.username}
shouldWrap={shouldWrapWithTooltip}
>
@@ -82,7 +79,13 @@ const renderCollaborator = ({
type UserListUserObject = Pick<
Collaborator,
"avatarUrl" | "id" | "socketId" | "username"
| "avatarUrl"
| "id"
| "socketId"
| "username"
| "isInCall"
| "isSpeaking"
| "isMuted"
>;
type UserListProps = {
@@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
"id",
"socketId",
"username",
"isInCall",
"isSpeaking",
"isMuted",
] as const;
export const UserList = React.memo(
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
const actionManager = useExcalidrawActionManager();
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
const uniqueCollaboratorsMap = new Map<
ClientId,
MarkRequired<Collaborator, "socketId">
>();
collaborators.forEach((collaborator, socketId) => {
const userId = (collaborator.id || socketId) as ClientId;
@@ -114,115 +123,147 @@ export const UserList = React.memo(
);
});
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
([_, collaborator]) => collaborator.username?.trim(),
);
const uniqueCollaboratorsArray = Array.from(
uniqueCollaboratorsMap.values(),
).filter((collaborator) => collaborator.username?.trim());
const [searchTerm, setSearchTerm] = React.useState("");
if (uniqueCollaboratorsArray.length === 0) {
return null;
}
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (userListWrapper.current) {
const updateMaxAvatars = (width: number) => {
const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38)));
setMaxAvatars(maxAvatars);
};
updateMaxAvatars(userListWrapper.current.clientWidth);
if (!supportsResizeObserver) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
updateMaxAvatars(width);
}
});
resizeObserver.observe(userListWrapper.current);
return () => {
resizeObserver.disconnect();
};
}
}, []);
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
? uniqueCollaboratorsArray.filter((collaborator) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
FIRST_N_AVATARS,
maxAvatars - 1,
);
const firstNAvatarsJSX = firstNCollaborators.map(
([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
socketId: collaborator.socketId,
shouldWrapWithTooltip: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
);
return mobile ? (
<div className={clsx("UserList UserList_mobile", className)}>
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
{uniqueCollaboratorsArray.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
socketId: collaborator.socketId,
shouldWrapWithTooltip: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
) : (
<div className={clsx("UserList", className)}>
{firstNAvatarsJSX}
<div className="UserList-wrapper" ref={userListWrapper}>
<div
className={clsx("UserList", className)}
style={{ [`--max-avatars` as any]: maxAvatars }}
>
{firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
</Popover.Trigger>
<Popover.Content
style={{
zIndex: 2,
width: "13rem",
textAlign: "left",
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
align="end"
sideOffset={10}
>
<Island style={{ overflow: "hidden" }}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper">
{searchIcon}
<input
className="UserList__search"
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)}
<div className="dropdown-menu UserList__collaborators">
{filteredCollaborators.length === 0 && (
<div className="UserList__collaborators__empty">
{t("userList.search.empty")}
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
</Popover.Trigger>
<Popover.Content
style={{
zIndex: 2,
width: "15rem",
textAlign: "left",
}}
align="end"
sideOffset={10}
>
<Island style={{ overflow: "hidden" }}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper">
{searchIcon}
<input
className="UserList__search"
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)}
<div className="UserList__hint">
{t("userList.hint.text")}
<div className="dropdown-menu UserList__collaborators">
{filteredCollaborators.length === 0 && (
<div className="UserList__collaborators__empty">
{t("userList.search.empty")}
</div>
)}
<div className="UserList__hint">
{t("userList.hint.text")}
</div>
{filteredCollaborators.map((collaborator) =>
renderCollaborator({
actionManager,
collaborator,
socketId: collaborator.socketId,
withName: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
{filteredCollaborators.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
withName: true,
isBeingFollowed: collaborator.socketId === userToFollow,
}),
)}
</div>
</Island>
</Popover.Content>
</Popover.Root>
)}
</Island>
</Popover.Content>
</Popover.Root>
)}
</div>
</div>
);
},
@@ -236,10 +277,15 @@ export const UserList = React.memo(
return false;
}
const nextCollaboratorSocketIds = next.collaborators.keys();
for (const [socketId, collaborator] of prev.collaborators) {
const nextCollaborator = next.collaborators.get(socketId);
if (
!nextCollaborator ||
// this checks order of collaborators in the map is the same
// as previous render
socketId !== nextCollaboratorSocketIds.next().value ||
!isShallowEqual(
collaborator,
nextCollaborator,
@@ -1,5 +1,4 @@
import React, { useEffect, useRef } from "react";
import { renderInteractiveScene } from "../../renderer/renderScene";
import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils";
import { CURSOR_TYPE } from "../../constants";
import { t } from "../../i18n";
@@ -12,6 +11,7 @@ import type {
} from "../../scene/types";
import type { NonDeletedExcalidrawElement } from "../../element/types";
import { isRenderThrottlingEnabled } from "../../reactUtils";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
type InteractiveCanvasProps = {
containerRef: React.RefObject<HTMLDivElement>;
@@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
return;
}
const cursorButton: {
[id: string]: string | undefined;
} = {};
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
{};
const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] =
new Map();
const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
new Map();
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
{};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
new Map();
const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] =
new Map();
const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] =
new Map();
props.appState.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) {
if (!(id in remoteSelectedElementIds)) {
remoteSelectedElementIds[id] = [];
if (!remoteSelectedElementIds.has(id)) {
remoteSelectedElementIds.set(id, []);
}
remoteSelectedElementIds[id].push(socketId);
remoteSelectedElementIds.get(id)!.push(socketId);
}
}
if (!user.pointer) {
if (!user.pointer || user.pointer.renderCursor === false) {
return;
}
if (user.username) {
pointerUsernames[socketId] = user.username;
remotePointerUsernames.set(socketId, user.username);
}
if (user.userState) {
pointerUserStates[socketId] = user.userState;
remotePointerUserStates.set(socketId, user.userState);
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
sceneY: user.pointer.y,
},
props.appState,
remotePointerViewportCoords.set(
socketId,
sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
sceneY: user.pointer.y,
},
props.appState,
),
);
cursorButton[socketId] = user.button;
remotePointerButton.set(socketId, user.button);
});
const selectionColor =
@@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
scale: window.devicePixelRatio,
appState: props.appState,
renderConfig: {
remotePointerViewportCoords: pointerViewportCoords,
remotePointerButton: cursorButton,
remotePointerViewportCoords,
remotePointerButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
remotePointerUsernames,
remotePointerUserStates,
selectionColor,
renderScrollbars: false,
},
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from "react";
import { RoughCanvas } from "roughjs/bin/canvas";
import { renderStaticScene } from "../../renderer/renderScene";
import { renderStaticScene } from "../../renderer/staticScene";
import { isShallowEqual } from "../../utils";
import type { AppState, StaticCanvasAppState } from "../../types";
import type {
@@ -0,0 +1,20 @@
import { Excalidraw } from "../../index";
import { KEYS } from "../../keys";
import { Keyboard } from "../../tests/helpers/ui";
import { render, waitFor, getByTestId } from "../../tests/test-utils";
describe("Test <DropdownMenu/>", () => {
it("should", async () => {
const { container } = await render(<Excalidraw />);
expect(window.h.state.openMenu).toBe(null);
getByTestId(container, "main-menu-trigger").click();
expect(window.h.state.openMenu).toBe("canvas");
await waitFor(() => {
Keyboard.keyDown(KEYS.ESCAPE);
expect(window.h.state.openMenu).toBe(null);
});
});
});
@@ -2,9 +2,12 @@ import { Island } from "../Island";
import { useDevice } from "../App";
import clsx from "clsx";
import Stack from "../Stack";
import React, { useRef } from "react";
import React, { useEffect, useRef } from "react";
import { DropdownMenuContentPropsContext } from "./common";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { KEYS } from "../../keys";
import { EVENT } from "../../constants";
import { useStable } from "../../hooks/useStable";
const MenuContent = ({
children,
@@ -25,10 +28,30 @@ const MenuContent = ({
const device = useDevice();
const menuRef = useRef<HTMLDivElement>(null);
const callbacksRef = useStable({ onClickOutside });
useOutsideClick(menuRef, () => {
onClickOutside?.();
callbacksRef.onClickOutside?.();
});
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === KEYS.ESCAPE) {
event.stopImmediatePropagation();
callbacksRef.onClickOutside?.();
}
};
document.addEventListener(EVENT.KEYDOWN, onKeyDown, {
// so that we can stop propagation of the event before it reaches
// event handlers that were bound before this one
capture: true,
});
return () => {
document.removeEventListener(EVENT.KEYDOWN, onKeyDown);
};
}, [callbacksRef]);
const classNames = clsx(`dropdown-menu ${className}`, {
"dropdown-menu--mobile": device.editor.isMobile,
}).trim();
+28 -1
View File
@@ -604,6 +604,10 @@ export const share = createIcon(
modifiedTablerIconProps,
);
export const warning = createIcon(
"M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z",
);
export const shareIOS = createIcon(
"M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z",
{ width: 24, height: 24 },
@@ -1794,7 +1798,7 @@ export const fullscreenIcon = createIcon(
);
export const eyeIcon = createIcon(
<g stroke="currentColor" fill="none">
<g stroke="currentColor" fill="none" strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
@@ -1833,3 +1837,26 @@ export const searchIcon = createIcon(
</g>,
tablerIconProps,
);
export const microphoneIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 2m0 3a3 3 0 0 1 3 -3h0a3 3 0 0 1 3 3v5a3 3 0 0 1 -3 3h0a3 3 0 0 1 -3 -3z" />
<path d="M5 10a7 7 0 0 0 14 0" />
<path d="M8 21l8 0" />
<path d="M12 17l0 4" />
</g>,
tablerIconProps,
);
export const microphoneMutedIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 3l18 18" />
<path d="M9 5a3 3 0 0 1 6 0v5a3 3 0 0 1 -.13 .874m-2 2a3 3 0 0 1 -3.87 -2.872v-1" />
<path d="M5 10a7 7 0 0 0 10.846 5.85m2 -2a6.967 6.967 0 0 0 1.152 -3.85" />
<path d="M8 21l8 0" />
<path d="M12 17l0 4" />
</g>,
tablerIconProps,
);
+9
View File
@@ -20,6 +20,9 @@ export const isIOS =
export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave";
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // px
@@ -28,6 +31,7 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
export const ELEMENT_TRANSLATE_AMOUNT = 1;
export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
export const SHIFT_LOCKING_ANGLE = Math.PI / 12;
export const DEFAULT_LASER_COLOR = "red";
export const CURSOR_TYPE = {
TEXT: "text",
CROSSHAIR: "crosshair",
@@ -144,6 +148,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
export const COLOR_WHITE = "#ffffff";
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
// keep this in sync with CSS
export const COLOR_VOICE_CALL = "#a2f1a6";
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable?
@@ -116,8 +116,8 @@
}
@mixin avatarStyles {
width: 1.25rem;
height: 1.25rem;
width: var(--avatar-size, 1.5rem);
height: var(--avatar-size, 1.5rem);
position: relative;
border-radius: 100%;
outline-offset: 2px;
@@ -131,6 +131,10 @@
color: var(--color-gray-90);
flex: 0 0 auto;
&:active {
transform: scale(0.94);
}
&-img {
width: 100%;
height: 100%;
@@ -144,14 +148,14 @@
right: -3px;
bottom: -3px;
left: -3px;
border: 1px solid var(--avatar-border-color);
border-radius: 100%;
}
&--is-followed::before {
&.is-followed::before {
border-color: var(--color-primary-hover);
box-shadow: 0 0 0 1px var(--color-primary-hover);
}
&--is-current-user {
&.is-current-user {
cursor: auto;
}
}
@@ -224,7 +224,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id48",
@@ -269,7 +268,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id48",
@@ -373,7 +371,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id48",
"customData": undefined,
@@ -472,7 +469,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id37",
"customData": undefined,
@@ -643,7 +639,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id41",
"customData": undefined,
@@ -683,7 +678,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id41",
@@ -728,7 +722,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [
{
"id": "id41",
@@ -1174,7 +1167,6 @@ exports[`Test Transform > should transform text element 1`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": null,
"customData": undefined,
@@ -1214,7 +1206,6 @@ exports[`Test Transform > should transform text element 2`] = `
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": null,
"customData": undefined,
@@ -1458,7 +1449,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id25",
"customData": undefined,
@@ -1498,7 +1488,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id26",
"customData": undefined,
@@ -1538,7 +1527,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id27",
"customData": undefined,
@@ -1579,7 +1567,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id28",
"customData": undefined,
@@ -1836,7 +1823,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id13",
"customData": undefined,
@@ -1876,7 +1862,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id14",
"customData": undefined,
@@ -1917,7 +1902,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id15",
"customData": undefined,
@@ -1960,7 +1944,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id16",
"customData": undefined,
@@ -2001,7 +1984,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id17",
"customData": undefined,
@@ -2043,7 +2025,6 @@ exports[`Test Transform > should transform to text containers when label provide
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": null,
"containerId": "id18",
"customData": undefined,
+13 -17
View File
@@ -4,7 +4,6 @@ import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors";
import { t } from "../i18n";
import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types";
import { ValueOf } from "../utility-types";
@@ -23,11 +22,11 @@ const parseFileContents = async (blob: Blob | File) => {
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"Image doesn't contain scene",
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
} else {
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
throw new ImageSceneDataError("Error: cannot restore image");
}
}
} else {
@@ -54,11 +53,11 @@ const parseFileContents = async (blob: Blob | File) => {
} catch (error: any) {
if (error.message === "INVALID") {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"Image doesn't contain scene",
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
} else {
throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
throw new ImageSceneDataError("Error: cannot restore image");
}
}
}
@@ -130,7 +129,7 @@ export const loadSceneOrLibraryFromBlob = async (
} catch (error: any) {
if (isSupportedImageFile(blob)) {
throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"),
"Image doesn't contain scene",
"IMAGE_NOT_CONTAINS_SCENE_DATA",
);
}
@@ -163,12 +162,12 @@ export const loadSceneOrLibraryFromBlob = async (
data,
};
}
throw new Error(t("alerts.couldNotLoadInvalidFile"));
throw new Error("Error: invalid file");
} catch (error: any) {
if (error instanceof ImageSceneDataError) {
throw error;
}
throw new Error(t("alerts.couldNotLoadInvalidFile"));
throw new Error("Error: invalid file");
}
};
@@ -187,7 +186,7 @@ export const loadFromBlob = async (
fileHandle,
);
if (ret.type !== MIME_TYPES.excalidraw) {
throw new Error(t("alerts.couldNotLoadInvalidFile"));
throw new Error("Error: invalid file");
}
return ret.data;
};
@@ -222,10 +221,7 @@ export const canvasToBlob = async (
canvas.toBlob((blob) => {
if (!blob) {
return reject(
new CanvasError(
t("canvasError.canvasTooBig"),
"CANVAS_POSSIBLY_TOO_BIG",
),
new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"),
);
}
resolve(blob);
@@ -314,7 +310,7 @@ export const resizeImageFile = async (
}
if (!isSupportedImageFile(file)) {
throw new Error(t("errors.unsupportedFileType"));
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
}
return new File(
@@ -340,11 +336,11 @@ export const ImageURLToFile = async (
try {
response = await fetch(imageUrl);
} catch (error: any) {
throw new Error(t("errors.failedToFetchImage"));
throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
}
if (!response.ok) {
throw new Error(t("errors.failedToFetchImage"));
throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
}
const blob = await response.blob();
@@ -354,7 +350,7 @@ export const ImageURLToFile = async (
return new File([blob], name, { type: blob.type });
}
throw new Error(t("errors.unsupportedFileType"));
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
};
export const getFileFromEvent = async (
+7 -4
View File
@@ -133,9 +133,12 @@ export const exportCanvas = async (
},
);
} else if (type === "clipboard-svg") {
await copyTextToSystemClipboard(
await svgPromise.then((svg) => svg.outerHTML),
);
const svg = await svgPromise.then((svg) => svg.outerHTML);
try {
await copyTextToSystemClipboard(svg);
} catch (e) {
throw new Error(t("errors.copyToSystemClipboardFailed"));
}
return;
}
}
@@ -176,7 +179,7 @@ export const exportCanvas = async (
} catch (error: any) {
console.warn(error);
if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
throw error;
throw new Error(t("canvasError.canvasTooBig"));
}
// TypeError *probably* suggests ClipboardItem not defined, which
// people on Firefox can enable through a flag, so let's tell them.
+471 -40
View File
@@ -4,6 +4,7 @@ import {
LibraryItem,
ExcalidrawImperativeAPI,
LibraryItemsSource,
LibraryItems_anyVersion,
} from "../types";
import { restoreLibraryItems } from "./restore";
import type App from "../components/App";
@@ -23,13 +24,72 @@ import {
LIBRARY_SIDEBAR_TAB,
} from "../constants";
import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
import { cloneJSON } from "../utils";
import {
arrayToMap,
cloneJSON,
preventUnload,
promiseTry,
resolvablePromise,
} from "../utils";
import { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter";
import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element";
type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */
deletedItems: Map<LibraryItem["id"], LibraryItem>;
/** newly added items in the library */
addedItems: Map<LibraryItem["id"], LibraryItem>;
};
// an object so that we can later add more properties to it without breaking,
// such as schema version
export type LibraryPersistedData = { libraryItems: LibraryItems };
const onLibraryUpdateEmitter = new Emitter<
[update: LibraryUpdate, libraryItems: LibraryItems]
>();
export type LibraryAdatapterSource = "load" | "save";
export interface LibraryPersistenceAdapter {
/**
* Should load data that were previously saved into the database using the
* `save` method. Should throw if saving fails.
*
* Will be used internally in multiple places, such as during save to
* in order to reconcile changes with latest store data.
*/
load(metadata: {
/**
* Indicates whether we're loading data for save purposes, or reading
* purposes, in which case host app can implement more aggressive caching.
*/
source: LibraryAdatapterSource;
}): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
/** Should persist to the database as is (do no change the data structure). */
save(libraryData: LibraryPersistedData): MaybePromise<void>;
}
export interface LibraryMigrationAdapter {
/**
* loads data from legacy data source. Returns `null` if no data is
* to be migrated.
*/
load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
/** clears entire storage afterwards */
clear(): MaybePromise<void>;
}
export const libraryItemsAtom = atom<{
status: "loading" | "loaded";
/** indicates whether library is initialized with library items (has gone
* through at least one update). Used in UI. Specific to this atom only. */
isInitialized: boolean;
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: true, libraryItems: [] });
}>({ status: "loaded", isInitialized: false, libraryItems: [] });
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
cloneJSON(libraryItems);
@@ -74,12 +134,45 @@ export const mergeLibraryItems = (
return [...newItems, ...localItems];
};
/**
* Returns { deletedItems, addedItems } maps of all added and deleted items
* since last onLibraryChange event.
*
* Host apps are recommended to diff with the latest state they have.
*/
const createLibraryUpdate = (
prevLibraryItems: LibraryItems,
nextLibraryItems: LibraryItems,
): LibraryUpdate => {
const nextItemsMap = arrayToMap(nextLibraryItems);
const update: LibraryUpdate = {
deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
addedItems: new Map<LibraryItem["id"], LibraryItem>(),
};
for (const item of prevLibraryItems) {
if (!nextItemsMap.has(item.id)) {
update.deletedItems.set(item.id, item);
}
}
const prevItemsMap = arrayToMap(prevLibraryItems);
for (const item of nextLibraryItems) {
if (!prevItemsMap.has(item.id)) {
update.addedItems.set(item.id, item);
}
}
return update;
};
class Library {
/** latest libraryItems */
private lastLibraryItems: LibraryItems = [];
/** indicates whether library is initialized with library items (has gone
* though at least one update) */
private isInitialized = false;
private currLibraryItems: LibraryItems = [];
/** snapshot of library items since last onLibraryChange call */
private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
private app: App;
@@ -95,21 +188,29 @@ class Library {
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
jotaiStore.set(libraryItemsAtom, {
jotaiStore.set(libraryItemsAtom, (s) => ({
status: "loading",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
});
libraryItems: this.currLibraryItems,
isInitialized: s.isInitialized,
}));
} else {
this.isInitialized = true;
jotaiStore.set(libraryItemsAtom, {
status: "loaded",
libraryItems: this.lastLibraryItems,
isInitialized: this.isInitialized,
libraryItems: this.currLibraryItems,
isInitialized: true,
});
try {
this.app.props.onLibraryChange?.(
cloneLibraryItems(this.lastLibraryItems),
const prevLibraryItems = this.prevLibraryItems;
this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
this.app.props.onLibraryChange?.(nextLibraryItems);
// for internal use in `useHandleLibrary` hook
onLibraryUpdateEmitter.trigger(
createLibraryUpdate(prevLibraryItems, nextLibraryItems),
nextLibraryItems,
);
} catch (error) {
console.error(error);
@@ -119,9 +220,8 @@ class Library {
/** call on excalidraw instance unmount */
destroy = () => {
this.isInitialized = false;
this.updateQueue = [];
this.lastLibraryItems = [];
this.currLibraryItems = [];
jotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
@@ -142,14 +242,14 @@ class Library {
return new Promise(async (resolve) => {
try {
const libraryItems = await (this.getLastUpdateTask() ||
this.lastLibraryItems);
this.currLibraryItems);
if (this.updateQueue.length > 0) {
resolve(this.getLatestLibrary());
} else {
resolve(cloneLibraryItems(libraryItems));
}
} catch (error) {
return resolve(this.lastLibraryItems);
return resolve(this.currLibraryItems);
}
});
};
@@ -181,7 +281,7 @@ class Library {
try {
const source = await (typeof libraryItems === "function" &&
!(libraryItems instanceof Blob)
? libraryItems(this.lastLibraryItems)
? libraryItems(this.currLibraryItems)
: libraryItems);
let nextItems;
@@ -207,7 +307,7 @@ class Library {
}
if (merge) {
resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
resolve(mergeLibraryItems(this.currLibraryItems, nextItems));
} else {
resolve(nextItems);
}
@@ -244,12 +344,12 @@ class Library {
await this.getLastUpdateTask();
if (typeof libraryItems === "function") {
libraryItems = libraryItems(this.lastLibraryItems);
libraryItems = libraryItems(this.currLibraryItems);
}
this.lastLibraryItems = cloneLibraryItems(await libraryItems);
this.currLibraryItems = cloneLibraryItems(await libraryItems);
resolve(this.lastLibraryItems);
resolve(this.currLibraryItems);
} catch (error: any) {
reject(error);
}
@@ -257,7 +357,7 @@ class Library {
.catch((error) => {
if (error.name === "AbortError") {
console.warn("Library update aborted by user");
return this.lastLibraryItems;
return this.currLibraryItems;
}
throw error;
})
@@ -382,20 +482,165 @@ export const parseLibraryTokensFromUrl = () => {
return libraryUrl ? { libraryUrl, idToken } : null;
};
export const useHandleLibrary = ({
excalidrawAPI,
getInitialLibraryItems,
}: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
getInitialLibraryItems?: () => LibraryItemsSource;
}) => {
const getInitialLibraryRef = useRef(getInitialLibraryItems);
class AdapterTransaction {
static queue = new Queue();
static async getLibraryItems(
adapter: LibraryPersistenceAdapter,
source: LibraryAdatapterSource,
_queue = true,
): Promise<LibraryItems> {
const task = () =>
new Promise<LibraryItems>(async (resolve, reject) => {
try {
const data = await adapter.load({ source });
resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
} catch (error: any) {
reject(error);
}
});
if (_queue) {
return AdapterTransaction.queue.push(task);
}
return task();
}
static run = async <T>(
adapter: LibraryPersistenceAdapter,
fn: (transaction: AdapterTransaction) => Promise<T>,
) => {
const transaction = new AdapterTransaction(adapter);
return AdapterTransaction.queue.push(() => fn(transaction));
};
// ------------------
private adapter: LibraryPersistenceAdapter;
constructor(adapter: LibraryPersistenceAdapter) {
this.adapter = adapter;
}
getLibraryItems(source: LibraryAdatapterSource) {
return AdapterTransaction.getLibraryItems(this.adapter, source, false);
}
}
let lastSavedLibraryItemsHash = 0;
let librarySaveCounter = 0;
export const getLibraryItemsHash = (items: LibraryItems) => {
return hashString(
items
.map((item) => {
return `${item.id}:${hashElementsVersion(item.elements)}`;
})
.sort()
.join(),
);
};
const persistLibraryUpdate = async (
adapter: LibraryPersistenceAdapter,
update: LibraryUpdate,
): Promise<LibraryItems> => {
try {
librarySaveCounter++;
return await AdapterTransaction.run(adapter, async (transaction) => {
const nextLibraryItemsMap = arrayToMap(
await transaction.getLibraryItems("save"),
);
for (const [id] of update.deletedItems) {
nextLibraryItemsMap.delete(id);
}
const addedItems: LibraryItem[] = [];
// we want to merge current library items with the ones stored in the
// DB so that we don't lose any elements that for some reason aren't
// in the current editor library, which could happen when:
//
// 1. we haven't received an update deleting some elements
// (in which case it's still better to keep them in the DB lest
// it was due to a different reason)
// 2. we keep a single DB for all active editors, but the editors'
// libraries aren't synced or there's a race conditions during
// syncing
// 3. some other race condition, e.g. during init where emit updates
// for partial updates (e.g. you install a 3rd party library and
// init from DB only after — we emit events for both updates)
for (const [id, item] of update.addedItems) {
if (nextLibraryItemsMap.has(id)) {
// replace item with latest version
// TODO we could prefer the newer item instead
nextLibraryItemsMap.set(id, item);
} else {
// we want to prepend the new items with the ones that are already
// in DB to preserve the ordering we do in editor (newly added
// items are added to the beginning)
addedItems.push(item);
}
}
const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()),
);
const version = getLibraryItemsHash(nextLibraryItems);
if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
}
lastSavedLibraryItemsHash = version;
return nextLibraryItems;
});
} finally {
librarySaveCounter--;
}
};
export const useHandleLibrary = (
opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
} & (
| {
/** @deprecated we recommend using `opts.adapter` instead */
getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>;
}
| {
adapter: LibraryPersistenceAdapter;
/**
* Adapter that takes care of loading data from legacy data store.
* Supply this if you want to migrate data on initial load from legacy
* data store.
*
* Can be a different LibraryPersistenceAdapter.
*/
migrationAdapter?: LibraryMigrationAdapter;
}
),
) => {
const { excalidrawAPI } = opts;
const optsRef = useRef(opts);
optsRef.current = opts;
const isLibraryLoadedRef = useRef(false);
useEffect(() => {
if (!excalidrawAPI) {
return;
}
// reset on editor remount (excalidrawAPI changed)
isLibraryLoadedRef.current = false;
const importLibraryFromURL = async ({
libraryUrl,
idToken,
@@ -463,23 +708,209 @@ export const useHandleLibrary = ({
};
// -------------------------------------------------------------------------
// ------ init load --------------------------------------------------------
if (getInitialLibraryRef.current) {
excalidrawAPI.updateLibrary({
libraryItems: getInitialLibraryRef.current(),
});
}
// ---------------------------------- init ---------------------------------
// -------------------------------------------------------------------------
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (libraryUrlTokens) {
importLibraryFromURL(libraryUrlTokens);
}
// ------ (A) init load (legacy) -------------------------------------------
if (
"getInitialLibraryItems" in optsRef.current &&
optsRef.current.getInitialLibraryItems
) {
console.warn(
"useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.",
);
Promise.resolve(optsRef.current.getInitialLibraryItems())
.then((libraryItems) => {
excalidrawAPI.updateLibrary({
libraryItems,
// merge with current library items because we may have already
// populated it (e.g. by installing 3rd party library which can
// happen before the DB data is loaded)
merge: true,
});
})
.catch((error: any) => {
console.error(
`UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`,
);
});
}
// -------------------------------------------------------------------------
// --------------------------------------------------------- init load -----
// -------------------------------------------------------------------------
// ------ (B) data source adapter ------------------------------------------
if ("adapter" in optsRef.current && optsRef.current.adapter) {
const adapter = optsRef.current.adapter;
const migrationAdapter = optsRef.current.migrationAdapter;
const initDataPromise = resolvablePromise<LibraryItems | null>();
// migrate from old data source if needed
// (note, if `migrate` function is defined, we always migrate even
// if the data has already been migrated. In that case it'll be a no-op,
// though with several unnecessary steps — we will still load latest
// DB data during the `persistLibraryChange()` step)
// -----------------------------------------------------------------------
if (migrationAdapter) {
initDataPromise.resolve(
promiseTry(migrationAdapter.load)
.then(async (libraryData) => {
let restoredData: LibraryItems | null = null;
try {
// if no library data to migrate, assume no migration needed
// and skip persisting to new data store, as well as well
// clearing the old store via `migrationAdapter.clear()`
if (!libraryData) {
return AdapterTransaction.getLibraryItems(adapter, "load");
}
restoredData = restoreLibraryItems(
libraryData.libraryItems || [],
"published",
);
// we don't queue this operation because it's running inside
// a promise that's running inside Library update queue itself
const nextItems = await persistLibraryUpdate(
adapter,
createLibraryUpdate([], restoredData),
);
try {
await migrationAdapter.clear();
} catch (error: any) {
console.error(
`couldn't delete legacy library data: ${error.message}`,
);
}
// migration suceeded, load migrated data
return nextItems;
} catch (error: any) {
console.error(
`couldn't migrate legacy library data: ${error.message}`,
);
// migration failed, load data from previous store, if any
return restoredData;
}
})
// errors caught during `migrationAdapter.load()`
.catch((error: any) => {
console.error(`error during library migration: ${error.message}`);
// as a default, load latest library from current data source
return AdapterTransaction.getLibraryItems(adapter, "load");
}),
);
} else {
initDataPromise.resolve(
promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"),
);
}
// load initial (or migrated) library
excalidrawAPI
.updateLibrary({
libraryItems: initDataPromise.then((libraryItems) => {
const _libraryItems = libraryItems || [];
lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
return _libraryItems;
}),
// merge with current library items because we may have already
// populated it (e.g. by installing 3rd party library which can
// happen before the DB data is loaded)
merge: true,
})
.finally(() => {
isLibraryLoadedRef.current = true;
});
}
// ---------------------------------------------- data source datapter -----
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
return () => {
window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
};
}, [excalidrawAPI]);
}, [
// important this useEffect only depends on excalidrawAPI so it only reruns
// on editor remounts (the excalidrawAPI changes)
excalidrawAPI,
]);
// This effect is run without excalidrawAPI dependency so that host apps
// can run this hook outside of an active editor instance and the library
// update queue/loop survives editor remounts
//
// This effect is still only meant to be run if host apps supply an persitence
// adapter. If we don't have access to it, it the update listener doesn't
// do anything.
useEffect(
() => {
// on update, merge with current library items and persist
// -----------------------------------------------------------------------
const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on(
async (update, nextLibraryItems) => {
const isLoaded = isLibraryLoadedRef.current;
// we want to operate with the latest adapter, but we don't want this
// effect to rerun on every adapter change in case host apps' adapter
// isn't stable
const adapter =
("adapter" in optsRef.current && optsRef.current.adapter) || null;
try {
if (adapter) {
if (
// if nextLibraryItems hash identical to previously saved hash,
// exit early, even if actual upstream state ends up being
// different (e.g. has more data than we have locally), as it'd
// be low-impact scenario.
lastSavedLibraryItemsHash !==
getLibraryItemsHash(nextLibraryItems)
) {
await persistLibraryUpdate(adapter, update);
}
}
} catch (error: any) {
console.error(
`couldn't persist library update: ${error.message}`,
update,
);
// currently we only show error if an editor is loaded
if (isLoaded && optsRef.current.excalidrawAPI) {
optsRef.current.excalidrawAPI.updateScene({
appState: {
errorMessage: t("errors.saveLibraryError"),
},
});
}
}
},
);
const onUnload = (event: Event) => {
if (librarySaveCounter) {
preventUnload(event);
}
};
window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload);
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
unsubOnLibraryUpdate();
lastSavedLibraryItemsHash = 0;
librarySaveCounter = 0;
};
},
[
// this effect must not have any deps so it doesn't rerun
],
);
};
+1 -8
View File
@@ -35,14 +35,13 @@ import {
import { getDefaultAppState } from "../appState";
import { LinearElementEditor } from "../element/linearElementEditor";
import { bumpVersion } from "../element/mutateElement";
import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils";
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
import { arrayToMap } from "../utils";
import { MarkOptional, Mutable } from "../utility-types";
import {
detectLineHeight,
getContainerElement,
getDefaultLineHeight,
measureBaseline,
} from "../element/textElement";
import { normalizeLink } from "./url";
@@ -207,11 +206,6 @@ const restoreElement = (
: // no element height likely means programmatic use, so default
// to a fixed line height
getDefaultLineHeight(element.fontFamily));
const baseline = measureBaseline(
element.text,
getFontString(element),
lineHeight,
);
element = restoreElementWithProperties(element, {
fontSize,
fontFamily,
@@ -222,7 +216,6 @@ const restoreElement = (
originalText: element.originalText || text,
lineHeight,
baseline,
});
// if empty text, mark as deleted. We keep in array
+1 -2
View File
@@ -3,7 +3,6 @@
// -----------------------------------------------------------------------------
import { MIME_TYPES, SVG_NS } from "../constants";
import { t } from "../i18n";
import { AppClassProperties, DataURL, BinaryFiles } from "../types";
import { isInitializedImageElement } from "./typeChecks";
import {
@@ -100,7 +99,7 @@ export const normalizeSVG = async (SVGString: string) => {
const svg = doc.querySelector("svg");
const errorNode = doc.querySelector("parsererror");
if (errorNode || !isHTMLSVGElement(svg)) {
throw new Error(t("errors.invalidSVGString"));
throw new Error("Invalid SVG");
} else {
if (!svg.hasAttribute("xmlns")) {
svg.setAttribute("xmlns", SVG_NS);
+27
View File
@@ -60,9 +60,36 @@ export {
} from "./sizeHelpers";
export { showSelectedShapeActions } from "./showSelectedShapeActions";
/**
* @deprecated unsafe, use hashElementsVersion instead
*/
export const getSceneVersion = (elements: readonly ExcalidrawElement[]) =>
elements.reduce((acc, el) => acc + el.version, 0);
/**
* Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
*/
export const hashElementsVersion = (
elements: readonly ExcalidrawElement[],
): number => {
let hash = 5381;
for (let i = 0; i < elements.length; i++) {
hash = (hash << 5) + hash + elements[i].versionNonce;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};
// string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such.
export const hashString = (s: string): number => {
let hash: number = 5381;
for (let i = 0; i < s.length; i++) {
const char: number = s.charCodeAt(i);
hash = (hash << 5) + hash + char;
}
return hash >>> 0; // Ensure unsigned 32-bit integer
};
export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
elements.filter(
(el) => !el.isDeleted && !isInvisiblySmallElement(el),
+5 -8
View File
@@ -249,7 +249,6 @@ export const newTextElement = (
y: opts.y - offsets.y,
width: metrics.width,
height: metrics.height,
baseline: metrics.baseline,
containerId: opts.containerId || null,
originalText: text,
lineHeight,
@@ -268,13 +267,12 @@ const getAdjustedDimensions = (
y: number;
width: number;
height: number;
baseline: number;
} => {
const {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
} = measureText(nextText, getFontString(element), element.lineHeight);
const { width: nextWidth, height: nextHeight } = measureText(
nextText,
getFontString(element),
element.lineHeight,
);
const { textAlign, verticalAlign } = element;
let x: number;
let y: number;
@@ -328,7 +326,6 @@ const getAdjustedDimensions = (
return {
width: nextWidth,
height: nextHeight,
baseline: nextBaseline,
x: Number.isFinite(x) ? x : element.x,
y: Number.isFinite(y) ? y : element.y,
};
+5 -30
View File
@@ -52,8 +52,6 @@ import {
handleBindTextResize,
getBoundTextMaxWidth,
getApproxMinLineHeight,
measureText,
getBoundTextMaxHeight,
} from "./textElement";
import { LinearElementEditor } from "./linearElementEditor";
@@ -213,8 +211,7 @@ const measureFontSizeFromWidth = (
element: NonDeleted<ExcalidrawTextElement>,
elementsMap: ElementsMap,
nextWidth: number,
nextHeight: number,
): { size: number; baseline: number } | null => {
): { size: number } | null => {
// We only use width to scale font on resize
let width = element.width;
@@ -229,14 +226,9 @@ const measureFontSizeFromWidth = (
if (nextFontSize < MIN_FONT_SIZE) {
return null;
}
const metrics = measureText(
element.text,
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
element.lineHeight,
);
return {
size: nextFontSize,
baseline: metrics.baseline + (nextHeight - metrics.height),
};
};
@@ -309,12 +301,7 @@ const resizeSingleTextElement = (
if (scale > 0) {
const nextWidth = element.width * scale;
const nextHeight = element.height * scale;
const metrics = measureFontSizeFromWidth(
element,
elementsMap,
nextWidth,
nextHeight,
);
const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth);
if (metrics === null) {
return;
}
@@ -342,7 +329,6 @@ const resizeSingleTextElement = (
fontSize: metrics.size,
width: nextWidth,
height: nextHeight,
baseline: metrics.baseline,
x: nextElementX,
y: nextElementY,
});
@@ -396,7 +382,7 @@ export const resizeSingleElement = (
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
let scaleY = atStartBoundsHeight / boundsCurrentHeight;
let boundTextFont: { fontSize?: number; baseline?: number } = {};
let boundTextFont: { fontSize?: number } = {};
const boundTextElement = getBoundTextElement(element, elementsMap);
if (transformHandleDirection.includes("e")) {
@@ -448,7 +434,6 @@ export const resizeSingleElement = (
if (stateOfBoundTextElementAtResize) {
boundTextFont = {
fontSize: stateOfBoundTextElementAtResize.fontSize,
baseline: stateOfBoundTextElementAtResize.baseline,
};
}
if (shouldMaintainAspectRatio) {
@@ -462,14 +447,12 @@ export const resizeSingleElement = (
boundTextElement,
elementsMap,
getBoundTextMaxWidth(updatedElement, boundTextElement),
getBoundTextMaxHeight(updatedElement, boundTextElement),
);
if (nextFont === null) {
return;
}
boundTextFont = {
fontSize: nextFont.size,
baseline: nextFont.baseline,
};
} else {
const minWidth = getApproxMinLineWidth(
@@ -638,7 +621,6 @@ export const resizeSingleElement = (
if (boundTextElement && boundTextFont != null) {
mutateElement(boundTextElement, {
fontSize: boundTextFont.fontSize,
baseline: boundTextFont.baseline,
});
}
handleBindTextResize(
@@ -769,7 +751,6 @@ export const resizeMultipleElements = (
> & {
points?: ExcalidrawLinearElement["points"];
fontSize?: ExcalidrawTextElement["fontSize"];
baseline?: ExcalidrawTextElement["baseline"];
scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
};
@@ -844,17 +825,11 @@ export const resizeMultipleElements = (
}
if (isTextElement(orig)) {
const metrics = measureFontSizeFromWidth(
orig,
elementsMap,
width,
height,
);
const metrics = measureFontSizeFromWidth(orig, elementsMap, width);
if (!metrics) {
return;
}
update.fontSize = metrics.size;
update.baseline = metrics.baseline;
}
const boundTextElement = originalElements.get(
+67 -65
View File
@@ -18,7 +18,6 @@ import {
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
FONT_FAMILY,
isSafari,
TEXT_ALIGN,
VERTICAL_ALIGN,
} from "../constants";
@@ -32,7 +31,7 @@ import { getElementAbsoluteCoords } from ".";
import { getSelectedElements } from "../scene";
import { isHittingElementNotConsideringBoundingBox } from "./collision";
import { ExtractSetType } from "../utility-types";
import { ExtractSetType, MakeBrand } from "../utility-types";
import {
resetOriginalContainerCache,
updateOriginalContainerCache,
@@ -62,7 +61,6 @@ export const redrawTextBoundingBox = (
text: textElement.text,
width: textElement.width,
height: textElement.height,
baseline: textElement.baseline,
};
boundTextUpdates.text = textElement.text;
@@ -83,7 +81,6 @@ export const redrawTextBoundingBox = (
boundTextUpdates.width = metrics.width;
boundTextUpdates.height = metrics.height;
boundTextUpdates.baseline = metrics.baseline;
if (container) {
const maxContainerHeight = getBoundTextMaxHeight(
@@ -188,7 +185,6 @@ export const handleBindTextResize = (
const maxWidth = getBoundTextMaxWidth(container, textElement);
const maxHeight = getBoundTextMaxHeight(container, textElement);
let containerHeight = container.height;
let nextBaseLine = textElement.baseline;
if (
shouldMaintainAspectRatio ||
(transformHandleType !== "n" && transformHandleType !== "s")
@@ -207,7 +203,6 @@ export const handleBindTextResize = (
);
nextHeight = metrics.height;
nextWidth = metrics.width;
nextBaseLine = metrics.baseline;
}
// increase height in case text element height exceeds
if (nextHeight > maxHeight) {
@@ -235,7 +230,6 @@ export const handleBindTextResize = (
text,
width: nextWidth,
height: nextHeight,
baseline: nextBaseLine,
});
if (!isArrowElement(container)) {
@@ -285,8 +279,6 @@ export const computeBoundTextPosition = (
return { x, y };
};
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export const measureText = (
text: string,
font: FontString,
@@ -301,59 +293,7 @@ export const measureText = (
const fontSize = parseFloat(font);
const height = getTextHeight(text, fontSize, lineHeight);
const width = getTextWidth(text, font);
const baseline = measureBaseline(text, font, lineHeight);
return { width, height, baseline };
};
export const measureBaseline = (
text: string,
font: FontString,
lineHeight: ExcalidrawTextElement["lineHeight"],
wrapInContainer?: boolean,
) => {
const container = document.createElement("div");
container.style.position = "absolute";
container.style.whiteSpace = "pre";
container.style.font = font;
container.style.minHeight = "1em";
if (wrapInContainer) {
container.style.overflow = "hidden";
container.style.wordBreak = "break-word";
container.style.whiteSpace = "pre-wrap";
}
container.style.lineHeight = String(lineHeight);
container.innerText = text;
// Baseline is important for positioning text on canvas
document.body.appendChild(container);
const span = document.createElement("span");
span.style.display = "inline-block";
span.style.overflow = "hidden";
span.style.width = "1px";
span.style.height = "1px";
container.appendChild(span);
let baseline = span.offsetTop + span.offsetHeight;
const height = container.offsetHeight;
if (isSafari) {
const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight);
const fontSize = parseFloat(font);
// In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs
// from the actual canvas height
const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight);
if (canvasHeight > height) {
baseline += canvasHeight - domHeight;
}
if (height > canvasHeight) {
baseline -= domHeight - canvasHeight;
}
}
document.body.removeChild(container);
return baseline;
return { width, height };
};
/**
@@ -378,6 +318,24 @@ export const getLineHeightInPx = (
return fontSize * lineHeight;
};
/**
* Calculates vertical offset for a text with alphabetic baseline.
*/
export const getVerticalOffset = (
fontFamily: ExcalidrawTextElement["fontFamily"],
fontSize: ExcalidrawTextElement["fontSize"],
lineHeightPx: number,
) => {
const { unitsPerEm, ascender, descender } =
FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
const fontSizeEm = fontSize / unitsPerEm;
const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
const verticalOffset = fontSizeEm * ascender + lineGap;
return verticalOffset;
};
// FIXME rename to getApproxMinContainerHeight
export const getApproxMinLineHeight = (
fontSize: ExcalidrawTextElement["fontSize"],
@@ -964,13 +922,57 @@ const DEFAULT_LINE_HEIGHT = {
// ~1.25 is the average for Virgil in WebKit and Blink.
// Gecko (FF) uses ~1.28.
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
// ~1.15 is the average for Virgil in WebKit and Blink.
// Gecko if all over the place.
// ~1.15 is the average for Helvetica in WebKit and Blink.
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
// ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
};
/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
type sTypoAscender = number & MakeBrand<"sTypoAscender">;
/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
type sTypoDescender = number & MakeBrand<"sTypoDescender">;
/** head.unitsPerEm, usually either 1000 or 2048 */
type unitsPerEm = number & MakeBrand<"unitsPerEm">;
/**
* Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
* For custom fonts, read these metrics from OS/2 table and extend this object.
*
* WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
*/
export const FONT_METRICS: Record<
number,
{
unitsPerEm: number;
ascender: sTypoAscender;
descender: sTypoDescender;
}
> = {
[FONT_FAMILY.Virgil]: {
unitsPerEm: 1000 as unitsPerEm,
ascender: 886 as sTypoAscender,
descender: -374 as sTypoDescender,
},
[FONT_FAMILY.Helvetica]: {
unitsPerEm: 2048 as unitsPerEm,
ascender: 1577 as sTypoAscender,
descender: -471 as sTypoDescender,
},
[FONT_FAMILY.Cascadia]: {
unitsPerEm: 2048 as unitsPerEm,
ascender: 1977 as sTypoAscender,
descender: -480 as sTypoDescender,
},
[FONT_FAMILY.Assistant]: {
unitsPerEm: 1000 as unitsPerEm,
ascender: 1021 as sTypoAscender,
descender: -287 as sTypoDescender,
},
};
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
if (fontFamily in DEFAULT_LINE_HEIGHT) {
return DEFAULT_LINE_HEIGHT[fontFamily];
+2 -15
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, isSafari } from "../constants";
import { CLASSES } from "../constants";
import {
ExcalidrawElement,
ExcalidrawLinearElement,
@@ -31,7 +31,6 @@ import {
getBoundTextMaxHeight,
getBoundTextMaxWidth,
computeContainerDimensionForBoundText,
detectLineHeight,
computeBoundTextPosition,
getBoundTextElement,
} from "./textElement";
@@ -227,18 +226,6 @@ export const textWysiwyg = ({
if (!container) {
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
textElementWidth = Math.min(textElementWidth, maxWidth);
} else {
textElementWidth += 0.5;
}
let lineHeight = updatedTextElement.lineHeight;
// In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size
if (isSafari) {
lineHeight = detectLineHeight({
...updatedTextElement,
fontSize: Math.round(updatedTextElement.fontSize),
});
}
// Make sure text editor height doesn't go beyond viewport
@@ -247,7 +234,7 @@ export const textWysiwyg = ({
Object.assign(editable.style, {
font: getFontString(updatedTextElement),
// must be defined *after* font ¯\_(ツ)_/¯
lineHeight,
lineHeight: updatedTextElement.lineHeight,
width: `${textElementWidth}px`,
height: `${textElementHeight}px`,
left: `${viewportX}px`,
-1
View File
@@ -176,7 +176,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
fontSize: number;
fontFamily: FontFamilyValues;
text: string;
baseline: number;
textAlign: TextAlign;
verticalAlign: VerticalAlign;
containerId: ExcalidrawGenericElement["id"] | null;
+8 -23
View File
@@ -21,12 +21,9 @@ import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds";
import {
doLineSegmentsIntersect,
elementsOverlappingBBox,
} from "../utils/export";
import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
import { ReadonlySetLike } from "./utility-types";
@@ -377,25 +374,13 @@ export const getElementsInNewFrame = (
export const getContainingFrame = (
element: ExcalidrawElement,
/**
* Optionally an elements map, in case the elements aren't in the Scene yet.
* Takes precedence over Scene elements, even if the element exists
* in Scene elements and not the supplied elements map.
*/
elementsMap?: Map<string, ExcalidrawElement>,
elementsMap: ElementsMap,
) => {
if (element.frameId) {
if (elementsMap) {
return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameLikeElement;
}
return (
(Scene.getScene(element)?.getElement(
element.frameId,
) as ExcalidrawFrameLikeElement) || null
);
if (!element.frameId) {
return null;
}
return null;
return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameLikeElement;
};
// --------------------------- Frame Operations -------------------------------
@@ -697,7 +682,7 @@ export const getTargetFrame = (
return appState.selectedElementIds[_element.id] &&
appState.selectedElementsAreBeingDragged
? appState.frameToHighlight
: getContainingFrame(_element);
: getContainingFrame(_element, elementsMap);
};
// TODO: this a huge bottleneck for large scenes, optimise
+20 -9
View File
@@ -207,6 +207,8 @@ Excalidraw.displayName = "Excalidraw";
export {
getSceneVersion,
hashElementsVersion,
hashString,
isInvisiblySmallElement,
getNonDeletedElements,
} from "./element";
@@ -217,22 +219,31 @@ export {
restoreElements,
restoreLibraryItems,
} from "./data/restore";
export {
exportToCanvas,
exportToBlob,
exportToSvg,
serializeAsJSON,
serializeLibraryAsJSON,
loadLibraryFromBlob,
exportToClipboard,
} from "../utils/export";
export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
export {
loadFromBlob,
loadSceneOrLibraryFromBlob,
getFreeDrawSvgPath,
exportToClipboard,
mergeLibraryItems,
} from "../utils/export";
loadLibraryFromBlob,
} from "./data/blob";
export { getFreeDrawSvgPath } from "./renderer/renderElement";
export { mergeLibraryItems, getLibraryItemsHash } from "./data/library";
export { isLinearElement } from "./element/typeChecks";
export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants";
export {
FONT_FAMILY,
THEME,
MIME_TYPES,
ROUNDNESS,
DEFAULT_LASER_COLOR,
} from "./constants";
export {
mutateElement,
@@ -268,4 +279,4 @@ export {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "../utils/export";
} from "../utils/withinBounds";
+14 -11
View File
@@ -5,6 +5,7 @@ import type App from "./components/App";
import { SocketId } from "./types";
import { easeOut } from "./utils";
import { getClientColor } from "./clients";
import { DEFAULT_LASER_COLOR } from "./constants";
export class LaserTrails implements Trail {
public localTrail: AnimatedTrail;
@@ -20,7 +21,7 @@ export class LaserTrails implements Trail {
this.localTrail = new AnimatedTrail(animationFrameHandler, app, {
...this.getTrailOptions(),
fill: () => "red",
fill: () => DEFAULT_LASER_COLOR,
});
}
@@ -78,13 +79,15 @@ export class LaserTrails implements Trail {
return;
}
for (const [key, collabolator] of this.app.state.collaborators.entries()) {
for (const [key, collaborator] of this.app.state.collaborators.entries()) {
let trail!: AnimatedTrail;
if (!this.collabTrails.has(key)) {
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
...this.getTrailOptions(),
fill: () => getClientColor(key),
fill: () =>
collaborator.pointer?.laserColor ||
getClientColor(key, collaborator),
});
trail.start(this.container);
@@ -93,21 +96,21 @@ export class LaserTrails implements Trail {
trail = this.collabTrails.get(key)!;
}
if (collabolator.pointer && collabolator.pointer.tool === "laser") {
if (collabolator.button === "down" && !trail.hasCurrentTrail) {
trail.startPath(collabolator.pointer.x, collabolator.pointer.y);
if (collaborator.pointer && collaborator.pointer.tool === "laser") {
if (collaborator.button === "down" && !trail.hasCurrentTrail) {
trail.startPath(collaborator.pointer.x, collaborator.pointer.y);
}
if (
collabolator.button === "down" &&
collaborator.button === "down" &&
trail.hasCurrentTrail &&
!trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y)
!trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y)
) {
trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
}
if (collabolator.button === "up" && trail.hasCurrentTrail) {
trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y);
if (collaborator.button === "up" && trail.hasCurrentTrail) {
trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y);
trail.endPath();
}
}
+6 -3
View File
@@ -214,9 +214,9 @@
"fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
"svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
"failedToFetchImage": "Failed to fetch image.",
"invalidSVGString": "Invalid SVG.",
"cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.",
"importLibraryError": "Couldn't load library",
"saveLibraryError": "Couldn't save library to storage. Please save your library to a file locally to make sure you don't lose changes.",
"collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
"collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
"imageToolNotSupported": "Images are disabled.",
@@ -248,7 +248,7 @@
"library": "Library",
"lock": "Keep selected tool active after drawing",
"penMode": "Pen mode - prevent touch",
"link": "Add/ Update link for a selected shape",
"link": "Add / Update link for a selected shape",
"eraser": "Eraser",
"frame": "Frame tool",
"magicframe": "Wireframe to code",
@@ -534,7 +534,10 @@
},
"hint": {
"text": "Click on user to follow",
"followStatus": "You're currently following this user"
"followStatus": "You're currently following this user",
"inCall": "User is in a voice call",
"micMuted": "User's microphone is muted",
"isSpeaking": "User is speaking"
}
}
}
+62
View File
@@ -0,0 +1,62 @@
import { Queue } from "./queue";
describe("Queue", () => {
const calls: any[] = [];
const createJobFactory =
<T>(
// for purpose of this test, Error object will become a rejection value
resolutionOrRejectionValue: T,
ms = 1,
) =>
() => {
return new Promise<T>((resolve, reject) => {
setTimeout(() => {
if (resolutionOrRejectionValue instanceof Error) {
reject(resolutionOrRejectionValue);
} else {
resolve(resolutionOrRejectionValue);
}
}, ms);
}).then((x) => {
calls.push(x);
return x;
});
};
beforeEach(() => {
calls.length = 0;
});
it("should await and resolve values in order of enqueueing", async () => {
const queue = new Queue();
const p1 = queue.push(createJobFactory("A", 50));
const p2 = queue.push(createJobFactory("B"));
const p3 = queue.push(createJobFactory("C"));
expect(await p3).toBe("C");
expect(await p2).toBe("B");
expect(await p1).toBe("A");
expect(calls).toEqual(["A", "B", "C"]);
});
it("should reject a job if it throws, and not affect other jobs", async () => {
const queue = new Queue();
const err = new Error("B");
queue.push(createJobFactory("A", 50));
const p2 = queue.push(createJobFactory(err));
const p3 = queue.push(createJobFactory("C"));
const p2err = p2.catch((err) => err);
await p3;
expect(await p2err).toBe(err);
expect(calls).toEqual(["A", "C"]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { MaybePromise } from "./utility-types";
import { promiseTry, ResolvablePromise, resolvablePromise } from "./utils";
type Job<T, TArgs extends unknown[]> = (...args: TArgs) => MaybePromise<T>;
type QueueJob<T, TArgs extends unknown[]> = {
jobFactory: Job<T, TArgs>;
promise: ResolvablePromise<T>;
args: TArgs;
};
export class Queue {
private jobs: QueueJob<any, any[]>[] = [];
private running = false;
private tick() {
if (this.running) {
return;
}
const job = this.jobs.shift();
if (job) {
this.running = true;
job.promise.resolve(
promiseTry(job.jobFactory, ...job.args).finally(() => {
this.running = false;
this.tick();
}),
);
} else {
this.running = false;
}
}
push<TValue, TArgs extends unknown[]>(
jobFactory: Job<TValue, TArgs>,
...args: TArgs
): Promise<TValue> {
const promise = resolvablePromise<TValue>();
this.jobs.push({ jobFactory, promise, args });
this.tick();
return promise;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { StaticCanvasAppState, AppState } from "../types";
import { StaticCanvasRenderConfig } from "../scene/types";
import { THEME_FILTER } from "../constants";
export const fillCircle = (
context: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
stroke = true,
) => {
context.beginPath();
context.arc(cx, cy, radius, 0, Math.PI * 2);
context.fill();
if (stroke) {
context.stroke();
}
};
export const getNormalizedCanvasDimensions = (
canvas: HTMLCanvasElement,
scale: number,
): [number, number] => {
// When doing calculations based on canvas width we should used normalized one
return [canvas.width / scale, canvas.height / scale];
};
export const bootstrapCanvas = ({
canvas,
scale,
normalizedWidth,
normalizedHeight,
theme,
isExporting,
viewBackgroundColor,
}: {
canvas: HTMLCanvasElement;
scale: number;
normalizedWidth: number;
normalizedHeight: number;
theme?: AppState["theme"];
isExporting?: StaticCanvasRenderConfig["isExporting"];
viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"];
}): CanvasRenderingContext2D => {
const context = canvas.getContext("2d")!;
context.setTransform(1, 0, 0, 1, 0, 0);
context.scale(scale, scale);
if (isExporting && theme === "dark") {
context.filter = THEME_FILTER;
}
// Paint background
if (typeof viewBackgroundColor === "string") {
const hasTransparence =
viewBackgroundColor === "transparent" ||
viewBackgroundColor.length === 5 || // #RGBA
viewBackgroundColor.length === 9 || // #RRGGBBA
/(hsla|rgba)\(/.test(viewBackgroundColor);
if (hasTransparence) {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
context.save();
context.fillStyle = viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore();
} else {
context.clearRect(0, 0, normalizedWidth, normalizedHeight);
}
return context;
};
+20 -575
View File
@@ -20,27 +20,17 @@ import {
} from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds";
import type { RoughCanvas } from "roughjs/bin/canvas";
import type { Drawable } from "roughjs/bin/core";
import type { RoughSVG } from "roughjs/bin/svg";
import {
SVGRenderConfig,
StaticCanvasRenderConfig,
RenderableElementsMap,
} from "../scene/types";
import {
distance,
getFontString,
getFontFamilyString,
isRTL,
isTestEnv,
} from "../utils";
import { getCornerRadius, isPathALoop, isRightAngle } from "../math";
import { distance, getFontString, isRTL } from "../utils";
import { getCornerRadius, isRightAngle } from "../math";
import rough from "roughjs/bin/rough";
import {
AppState,
StaticCanvasAppState,
BinaryFiles,
Zoom,
InteractiveCanvasAppState,
ElementsPendingErasure,
@@ -50,9 +40,7 @@ import {
BOUND_TEXT_PADDING,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES,
SVG_NS,
} from "../constants";
import { getStroke, StrokeOptions } from "perfect-freehand";
import {
@@ -62,21 +50,19 @@ import {
getLineHeightInPx,
getBoundTextMaxHeight,
getBoundTextMaxWidth,
getVerticalOffset,
} from "../element/textElement";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
createPlaceholderEmbeddableLabel,
getEmbedLink,
} from "../element/embeddable";
import { getContainingFrame } from "../frame";
import { normalizeLink, toValidURL } from "../data/url";
import { ShapeCache } from "../scene/ShapeCache";
// using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original
// color scheme (it's still not quite there and the colors look slightly
// desatured, alas...)
const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)";
export const IMAGE_INVERT_FILTER =
"invert(100%) hue-rotate(180deg) saturate(1.25)";
const defaultAppState = getDefaultAppState();
@@ -257,7 +243,8 @@ const generateElementCanvas = (
canvasOffsetY,
boundTextElementVersion:
getBoundTextElement(element, elementsMap)?.version || null,
containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
containingFrameOpacity:
getContainingFrame(element, elementsMap)?.opacity || 100,
};
};
@@ -397,16 +384,23 @@ const drawElementOnCanvas = (
: element.textAlign === "right"
? element.width
: 0;
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const verticalOffset = element.height - element.baseline;
const verticalOffset = getVerticalOffset(
element.fontFamily,
element.fontSize,
lineHeightPx,
);
for (let index = 0; index < lines.length; index++) {
context.fillText(
lines[index],
horizontalOffset,
(index + 1) * lineHeightPx - verticalOffset,
index * lineHeightPx + verticalOffset,
);
}
context.restore();
@@ -440,7 +434,8 @@ const generateElementWithCanvas = (
const boundTextElementVersion =
getBoundTextElement(element, elementsMap)?.version || null;
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
const containingFrameOpacity =
getContainingFrame(element, elementsMap)?.opacity || 100;
if (
!prevElementWithCanvas ||
@@ -652,7 +647,7 @@ export const renderElement = (
) => {
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element),
getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure,
);
@@ -903,556 +898,6 @@ export const renderElement = (
context.globalAlpha = 1;
};
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
drawable: Drawable,
precision?: number,
) => {
if (typeof precision === "undefined") {
return rsvg.draw(drawable);
}
const pshape: Drawable = {
sets: drawable.sets,
shape: drawable.shape,
options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
};
return rsvg.draw(pshape);
};
const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
frameRendering: AppState["frameRendering"],
) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element);
if (frame) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node));
return g;
}
return null;
};
export const renderElementToSvg = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
offsetX: number,
offsetY: number,
renderConfig: SVGRenderConfig,
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
let cx = (x2 - x1) / 2 - (element.x - x1);
let cy = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
offsetX = offsetX + boundTextCoords.x - element.x;
offsetY = offsetY + boundTextCoords.y - element.y;
}
}
const degree = (180 * element.angle) / Math.PI;
// element to append node to, most of the time svgRoot
let root = svgRoot;
// if the element has a link, create an anchor tag and make that the new root
if (element.link) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link));
root.appendChild(anchorTag);
root = anchorTag;
}
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const opacity =
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) {
case "selection": {
// Since this is used only during editing experience, which is canvas based,
// this should not happen
throw new Error("Selection rendering is not supported for SVG");
}
case "rectangle":
case "diamond":
case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
);
addToRoot(g || node, element);
break;
}
case "iframe":
case "embeddable": {
// render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, renderConfig);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
addToRoot(node, element);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
renderElementToSvg(
label,
elementsMap,
rsvg,
root,
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
renderConfig,
);
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
embeddableNode.setAttribute("stroke-linecap", "round");
embeddableNode.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
while (embeddableNode.firstChild) {
embeddableNode.removeChild(embeddableNode.firstChild);
}
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const embedLink = getEmbedLink(toValidURL(element.link || ""));
// if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead
if (
renderConfig.renderEmbeddables === false ||
embedLink?.type === "document"
) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
anchorTag.setAttribute("target", "_blank");
anchorTag.setAttribute("rel", "noopener noreferrer");
anchorTag.style.borderRadius = `${radius}px`;
embeddableNode.appendChild(anchorTag);
} else {
const foreignObject = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"foreignObject",
);
foreignObject.style.width = `${element.width}px`;
foreignObject.style.height = `${element.height}px`;
foreignObject.style.border = "none";
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
div.style.width = "100%";
div.style.height = "100%";
const iframe = div.ownerDocument!.createElement("iframe");
iframe.src = embedLink?.link ?? "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.borderRadius = `${radius}px`;
iframe.style.top = "0";
iframe.style.left = "0";
iframe.allowFullscreen = true;
div.appendChild(iframe);
foreignObject.appendChild(div);
embeddableNode.appendChild(foreignObject);
}
addToRoot(embeddableNode, element);
break;
}
case "line":
case "arrow": {
const boundText = getBoundTextElement(element, elementsMap);
const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
if (boundText) {
maskPath.setAttribute("id", `mask-${element.id}`);
const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
offsetX = offsetX || 0;
offsetY = offsetY || 0;
maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff");
maskRectVisible.setAttribute(
"width",
`${element.width + 100 + offsetX}`,
);
maskRectVisible.setAttribute(
"height",
`${element.height + 100 + offsetY}`,
);
maskPath.appendChild(maskRectVisible);
const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
element,
boundText,
elementsMap,
);
const maskX = offsetX + boundTextCoords.x - element.x;
const maskY = offsetY + boundTextCoords.y - element.y;
maskRectInvisible.setAttribute("x", maskX.toString());
maskRectInvisible.setAttribute("y", maskY.toString());
maskRectInvisible.setAttribute("fill", "#000");
maskRectInvisible.setAttribute("width", `${boundText.width}`);
maskRectInvisible.setAttribute("height", `${boundText.height}`);
maskRectInvisible.setAttribute("opacity", "1");
maskPath.appendChild(maskRectInvisible);
}
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`);
}
group.setAttribute("stroke-linecap", "round");
const shapes = ShapeCache.generateElementShape(element, renderConfig);
shapes.forEach((shape) => {
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (
element.type === "line" &&
isPathALoop(element.points) &&
element.backgroundColor !== "transparent"
) {
node.setAttribute("fill-rule", "evenodd");
}
group.appendChild(node);
});
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[group, maskPath],
renderConfig.frameRendering,
);
if (g) {
addToRoot(g, element);
root.appendChild(g);
} else {
addToRoot(group, element);
root.append(maskPath);
}
break;
}
case "freedraw": {
const backgroundFillShape = ShapeCache.generateElementShape(
element,
renderConfig,
);
const node = backgroundFillShape
? roughSVGDrawWithPrecision(
rsvg,
backgroundFillShape,
MAX_DECIMALS_FOR_SVG_EXPORT,
)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
node.setAttribute("stroke", "none");
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
);
addToRoot(g || node, element);
break;
}
case "image": {
const width = Math.round(element.width);
const height = Math.round(element.height);
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (fileData) {
const symbolId = `image-${fileData.id}`;
let symbol = svgRoot.querySelector(`#${symbolId}`);
if (!symbol) {
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
symbol.appendChild(image);
root.prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
use.setAttribute("width", `${width}`);
use.setAttribute("height", `${height}`);
use.setAttribute("opacity", `${opacity}`);
// We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation
// on the <g> element which wraps the <use>.
// Doing this separately is a quick hack to to work around compositing
// the transformations correctly (the transform-origin was not being
// applied correctly).
if (element.scale[0] !== 1 || element.scale[1] !== 1) {
const translateX = element.scale[0] !== 1 ? -width : 0;
const translateY = element.scale[1] !== 1 ? -height : 0;
use.setAttribute(
"transform",
`scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
);
}
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
g.appendChild(use);
g.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (element.roundness) {
const clipPath = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"clipPath",
);
clipPath.id = `image-clipPath-${element.id}`;
const clipRect = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
clipRect.setAttribute("width", `${element.width}`);
clipRect.setAttribute("height", `${element.height}`);
clipRect.setAttribute("rx", `${radius}`);
clipRect.setAttribute("ry", `${radius}`);
clipPath.appendChild(clipRect);
addToRoot(clipPath, element);
g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`);
}
const clipG = maybeWrapNodesInFrameClipPath(
element,
root,
[g],
renderConfig.frameRendering,
);
addToRoot(clipG || g, element);
}
break;
}
// frames are not rendered and only acts as a container
case "frame":
case "magicframe": {
if (
renderConfig.frameRendering.enabled &&
renderConfig.frameRendering.outline
) {
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
rect.setAttribute("width", `${element.width}px`);
rect.setAttribute("height", `${element.height}px`);
// Rounded corners
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
rect.setAttribute("fill", "none");
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
addToRoot(rect, element);
}
break;
}
default: {
if (isTextElement(element)) {
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
: element.textAlign === "right"
? element.width
: 0;
const direction = isRTL(element.text) ? "rtl" : "ltr";
const textAnchor =
element.textAlign === "center"
? "middle"
: element.textAlign === "right" || direction === "rtl"
? "end"
: "start";
for (let i = 0; i < lines.length; i++) {
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeightPx}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction);
text.setAttribute("dominant-baseline", "text-before-edge");
node.appendChild(text);
}
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
);
addToRoot(g || node, element);
} else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);
}
}
}
};
export const pathsCache = new WeakMap<ExcalidrawFreeDrawElement, Path2D>([]);
export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) {
+370
View File
@@ -0,0 +1,370 @@
import { FRAME_STYLE } from "../constants";
import { getElementAbsoluteCoords } from "../element";
import {
elementOverlapsWithFrame,
getTargetFrame,
isElementInFrame,
} from "../frame";
import {
isEmbeddableElement,
isIframeLikeElement,
} from "../element/typeChecks";
import { renderElement } from "../renderer/renderElement";
import { createPlaceholderEmbeddableLabel } from "../element/embeddable";
import { StaticCanvasAppState, Zoom } from "../types";
import {
ElementsMap,
ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement,
} from "../element/types";
import {
StaticCanvasRenderConfig,
StaticSceneRenderConfig,
} from "../scene/types";
import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../components/hyperlink/helpers";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils";
const strokeGrid = (
context: CanvasRenderingContext2D,
gridSize: number,
scrollX: number,
scrollY: number,
zoom: Zoom,
width: number,
height: number,
) => {
const BOLD_LINE_FREQUENCY = 5;
enum GridLineColor {
Bold = "#cccccc",
Regular = "#e5e5e5",
}
const offsetX =
-Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize);
const offsetY =
-Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize);
const lineWidth = Math.min(1 / zoom.value, 1);
const spaceWidth = 1 / zoom.value;
const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)];
context.save();
context.lineWidth = lineWidth;
for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) {
const isBold =
Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
context.beginPath();
context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(x, offsetY - gridSize);
context.lineTo(x, offsetY + height + gridSize * 2);
context.stroke();
}
for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) {
const isBold =
Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0;
context.beginPath();
context.setLineDash(isBold ? [] : lineDash);
context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular;
context.moveTo(offsetX - gridSize, y);
context.lineTo(offsetX + width + gridSize * 2, y);
context.stroke();
}
context.restore();
};
const frameClip = (
frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY);
context.beginPath();
if (context.roundRect) {
context.roundRect(
0,
0,
frame.width,
frame.height,
FRAME_STYLE.radius / appState.zoom.value,
);
} else {
context.rect(0, 0, frame.width, frame.height);
}
context.clip();
context.translate(
-(frame.x + appState.scrollX),
-(frame.y + appState.scrollY),
);
};
let linkCanvasCache: any;
const renderLinkIcon = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: StaticCanvasAppState,
elementsMap: ElementsMap,
) => {
if (element.link && !appState.selectedElementIds[element.id]) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
const [x, y, width, height] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const centerX = x + width / 2;
const centerY = y + height / 2;
context.save();
context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
context.rotate(element.angle);
if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
linkCanvasCache = document.createElement("canvas");
linkCanvasCache.zoom = appState.zoom.value;
linkCanvasCache.width =
width * window.devicePixelRatio * appState.zoom.value;
linkCanvasCache.height =
height * window.devicePixelRatio * appState.zoom.value;
const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
linkCanvasCacheContext.scale(
window.devicePixelRatio * appState.zoom.value,
window.devicePixelRatio * appState.zoom.value,
);
linkCanvasCacheContext.fillStyle = "#fff";
linkCanvasCacheContext.fillRect(0, 0, width, height);
linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
linkCanvasCacheContext.restore();
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
} else {
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
}
context.restore();
}
};
const _renderStaticScene = ({
canvas,
rc,
elementsMap,
allElementsMap,
visibleElements,
scale,
appState,
renderConfig,
}: StaticSceneRenderConfig) => {
if (canvas === null) {
return;
}
const { renderGrid = true, isExporting } = renderConfig;
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas,
scale,
);
const context = bootstrapCanvas({
canvas,
scale,
normalizedWidth,
normalizedHeight,
theme: appState.theme,
isExporting,
viewBackgroundColor: appState.viewBackgroundColor,
});
// Apply zoom
context.scale(appState.zoom.value, appState.zoom.value);
// Grid
if (renderGrid && appState.gridSize) {
strokeGrid(
context,
appState.gridSize,
appState.scrollX,
appState.scrollY,
appState.zoom,
normalizedWidth / appState.zoom.value,
normalizedHeight / appState.zoom.value,
);
}
const groupsToBeAddedToFrame = new Set<string>();
visibleElements.forEach((element) => {
if (
element.groupIds.length > 0 &&
appState.frameToHighlight &&
appState.selectedElementIds[element.id] &&
(elementOverlapsWithFrame(
element,
appState.frameToHighlight,
elementsMap,
) ||
element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId)))
) {
element.groupIds.forEach((groupId) =>
groupsToBeAddedToFrame.add(groupId),
);
}
});
// Paint visible elements
visibleElements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
try {
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, elementsMap, appState);
// TODO do we need to check isElementInFrame here?
if (frame && isElementInFrame(element, elementsMap, appState)) {
frameClip(frame, context, renderConfig, appState);
}
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
context.restore();
} else {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
if (!isExporting) {
renderLinkIcon(element, context, appState, elementsMap);
}
} catch (error: any) {
console.error(error);
}
});
// render embeddables on top
visibleElements
.filter((el) => isIframeLikeElement(el))
.forEach((element) => {
try {
const render = () => {
renderElement(
element,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
if (
isIframeLikeElement(element) &&
(isExporting ||
(isEmbeddableElement(element) &&
renderConfig.embedsValidationStatus.get(element.id) !==
true)) &&
element.width &&
element.height
) {
const label = createPlaceholderEmbeddableLabel(element);
renderElement(
label,
elementsMap,
allElementsMap,
rc,
context,
renderConfig,
appState,
);
}
if (!isExporting) {
renderLinkIcon(element, context, appState, elementsMap);
}
};
// - when exporting the whole canvas, we DO NOT apply clipping
// - when we are exporting a particular frame, apply clipping
// if the containing frame is not selected, apply clipping
const frameId = element.frameId || appState.frameToHighlight?.id;
if (
frameId &&
appState.frameRendering.enabled &&
appState.frameRendering.clip
) {
context.save();
const frame = getTargetFrame(element, elementsMap, appState);
if (frame && isElementInFrame(element, elementsMap, appState)) {
frameClip(frame, context, renderConfig, appState);
}
render();
context.restore();
} else {
render();
}
} catch (error: any) {
console.error(error);
}
});
};
/** throttled to animation framerate */
export const renderStaticSceneThrottled = throttleRAF(
(config: StaticSceneRenderConfig) => {
_renderStaticScene(config);
},
{ trailing: true },
);
/**
* Static scene is the non-ui canvas where we render elements.
*/
export const renderStaticScene = (
renderConfig: StaticSceneRenderConfig,
throttle?: boolean,
) => {
if (throttle) {
renderStaticSceneThrottled(renderConfig);
return;
}
_renderStaticScene(renderConfig);
};
@@ -0,0 +1,659 @@
import { Drawable } from "roughjs/bin/core";
import { RoughSVG } from "roughjs/bin/svg";
import {
FRAME_STYLE,
MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES,
SVG_NS,
} from "../constants";
import { normalizeLink, toValidURL } from "../data/url";
import { getElementAbsoluteCoords } from "../element";
import {
createPlaceholderEmbeddableLabel,
getEmbedLink,
} from "../element/embeddable";
import { LinearElementEditor } from "../element/linearElementEditor";
import {
getBoundTextElement,
getContainerElement,
getLineHeightInPx,
getVerticalOffset,
} from "../element/textElement";
import {
isArrowElement,
isIframeLikeElement,
isInitializedImageElement,
isTextElement,
} from "../element/typeChecks";
import {
ExcalidrawElement,
ExcalidrawTextElementWithContainer,
NonDeletedExcalidrawElement,
} from "../element/types";
import { getContainingFrame } from "../frame";
import { getCornerRadius, isPathALoop } from "../math";
import { ShapeCache } from "../scene/ShapeCache";
import { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
import { AppState, BinaryFiles } from "../types";
import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
const roughSVGDrawWithPrecision = (
rsvg: RoughSVG,
drawable: Drawable,
precision?: number,
) => {
if (typeof precision === "undefined") {
return rsvg.draw(drawable);
}
const pshape: Drawable = {
sets: drawable.sets,
shape: drawable.shape,
options: { ...drawable.options, fixedDecimalPlaceDigits: precision },
};
return rsvg.draw(pshape);
};
const maybeWrapNodesInFrameClipPath = (
element: NonDeletedExcalidrawElement,
root: SVGElement,
nodes: SVGElement[],
frameRendering: AppState["frameRendering"],
elementsMap: RenderableElementsMap,
) => {
if (!frameRendering.enabled || !frameRendering.clip) {
return null;
}
const frame = getContainingFrame(element, elementsMap);
if (frame) {
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
nodes.forEach((node) => g.appendChild(node));
return g;
}
return null;
};
const renderElementToSvg = (
element: NonDeletedExcalidrawElement,
elementsMap: RenderableElementsMap,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
offsetX: number,
offsetY: number,
renderConfig: SVGRenderConfig,
) => {
const offset = { x: offsetX, y: offsetY };
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
let cx = (x2 - x1) / 2 - (element.x - x1);
let cy = (y2 - y1) / 2 - (element.y - y1);
if (isTextElement(element)) {
const container = getContainerElement(element, elementsMap);
if (isArrowElement(container)) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
container,
element as ExcalidrawTextElementWithContainer,
elementsMap,
);
cx = (x2 - x1) / 2 - (boundTextCoords.x - x1);
cy = (y2 - y1) / 2 - (boundTextCoords.y - y1);
offsetX = offsetX + boundTextCoords.x - element.x;
offsetY = offsetY + boundTextCoords.y - element.y;
}
}
const degree = (180 * element.angle) / Math.PI;
// element to append node to, most of the time svgRoot
let root = svgRoot;
// if the element has a link, create an anchor tag and make that the new root
if (element.link) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link));
root.appendChild(anchorTag);
root = anchorTag;
}
const addToRoot = (node: SVGElement, element: ExcalidrawElement) => {
if (isTestEnv()) {
node.setAttribute("data-id", element.id);
}
root.appendChild(node);
};
const opacity =
((getContainingFrame(element, elementsMap)?.opacity ?? 100) *
element.opacity) /
10000;
switch (element.type) {
case "selection": {
// Since this is used only during editing experience, which is canvas based,
// this should not happen
throw new Error("Selection rendering is not supported for SVG");
}
case "rectangle":
case "diamond":
case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
break;
}
case "iframe":
case "embeddable": {
// render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, renderConfig);
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
const opacity = element.opacity / 100;
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute("stroke-linecap", "round");
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
addToRoot(node, element);
const label: ExcalidrawElement =
createPlaceholderEmbeddableLabel(element);
renderElementToSvg(
label,
elementsMap,
rsvg,
root,
files,
label.x + offset.x - element.x,
label.y + offset.y - element.y,
renderConfig,
);
// render embeddable element + iframe
const embeddableNode = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
embeddableNode.setAttribute("stroke-linecap", "round");
embeddableNode.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
while (embeddableNode.firstChild) {
embeddableNode.removeChild(embeddableNode.firstChild);
}
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
const embedLink = getEmbedLink(toValidURL(element.link || ""));
// if rendering embeddables explicitly disabled or
// embedding documents via srcdoc (which doesn't seem to work for SVGs)
// replace with a link instead
if (
renderConfig.renderEmbeddables === false ||
embedLink?.type === "document"
) {
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
anchorTag.setAttribute("href", normalizeLink(element.link || ""));
anchorTag.setAttribute("target", "_blank");
anchorTag.setAttribute("rel", "noopener noreferrer");
anchorTag.style.borderRadius = `${radius}px`;
embeddableNode.appendChild(anchorTag);
} else {
const foreignObject = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"foreignObject",
);
foreignObject.style.width = `${element.width}px`;
foreignObject.style.height = `${element.height}px`;
foreignObject.style.border = "none";
const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div");
div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
div.style.width = "100%";
div.style.height = "100%";
const iframe = div.ownerDocument!.createElement("iframe");
iframe.src = embedLink?.link ?? "";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.style.borderRadius = `${radius}px`;
iframe.style.top = "0";
iframe.style.left = "0";
iframe.allowFullscreen = true;
div.appendChild(iframe);
foreignObject.appendChild(div);
embeddableNode.appendChild(foreignObject);
}
addToRoot(embeddableNode, element);
break;
}
case "line":
case "arrow": {
const boundText = getBoundTextElement(element, elementsMap);
const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask");
if (boundText) {
maskPath.setAttribute("id", `mask-${element.id}`);
const maskRectVisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
offsetX = offsetX || 0;
offsetY = offsetY || 0;
maskRectVisible.setAttribute("x", "0");
maskRectVisible.setAttribute("y", "0");
maskRectVisible.setAttribute("fill", "#fff");
maskRectVisible.setAttribute(
"width",
`${element.width + 100 + offsetX}`,
);
maskRectVisible.setAttribute(
"height",
`${element.height + 100 + offsetY}`,
);
maskPath.appendChild(maskRectVisible);
const maskRectInvisible = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const boundTextCoords = LinearElementEditor.getBoundTextElementPosition(
element,
boundText,
elementsMap,
);
const maskX = offsetX + boundTextCoords.x - element.x;
const maskY = offsetY + boundTextCoords.y - element.y;
maskRectInvisible.setAttribute("x", maskX.toString());
maskRectInvisible.setAttribute("y", maskY.toString());
maskRectInvisible.setAttribute("fill", "#000");
maskRectInvisible.setAttribute("width", `${boundText.width}`);
maskRectInvisible.setAttribute("height", `${boundText.height}`);
maskRectInvisible.setAttribute("opacity", "1");
maskPath.appendChild(maskRectInvisible);
}
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (boundText) {
group.setAttribute("mask", `url(#mask-${element.id})`);
}
group.setAttribute("stroke-linecap", "round");
const shapes = ShapeCache.generateElementShape(element, renderConfig);
shapes.forEach((shape) => {
const node = roughSVGDrawWithPrecision(
rsvg,
shape,
MAX_DECIMALS_FOR_SVG_EXPORT,
);
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (
element.type === "line" &&
isPathALoop(element.points) &&
element.backgroundColor !== "transparent"
) {
node.setAttribute("fill-rule", "evenodd");
}
group.appendChild(node);
});
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[group, maskPath],
renderConfig.frameRendering,
elementsMap,
);
if (g) {
addToRoot(g, element);
root.appendChild(g);
} else {
addToRoot(group, element);
root.append(maskPath);
}
break;
}
case "freedraw": {
const backgroundFillShape = ShapeCache.generateElementShape(
element,
renderConfig,
);
const node = backgroundFillShape
? roughSVGDrawWithPrecision(
rsvg,
backgroundFillShape,
MAX_DECIMALS_FOR_SVG_EXPORT,
)
: svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
node.setAttribute("stroke", "none");
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor);
path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path);
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
break;
}
case "image": {
const width = Math.round(element.width);
const height = Math.round(element.height);
const fileData =
isInitializedImageElement(element) && files[element.fileId];
if (fileData) {
const symbolId = `image-${fileData.id}`;
let symbol = svgRoot.querySelector(`#${symbolId}`);
if (!symbol) {
symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
symbol.id = symbolId;
const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
image.setAttribute("width", "100%");
image.setAttribute("height", "100%");
image.setAttribute("href", fileData.dataURL);
symbol.appendChild(image);
root.prepend(symbol);
}
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
use.setAttribute("href", `#${symbolId}`);
// in dark theme, revert the image color filter
if (
renderConfig.exportWithDarkMode &&
fileData.mimeType !== MIME_TYPES.svg
) {
use.setAttribute("filter", IMAGE_INVERT_FILTER);
}
use.setAttribute("width", `${width}`);
use.setAttribute("height", `${height}`);
use.setAttribute("opacity", `${opacity}`);
// We first apply `scale` transforms (horizontal/vertical mirroring)
// on the <use> element, then apply translation and rotation
// on the <g> element which wraps the <use>.
// Doing this separately is a quick hack to to work around compositing
// the transformations correctly (the transform-origin was not being
// applied correctly).
if (element.scale[0] !== 1 || element.scale[1] !== 1) {
const translateX = element.scale[0] !== 1 ? -width : 0;
const translateY = element.scale[1] !== 1 ? -height : 0;
use.setAttribute(
"transform",
`scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`,
);
}
const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
g.appendChild(use);
g.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
if (element.roundness) {
const clipPath = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"clipPath",
);
clipPath.id = `image-clipPath-${element.id}`;
const clipRect = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"rect",
);
const radius = getCornerRadius(
Math.min(element.width, element.height),
element,
);
clipRect.setAttribute("width", `${element.width}`);
clipRect.setAttribute("height", `${element.height}`);
clipRect.setAttribute("rx", `${radius}`);
clipRect.setAttribute("ry", `${radius}`);
clipPath.appendChild(clipRect);
addToRoot(clipPath, element);
g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`);
}
const clipG = maybeWrapNodesInFrameClipPath(
element,
root,
[g],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(clipG || g, element);
}
break;
}
// frames are not rendered and only acts as a container
case "frame":
case "magicframe": {
if (
renderConfig.frameRendering.enabled &&
renderConfig.frameRendering.outline
) {
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
rect.setAttribute("width", `${element.width}px`);
rect.setAttribute("height", `${element.height}px`);
// Rounded corners
rect.setAttribute("rx", FRAME_STYLE.radius.toString());
rect.setAttribute("ry", FRAME_STYLE.radius.toString());
rect.setAttribute("fill", "none");
rect.setAttribute("stroke", FRAME_STYLE.strokeColor);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
addToRoot(rect, element);
}
break;
}
default: {
if (isTextElement(element)) {
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
if (opacity !== 1) {
node.setAttribute("stroke-opacity", `${opacity}`);
node.setAttribute("fill-opacity", `${opacity}`);
}
node.setAttribute(
"transform",
`translate(${offsetX || 0} ${
offsetY || 0
}) rotate(${degree} ${cx} ${cy})`,
);
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
const lineHeightPx = getLineHeightInPx(
element.fontSize,
element.lineHeight,
);
const horizontalOffset =
element.textAlign === "center"
? element.width / 2
: element.textAlign === "right"
? element.width
: 0;
const verticalOffset = getVerticalOffset(
element.fontFamily,
element.fontSize,
lineHeightPx,
);
const direction = isRTL(element.text) ? "rtl" : "ltr";
const textAnchor =
element.textAlign === "center"
? "middle"
: element.textAlign === "right" || direction === "rtl"
? "end"
: "start";
for (let i = 0; i < lines.length; i++) {
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
text.textContent = lines[i];
text.setAttribute("x", `${horizontalOffset}`);
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor);
text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction);
text.setAttribute("dominant-baseline", "alphabetic");
node.appendChild(text);
}
const g = maybeWrapNodesInFrameClipPath(
element,
root,
[node],
renderConfig.frameRendering,
elementsMap,
);
addToRoot(g || node, element);
} else {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);
}
}
}
};
export const renderSceneToSvg = (
elements: readonly NonDeletedExcalidrawElement[],
elementsMap: RenderableElementsMap,
rsvg: RoughSVG,
svgRoot: SVGElement,
files: BinaryFiles,
renderConfig: SVGRenderConfig,
) => {
if (!svgRoot) {
return;
}
// render elements
elements
.filter((el) => !isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
element,
elementsMap,
rsvg,
svgRoot,
files,
element.x + renderConfig.offsetX,
element.y + renderConfig.offsetY,
renderConfig,
);
} catch (error: any) {
console.error(error);
}
}
});
// render embeddables on top
elements
.filter((el) => isIframeLikeElement(el))
.forEach((element) => {
if (!element.isDeleted) {
try {
renderElementToSvg(
element,
elementsMap,
rsvg,
svgRoot,
files,
element.x + renderConfig.offsetX,
element.y + renderConfig.offsetY,
renderConfig,
);
} catch (error: any) {
console.error(error);
}
}
});
};
+5 -2
View File
@@ -4,7 +4,9 @@ import {
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
} from "../element/types";
import { cancelRender } from "../renderer/renderScene";
import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene";
import { renderStaticSceneThrottled } from "../renderer/staticScene";
import { AppState } from "../types";
import { memoize, toBrandedType } from "../utils";
import Scene from "./Scene";
@@ -147,7 +149,8 @@ export class Renderer {
// NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be
// safe to break TS contract here (for upstream cases)
public destroy() {
cancelRender();
renderInteractiveSceneThrottled.cancel();
renderStaticSceneThrottled.cancel();
this.getRenderableElements.clear();
}
}
+6 -19
View File
@@ -80,29 +80,16 @@ class Scene {
private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
private static sceneMapById = new Map<string, Scene>();
static mapElementToScene(
elementKey: ElementKey,
scene: Scene,
/**
* needed because of frame exporting hack.
* elementId:Scene mapping will be removed completely, soon.
*/
mapElementIds = true,
) {
static mapElementToScene(elementKey: ElementKey, scene: Scene) {
if (isIdKey(elementKey)) {
if (!mapElementIds) {
return;
}
// for cases where we don't have access to the element object
// (e.g. restore serialized appState with id references)
this.sceneMapById.set(elementKey, scene);
} else {
this.sceneMapByElement.set(elementKey, scene);
if (!mapElementIds) {
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
// if mapping element objects, also cache the id string when later
// looking up by id alone
this.sceneMapById.set(elementKey.id, scene);
}
}
@@ -256,7 +243,7 @@ class Scene {
return didChange;
}
replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) {
replaceAllElements(nextElements: ElementsMapOrArray) {
this.elements =
// ts doesn't like `Array.isArray` of `instanceof Map`
nextElements instanceof Array
@@ -269,7 +256,7 @@ class Scene {
nextFrameLikes.push(element);
}
this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this, mapElementIds);
Scene.mapElementToScene(element, this);
});
const nonDeletedElements = getNonDeletedElements(this.elements);
this.nonDeletedElements = nonDeletedElements.elements;
+3 -42
View File
@@ -11,14 +11,8 @@ import {
getCommonBounds,
getElementAbsoluteCoords,
} from "../element/bounds";
import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene";
import {
arrayToMap,
cloneJSON,
distance,
getFontString,
toBrandedType,
} from "../utils";
import { renderSceneToSvg } from "../renderer/staticSvgScene";
import { arrayToMap, distance, getFontString, toBrandedType } from "../utils";
import { AppState, BinaryFiles } from "../types";
import {
DEFAULT_EXPORT_PADDING,
@@ -42,35 +36,12 @@ import {
import { newTextElement } from "../element";
import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement";
import Scene from "./Scene";
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
import { RenderableElementsMap } from "./types";
import { renderStaticScene } from "../renderer/staticScene";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
// getContainerElement and getBoundTextElement and potentially other helpers
// depend on `Scene` which will not be available when these pure utils are
// called outside initialized Excalidraw editor instance or even if called
// from inside Excalidraw if the elements were never cached by Scene (e.g.
// for library elements).
//
// As such, before passing the elements down, we need to initialize a custom
// Scene instance and assign them to it.
//
// FIXME This is a super hacky workaround and we'll need to rewrite this soon.
const __createSceneForElementsHack__ = (
elements: readonly ExcalidrawElement[],
) => {
const scene = new Scene();
// we can't duplicate elements to regenerate ids because we need the
// orig ids when embedding. So we do another hack of not mapping element
// ids to Scene instances so that we don't override the editor elements
// mapping.
// We still need to clone the objects themselves to regen references.
scene.replaceAllElements(cloneJSON(elements), false);
return scene;
};
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) {
return element;
@@ -213,9 +184,6 @@ export const exportToCanvas = async (
return { canvas, scale: appState.exportScale };
},
) => {
const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
const frameRendering = getFrameRenderingConfig(
exportingFrame ?? null,
appState.frameRendering ?? null,
@@ -281,8 +249,6 @@ export const exportToCanvas = async (
},
});
tempScene.destroy();
return canvas;
};
@@ -306,9 +272,6 @@ export const exportToSvg = async (
exportingFrame?: ExcalidrawFrameLikeElement | null;
},
): Promise<SVGSVGElement> => {
const tempScene = __createSceneForElementsHack__(elements);
elements = tempScene.getNonDeletedElements();
const frameRendering = getFrameRenderingConfig(
opts?.exportingFrame ?? null,
appState.frameRendering ?? null,
@@ -470,8 +433,6 @@ export const exportToSvg = async (
},
);
tempScene.destroy();
return svgRoot;
};
+2 -2
View File
@@ -57,7 +57,7 @@ export const getElementsWithinSelection = (
elementsMap,
);
const containingFrame = getContainingFrame(element);
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
const [fx1, fy1, fx2, fy2] = getElementBounds(
containingFrame,
@@ -86,7 +86,7 @@ export const getElementsWithinSelection = (
: elementsInSelection;
elementsInSelection = elementsInSelection.filter((element) => {
const containingFrame = getContainingFrame(element);
const containingFrame = getContainingFrame(element, elementsMap);
if (containingFrame) {
return elementOverlapsWithFrame(element, containingFrame, elementsMap);
+8 -5
View File
@@ -1,6 +1,7 @@
import type { RoughCanvas } from "roughjs/bin/canvas";
import { Drawable } from "roughjs/bin/core";
import {
ExcalidrawElement,
ExcalidrawTextElement,
NonDeletedElementsMap,
NonDeletedExcalidrawElement,
@@ -13,6 +14,8 @@ import {
ElementsPendingErasure,
InteractiveCanvasAppState,
StaticCanvasAppState,
SocketId,
UserIdleState,
} from "../types";
import { MakeBrand } from "../utility-types";
@@ -46,11 +49,11 @@ export type SVGRenderConfig = {
export type InteractiveCanvasRenderConfig = {
// collab-related state
// ---------------------------------------------------------------------------
remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
remotePointerUserStates: { [id: string]: string };
remotePointerUsernames: { [id: string]: string };
remotePointerButton?: { [id: string]: string | undefined };
remoteSelectedElementIds: Map<ExcalidrawElement["id"], SocketId[]>;
remotePointerViewportCoords: Map<SocketId, { x: number; y: number }>;
remotePointerUserStates: Map<SocketId, UserIdleState>;
remotePointerUsernames: Map<SocketId, string>;
remotePointerButton: Map<SocketId, string | undefined>;
selectionColor?: string;
// extra options passed to the renderer
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -1,12 +1,12 @@
import ReactDOM from "react-dom";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import { reseed } from "../random";
import { render, queryByTestId } from "../tests/test-utils";
import { Excalidraw } from "../index";
import { vi } from "vitest";
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
describe("Test <App/>", () => {
beforeEach(async () => {
@@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>
@@ -12,7 +12,7 @@ import {
togglePopover,
} from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { KEYS } from "../keys";
@@ -39,7 +39,7 @@ const mouse = new Pointer("mouse");
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
renderStaticScene.mockClear();
@@ -296,7 +296,6 @@ exports[`restoreElements > should restore text element correctly passing value f
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [],
"containerId": null,
"customData": undefined,
@@ -338,7 +337,6 @@ exports[`restoreElements > should restore text element correctly with unknown fo
{
"angle": 0,
"backgroundColor": "transparent",
"baseline": 0,
"boundElements": [],
"containerId": null,
"customData": undefined,
@@ -1,6 +1,7 @@
import ReactDOM from "react-dom";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveScene from "../renderer/interactiveScene";
import { KEYS } from "../keys";
import {
render,
@@ -15,8 +16,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveScene,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
@@ -8,7 +8,9 @@ import {
import { Excalidraw } from "../index";
import { centerPoint } from "../math";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { Keyboard, Pointer, UI } from "./helpers/ui";
import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
import { API } from "../tests/helpers/api";
@@ -26,8 +28,11 @@ import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
import { vi } from "vitest";
import { arrayToMap } from "../utils";
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
const { h } = window;
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
+7 -3
View File
@@ -1,7 +1,8 @@
import ReactDOM from "react-dom";
import { render, fireEvent } from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { reseed } from "../random";
import { bindOrUnbindLinearElement } from "../element/binding";
import {
@@ -16,8 +17,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
@@ -6,7 +6,8 @@ import {
restoreOriginalGetBoundingClientRect,
} from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { KEYS } from "../keys";
import { ExcalidrawLinearElement } from "../element/types";
import { reseed } from "../random";
@@ -15,8 +16,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
@@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types";
import { CODES, KEYS } from "../keys";
import { Excalidraw } from "../index";
import { reseed } from "../random";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import { setDateTimeForTests } from "../utils";
import { API } from "./helpers/api";
import { Keyboard, Pointer, UI } from "./helpers/ui";
@@ -19,7 +19,7 @@ import { vi } from "vitest";
const { h } = window;
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1);
+7 -3
View File
@@ -7,7 +7,8 @@ import {
assertSelectedElements,
} from "./test-utils";
import { Excalidraw } from "../index";
import * as Renderer from "../renderer/renderScene";
import * as StaticScene from "../renderer/staticScene";
import * as InteractiveCanvas from "../renderer/interactiveScene";
import { KEYS } from "../keys";
import { reseed } from "../random";
import { API } from "./helpers/api";
@@ -18,8 +19,11 @@ import { vi } from "vitest";
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene");
const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene");
const renderInteractiveScene = vi.spyOn(
InteractiveCanvas,
"renderInteractiveScene",
);
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
beforeEach(() => {
localStorage.clear();
+25 -19
View File
@@ -38,7 +38,7 @@ import type { FileSystemHandle } from "./data/filesystem";
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
import { ContextMenuItems } from "./components/ContextMenu";
import { SnapLine } from "./snapping";
import { Merge, ValueOf } from "./utility-types";
import { Merge, MaybePromise, ValueOf } from "./utility-types";
export type Point = Readonly<RoughPoint>;
@@ -61,12 +61,28 @@ export type Collaborator = Readonly<{
id?: string;
socketId?: SocketId;
isCurrentUser?: boolean;
isInCall?: boolean;
isSpeaking?: boolean;
isMuted?: boolean;
}>;
export type CollaboratorPointer = {
x: number;
y: number;
tool: "pointer" | "laser";
/**
* Whether to render cursor + username. Useful when you only want to render
* laser trail.
*
* @default true
*/
renderCursor?: boolean;
/**
* Explicit laser color.
*
* @default string collaborator's cursor color
*/
laserColor?: string;
};
export type DataURL = string & { _brand: "DataURL" };
@@ -319,9 +335,9 @@ export interface AppState {
y: number;
} | null;
objectsSnapModeEnabled: boolean;
/** the user's clientId & username who is being followed on the canvas */
/** the user's socket id & username who is being followed on the canvas */
userToFollow: UserToFollow | null;
/** the clientIds of the users following the current user */
/** the socket ids of the users following the current user */
followedBy: Set<SocketId>;
}
@@ -380,21 +396,14 @@ export type LibraryItems_anyVersion = LibraryItems | LibraryItems_v1;
export type LibraryItemsSource =
| ((
currentLibraryItems: LibraryItems,
) =>
| Blob
| LibraryItems_anyVersion
| Promise<LibraryItems_anyVersion | Blob>)
| Blob
| LibraryItems_anyVersion
| Promise<LibraryItems_anyVersion | Blob>;
) => MaybePromise<LibraryItems_anyVersion | Blob>)
| MaybePromise<LibraryItems_anyVersion | Blob>;
// -----------------------------------------------------------------------------
export type ExcalidrawInitialDataState = Merge<
ImportedDataState,
{
libraryItems?:
| Required<ImportedDataState>["libraryItems"]
| Promise<Required<ImportedDataState>["libraryItems"]>;
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
}
>;
@@ -409,10 +418,7 @@ export interface ExcalidrawProps {
appState: AppState,
files: BinaryFiles,
) => void;
initialData?:
| ExcalidrawInitialDataState
| null
| Promise<ExcalidrawInitialDataState | null>;
initialData?: MaybePromise<ExcalidrawInitialDataState | null>;
excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void;
isCollaborating?: boolean;
onPointerUpdate?: (payload: {
@@ -643,7 +649,7 @@ export type PointerDownState = Readonly<{
export type UnsubscribeCallback = () => void;
export type ExcalidrawImperativeAPI = {
export interface ExcalidrawImperativeAPI {
updateScene: InstanceType<typeof App>["updateScene"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
resetScene: InstanceType<typeof App>["resetScene"];
@@ -700,7 +706,7 @@ export type ExcalidrawImperativeAPI = {
onUserFollow: (
callback: (payload: OnUserFollowedPayload) => void,
) => UnsubscribeCallback;
};
}
export type Device = Readonly<{
viewport: {
+3
View File
@@ -62,3 +62,6 @@ export type MakeBrand<T extends string> = {
/** @private using ~ to sort last in intellisense */
[K in `~brand~${T}`]: T;
};
/** Maybe just promise or already fulfilled one! */
export type MaybePromise<T> = T | Promise<T>;
+22 -2
View File
@@ -14,7 +14,7 @@ import {
UnsubscribeCallback,
Zoom,
} from "./types";
import { ResolutionType } from "./utility-types";
import { MaybePromise, ResolutionType } from "./utility-types";
let mockDateTime: string | null = null;
@@ -538,7 +538,9 @@ export const isTransparent = (color: string) => {
};
export type ResolvablePromise<T> = Promise<T> & {
resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
resolve: [T] extends [undefined]
? (value?: MaybePromise<Awaited<T>>) => void
: (value: MaybePromise<Awaited<T>>) => void;
reject: (error: Error) => void;
};
export const resolvablePromise = <T>() => {
@@ -789,6 +791,14 @@ export const isShallowEqual = <
const aKeys = Object.keys(objA);
const bKeys = Object.keys(objB);
if (aKeys.length !== bKeys.length) {
if (debug) {
console.warn(
`%cisShallowEqual: objects don't have same properties ->`,
"color: #8B4000",
objA,
objB,
);
}
return false;
}
@@ -1090,3 +1100,13 @@ export const toBrandedType = <BrandedType, CurrentType = BrandedType>(
};
// -----------------------------------------------------------------------------
// Promise.try, adapted from https://github.com/sindresorhus/p-try
export const promiseTry = async <TValue, TArgs extends unknown[]>(
fn: (...args: TArgs) => PromiseLike<TValue> | TValue,
...args: TArgs
): Promise<TValue> => {
return new Promise((resolve) => {
resolve(fn(...args));
});
};
-18
View File
@@ -205,21 +205,3 @@ export const exportToClipboard = async (
throw new Error("Invalid export type");
}
};
export * from "./bbox";
export {
elementsOverlappingBBox,
isElementInsideBBox,
elementPartiallyOverlapsWithOrContainsBBox,
} from "./withinBounds";
export {
serializeAsJSON,
serializeLibraryAsJSON,
} from "../excalidraw/data/json";
export {
loadFromBlob,
loadSceneOrLibraryFromBlob,
loadLibraryFromBlob,
} from "../excalidraw/data/blob";
export { getFreeDrawSvgPath } from "../excalidraw/renderer/renderElement";
export { mergeLibraryItems } from "../excalidraw/data/library";
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
import "../excalidraw/global";
import "../excalidraw/css";
-1
View File
@@ -1 +0,0 @@
export * from "./export";
+3
View File
@@ -0,0 +1,3 @@
export * from "./export";
export * from "./withinBounds";
export * from "./bbox";
+25 -1
View File
@@ -1,7 +1,16 @@
{
"name": "@excalidraw/utils",
"version": "0.1.2",
"main": "dist/excalidraw-utils.min.js",
"main": "./dist/prod/index.js",
"type": "module",
"module": "./dist/prod/index.js",
"exports": {
".": {
"development": "./dist/dev/index.js",
"default": "./dist/prod/index.js"
}
},
"types": "./dist/utils/index.d.ts",
"files": [
"dist/*"
],
@@ -33,6 +42,18 @@
"last 1 safari version"
]
},
"dependencies": {
"@braintree/sanitize-url": "6.0.2",
"@excalidraw/laser-pointer": "1.3.1",
"browser-fs-access": "0.29.1",
"open-color": "1.9.1",
"pako": "1.0.11",
"perfect-freehand": "1.2.0",
"png-chunk-text": "1.0.0",
"png-chunks-encode": "1.0.0",
"png-chunks-extract": "1.0.0",
"roughjs": "4.6.4"
},
"devDependencies": {
"@babel/core": "7.18.9",
"@babel/plugin-transform-arrow-functions": "7.18.6",
@@ -48,6 +69,7 @@
"file-loader": "6.2.0",
"sass-loader": "13.0.2",
"ts-loader": "9.3.1",
"typescript": "4.9.4",
"webpack": "5.76.0",
"webpack-bundle-analyzer": "4.5.0",
"webpack-cli": "4.10.0"
@@ -55,7 +77,9 @@
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types",
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
"pack": "yarn build:umd && yarn pack"
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"strict": true,
"outDir": "dist",
"skipLibCheck": true,
"declaration": true,
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"jsx": "react-jsx"
},
"exclude": ["**/*.test.*", "**/tests/*", "types", "dist"]
}
+123
View File
@@ -0,0 +1,123 @@
const { build } = require("esbuild");
const { sassPlugin } = require("esbuild-sass-plugin");
const fs = require("fs");
const browserConfig = {
entryPoints: ["index.ts"],
bundle: true,
format: "esm",
plugins: [sassPlugin()],
};
// Will be used later for treeshaking
// function getFiles(dir, files = []) {
// const fileList = fs.readdirSync(dir);
// for (const file of fileList) {
// const name = `${dir}/${file}`;
// if (
// name.includes("node_modules") ||
// name.includes("config") ||
// name.includes("package.json") ||
// name.includes("main.js") ||
// name.includes("index-node.ts") ||
// name.endsWith(".d.ts") ||
// name.endsWith(".md")
// ) {
// continue;
// }
// if (fs.statSync(name).isDirectory()) {
// getFiles(name, files);
// } else if (
// name.match(/\.(sa|sc|c)ss$/) ||
// name.match(/\.(woff|woff2|eot|ttf|otf)$/) ||
// name.match(/locales\/[^/]+\.json$/)
// ) {
// continue;
// } else {
// files.push(name);
// }
// }
// return files;
// }
const createESMBrowserBuild = async () => {
// Development unminified build with source maps
const browserDev = await build({
...browserConfig,
outdir: "dist/browser/dev",
sourcemap: true,
metafile: true,
define: {
"import.meta.env": JSON.stringify({ DEV: true }),
},
});
fs.writeFileSync(
"meta-browser-dev.json",
JSON.stringify(browserDev.metafile),
);
// production minified build without sourcemaps
const browserProd = await build({
...browserConfig,
outdir: "dist/browser/prod",
minify: true,
metafile: true,
define: {
"import.meta.env": JSON.stringify({ PROD: true }),
},
});
fs.writeFileSync(
"meta-browser-prod.json",
JSON.stringify(browserProd.metafile),
);
};
const rawConfig = {
entryPoints: ["index.ts"],
bundle: true,
format: "esm",
packages: "external",
plugins: [sassPlugin()],
};
// const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`;
// const filesinExcalidrawPackage = getFiles(`${BASE_PATH}/packages/utils`);
// const filesToTransform = filesinExcalidrawPackage.filter((file) => {
// return !(
// file.includes("/__tests__/") ||
// file.includes(".test.") ||
// file.includes("/tests/") ||
// file.includes("example")
// );
// });
const createESMRawBuild = async () => {
// Development unminified build with source maps
const rawDev = await build({
...rawConfig,
outdir: "dist/dev",
sourcemap: true,
metafile: true,
define: {
"import.meta.env": JSON.stringify({ DEV: true }),
},
});
fs.writeFileSync("meta-raw-dev.json", JSON.stringify(rawDev.metafile));
// production minified build without sourcemaps
const rawProd = await build({
...rawConfig,
outdir: "dist/prod",
minify: true,
metafile: true,
define: {
"import.meta.env": JSON.stringify({ PROD: true }),
},
});
fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile));
};
createESMRawBuild();
createESMBrowserBuild();