feat: Make library local to given excalidraw instance and allow consumer to control it (#3451)

* feat: dnt share library & attach to the excalidraw instance

* fix

* Add addToLibrary, resetLibrary and libraryItems in initialData

* remove comment

* handle errors & improve types

* remove resetLibrary and addToLibrary and add onLibraryChange prop

* set library cache to empty arrary on reset

* Add i18n for remove/add library

* Update src/locales/en.json

Co-authored-by: David Luzar <luzar.david@gmail.com>

* update docs

* Assign unique ID to
 each excalidraw component and remove csrfToken from library as its not needed

* tweaks

Co-authored-by: David Luzar <luzar.david@gmail.com>

* update

* tweak

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2021-04-21 23:38:24 +05:30 committed by GitHub
parent 46624cc953
commit 37d513ad59
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 189 additions and 77 deletions

View File

@ -2,18 +2,20 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement"; import { deepCopyElement } from "../element/newElement";
import { Library } from "../data/library";
export const actionAddToLibrary = register({ export const actionAddToLibrary = register({
name: "addToLibrary", name: "addToLibrary",
perform: (elements, appState) => { perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements( const selectedElements = getSelectedElements(
getNonDeletedElements(elements), getNonDeletedElements(elements),
appState, appState,
); );
Library.loadLibrary().then((items) => { app.library.loadLibrary().then((items) => {
Library.saveLibrary([...items, selectedElements.map(deepCopyElement)]); app.library.saveLibrary([
...items,
selectedElements.map(deepCopyElement),
]);
}); });
return false; return false;
}, },

View File

@ -9,6 +9,7 @@ import {
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types"; import { AppProps, AppState } from "../types";
import { MODES } from "../constants"; import { MODES } from "../constants";
import Library from "../data/library";
// This is the <App> component, but for now we don't care about anything but its // This is the <App> component, but for now we don't care about anything but its
// `canvas` state. // `canvas` state.
@ -16,6 +17,7 @@ type App = {
canvas: HTMLCanvasElement | null; canvas: HTMLCanvasElement | null;
focusContainer: () => void; focusContainer: () => void;
props: AppProps; props: AppProps;
library: Library;
}; };
export class ActionManager implements ActionsManagerInterface { export class ActionManager implements ActionsManagerInterface {

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types"; import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
/** if false, the action should be prevented */ /** if false, the action should be prevented */
export type ActionResult = export type ActionResult =
@ -15,7 +16,11 @@ export type ActionResult =
} }
| false; | false;
type AppAPI = { canvas: HTMLCanvasElement | null; focusContainer(): void }; type AppAPI = {
canvas: HTMLCanvasElement | null;
focusContainer(): void;
library: Library;
};
type ActionFn = ( type ActionFn = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],

View File

@ -4,6 +4,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import clsx from "clsx"; import clsx from "clsx";
import { supported } from "browser-fs-access"; import { supported } from "browser-fs-access";
import { nanoid } from "nanoid";
import { import {
actionAddToLibrary, actionAddToLibrary,
@ -68,7 +69,7 @@ import {
} from "../constants"; } from "../constants";
import { loadFromBlob } from "../data"; import { loadFromBlob } from "../data";
import { isValidLibrary } from "../data/json"; import { isValidLibrary } from "../data/json";
import { Library } from "../data/library"; import Library from "../data/library";
import { restore } from "../data/restore"; import { restore } from "../data/restore";
import { import {
dragNewElement, dragNewElement,
@ -163,7 +164,14 @@ import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types"; import { SceneState, ScrollBars } from "../scene/types";
import { getNewZoom } from "../scene/zoom"; import { getNewZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes"; import { findShapeByKey } from "../shapes";
import { AppProps, AppState, Gesture, GestureEvent, SceneData } from "../types"; import {
AppProps,
AppState,
Gesture,
GestureEvent,
LibraryItems,
SceneData,
} from "../types";
import { import {
debounce, debounce,
distance, distance,
@ -289,6 +297,7 @@ export type ExcalidrawImperativeAPI = {
setToastMessage: InstanceType<typeof App>["setToastMessage"]; setToastMessage: InstanceType<typeof App>["setToastMessage"];
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>; readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true; ready: true;
id: string;
}; };
class App extends React.Component<AppProps, AppState> { class App extends React.Component<AppProps, AppState> {
@ -309,6 +318,9 @@ class App extends React.Component<AppProps, AppState> {
private scene: Scene; private scene: Scene;
private resizeObserver: ResizeObserver | undefined; private resizeObserver: ResizeObserver | undefined;
private nearestScrollableContainer: HTMLElement | Document | undefined; private nearestScrollableContainer: HTMLElement | Document | undefined;
public library: Library;
public libraryItemsFromStorage: LibraryItems | undefined;
private id: string;
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
@ -334,6 +346,8 @@ class App extends React.Component<AppProps, AppState> {
height: window.innerHeight, height: window.innerHeight,
}; };
this.id = nanoid();
if (excalidrawRef) { if (excalidrawRef) {
const readyPromise = const readyPromise =
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) || ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
@ -354,6 +368,7 @@ class App extends React.Component<AppProps, AppState> {
refresh: this.refresh, refresh: this.refresh,
importLibrary: this.importLibraryFromUrl, importLibrary: this.importLibraryFromUrl,
setToastMessage: this.setToastMessage, setToastMessage: this.setToastMessage,
id: this.id,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@ -363,6 +378,7 @@ class App extends React.Component<AppProps, AppState> {
readyPromise.resolve(api); readyPromise.resolve(api);
} }
this.scene = new Scene(); this.scene = new Scene();
this.library = new Library(this);
this.actionManager = new ActionManager( this.actionManager = new ActionManager(
this.syncActionResult, this.syncActionResult,
@ -490,6 +506,8 @@ class App extends React.Component<AppProps, AppState> {
libraryReturnUrl={this.props.libraryReturnUrl} libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions} UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer} focusContainer={this.focusContainer}
library={this.library}
id={this.id}
/> />
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <div className="excalidraw-contextMenuContainer" />
@ -650,12 +668,12 @@ class App extends React.Component<AppProps, AppState> {
throw new Error(); throw new Error();
} }
if ( if (
token === Library.csrfToken || token === this.id ||
window.confirm( window.confirm(
t("alerts.confirmAddLibrary", { numShapes: json.library.length }), t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
) )
) { ) {
await Library.importLibrary(blob); await this.library.importLibrary(blob);
// hack to rerender the library items after import // hack to rerender the library items after import
if (this.state.isLibraryOpen) { if (this.state.isLibraryOpen) {
this.setState({ isLibraryOpen: false }); this.setState({ isLibraryOpen: false });
@ -724,6 +742,9 @@ class App extends React.Component<AppProps, AppState> {
let initialData = null; let initialData = null;
try { try {
initialData = (await this.props.initialData) || null; initialData = (await this.props.initialData) || null;
if (initialData?.libraryItems) {
this.libraryItemsFromStorage = initialData.libraryItems;
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
initialData = { initialData = {
@ -3713,7 +3734,8 @@ class App extends React.Component<AppProps, AppState> {
file?.type === MIME_TYPES.excalidrawlib || file?.type === MIME_TYPES.excalidrawlib ||
file?.name?.endsWith(".excalidrawlib") file?.name?.endsWith(".excalidrawlib")
) { ) {
Library.importLibrary(file) this.library
.importLibrary(file)
.then(() => { .then(() => {
// Close and then open to get the libraries updated // Close and then open to get the libraries updated
this.setState({ isLibraryOpen: false }); this.setState({ isLibraryOpen: false });
@ -4248,7 +4270,6 @@ declare global {
setState: React.Component<any, AppState>["setState"]; setState: React.Component<any, AppState>["setState"];
history: SceneHistory; history: SceneHistory;
app: InstanceType<typeof App>; app: InstanceType<typeof App>;
library: typeof Library;
}; };
} }
} }
@ -4273,10 +4294,6 @@ if (
configurable: true, configurable: true,
get: () => history, get: () => history,
}, },
library: {
configurable: true,
value: Library,
},
}); });
} }
export default App; export default App;

View File

@ -10,7 +10,6 @@ import { ActionManager } from "../actions/manager";
import { CLASSES } from "../constants"; import { CLASSES } from "../constants";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
import { Library } from "../data/library";
import { isTextElement, showSelectedShapeActions } from "../element"; import { isTextElement, showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
@ -47,6 +46,7 @@ import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { UserList } from "./UserList"; import { UserList } from "./UserList";
import Library from "../data/library";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@ -73,6 +73,8 @@ interface LayerUIProps {
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"]; UIOptions: AppProps["UIOptions"];
focusContainer: () => void; focusContainer: () => void;
library: Library;
id: string;
} }
const useOnClickOutside = ( const useOnClickOutside = (
@ -104,7 +106,7 @@ const useOnClickOutside = (
}; };
const LibraryMenuItems = ({ const LibraryMenuItems = ({
library, libraryItems,
onRemoveFromLibrary, onRemoveFromLibrary,
onAddToLibrary, onAddToLibrary,
onInsertShape, onInsertShape,
@ -113,8 +115,10 @@ const LibraryMenuItems = ({
setLibraryItems, setLibraryItems,
libraryReturnUrl, libraryReturnUrl,
focusContainer, focusContainer,
library,
id,
}: { }: {
library: LibraryItems; libraryItems: LibraryItems;
pendingElements: LibraryItem; pendingElements: LibraryItem;
onRemoveFromLibrary: (index: number) => void; onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: LibraryItem) => void; onInsertShape: (elements: LibraryItem) => void;
@ -123,9 +127,11 @@ const LibraryMenuItems = ({
setLibraryItems: (library: LibraryItems) => void; setLibraryItems: (library: LibraryItems) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void; focusContainer: () => void;
library: Library;
id: string;
}) => { }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0); const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = isMobile ? 4 : 6; const CELLS_PER_ROW = isMobile ? 4 : 6;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW)); const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = []; const rows = [];
@ -143,7 +149,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.load")} aria-label={t("buttons.load")}
icon={load} icon={load}
onClick={() => { onClick={() => {
importLibraryFromJSON() importLibraryFromJSON(library)
.then(() => { .then(() => {
// Close and then open to get the libraries updated // Close and then open to get the libraries updated
setAppState({ isLibraryOpen: false }); setAppState({ isLibraryOpen: false });
@ -155,7 +161,7 @@ const LibraryMenuItems = ({
}); });
}} }}
/> />
{!!library.length && ( {!!libraryItems.length && (
<> <>
<ToolButton <ToolButton
key="export" key="export"
@ -164,7 +170,7 @@ const LibraryMenuItems = ({
aria-label={t("buttons.export")} aria-label={t("buttons.export")}
icon={exportFile} icon={exportFile}
onClick={() => { onClick={() => {
saveLibraryAsJSON() saveLibraryAsJSON(library)
.catch(muteFSAbortError) .catch(muteFSAbortError)
.catch((error) => { .catch((error) => {
setAppState({ errorMessage: error.message }); setAppState({ errorMessage: error.message });
@ -179,7 +185,7 @@ const LibraryMenuItems = ({
icon={trash} icon={trash}
onClick={() => { onClick={() => {
if (window.confirm(t("alerts.resetLibrary"))) { if (window.confirm(t("alerts.resetLibrary"))) {
Library.resetLibrary(); library.resetLibrary();
setLibraryItems([]); setLibraryItems([]);
focusContainer(); focusContainer();
} }
@ -190,7 +196,7 @@ const LibraryMenuItems = ({
<a <a
href={`https://libraries.excalidraw.com?target=${ href={`https://libraries.excalidraw.com?target=${
window.name || "_blank" window.name || "_blank"
}&referrer=${referrer}&useHash=true&token=${Library.csrfToken}`} }&referrer=${referrer}&useHash=true&token=${id}`}
target="_excalidraw_libraries" target="_excalidraw_libraries"
> >
{t("labels.libraries")} {t("labels.libraries")}
@ -205,13 +211,13 @@ const LibraryMenuItems = ({
const shouldAddPendingElements: boolean = const shouldAddPendingElements: boolean =
pendingElements.length > 0 && pendingElements.length > 0 &&
!addedPendingElements && !addedPendingElements &&
y + x >= library.length; y + x >= libraryItems.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements; addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push( children.push(
<Stack.Col key={x}> <Stack.Col key={x}>
<LibraryUnit <LibraryUnit
elements={library[y + x]} elements={libraryItems[y + x]}
pendingElements={ pendingElements={
shouldAddPendingElements ? pendingElements : undefined shouldAddPendingElements ? pendingElements : undefined
} }
@ -219,7 +225,7 @@ const LibraryMenuItems = ({
onClick={ onClick={
shouldAddPendingElements shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements) ? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, library[y + x]) : onInsertShape.bind(null, libraryItems[y + x])
} }
/> />
</Stack.Col>, </Stack.Col>,
@ -247,6 +253,8 @@ const LibraryMenu = ({
setAppState, setAppState,
libraryReturnUrl, libraryReturnUrl,
focusContainer, focusContainer,
library,
id,
}: { }: {
pendingElements: LibraryItem; pendingElements: LibraryItem;
onClickOutside: (event: MouseEvent) => void; onClickOutside: (event: MouseEvent) => void;
@ -255,6 +263,8 @@ const LibraryMenu = ({
setAppState: React.Component<any, AppState>["setState"]; setAppState: React.Component<any, AppState>["setState"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
focusContainer: () => void; focusContainer: () => void;
library: Library;
id: string;
}) => { }) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, (event) => { useOnClickOutside(ref, (event) => {
@ -280,7 +290,7 @@ const LibraryMenu = ({
resolve("loading"); resolve("loading");
}, 100); }, 100);
}), }),
Library.loadLibrary().then((items) => { library.loadLibrary().then((items) => {
setLibraryItems(items); setLibraryItems(items);
setIsLoading("ready"); setIsLoading("ready");
}), }),
@ -292,24 +302,33 @@ const LibraryMenu = ({
return () => { return () => {
clearTimeout(loadingTimerRef.current!); clearTimeout(loadingTimerRef.current!);
}; };
}, []); }, [library]);
const removeFromLibrary = useCallback(async (indexToRemove) => { const removeFromLibrary = useCallback(
const items = await Library.loadLibrary(); async (indexToRemove) => {
const nextItems = items.filter((_, index) => index !== indexToRemove); const items = await library.loadLibrary();
Library.saveLibrary(nextItems); const nextItems = items.filter((_, index) => index !== indexToRemove);
setLibraryItems(nextItems); library.saveLibrary(nextItems).catch((error) => {
}, []); setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
});
setLibraryItems(nextItems);
},
[library, setAppState],
);
const addToLibrary = useCallback( const addToLibrary = useCallback(
async (elements: LibraryItem) => { async (elements: LibraryItem) => {
const items = await Library.loadLibrary(); const items = await library.loadLibrary();
const nextItems = [...items, elements]; const nextItems = [...items, elements];
onAddToLibrary(); onAddToLibrary();
Library.saveLibrary(nextItems); library.saveLibrary(nextItems).catch((error) => {
setLibraryItems(items);
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
setLibraryItems(nextItems); setLibraryItems(nextItems);
}, },
[onAddToLibrary], [onAddToLibrary, library, setAppState],
); );
return loadingState === "preloading" ? null : ( return loadingState === "preloading" ? null : (
@ -320,7 +339,7 @@ const LibraryMenu = ({
</div> </div>
) : ( ) : (
<LibraryMenuItems <LibraryMenuItems
library={libraryItems} libraryItems={libraryItems}
onRemoveFromLibrary={removeFromLibrary} onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary} onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape} onInsertShape={onInsertShape}
@ -329,6 +348,8 @@ const LibraryMenu = ({
setLibraryItems={setLibraryItems} setLibraryItems={setLibraryItems}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer} focusContainer={focusContainer}
library={library}
id={id}
/> />
)} )}
</Island> </Island>
@ -355,6 +376,8 @@ const LayerUI = ({
libraryReturnUrl, libraryReturnUrl,
UIOptions, UIOptions,
focusContainer, focusContainer,
library,
id,
}: LayerUIProps) => { }: LayerUIProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -526,6 +549,8 @@ const LayerUI = ({
setAppState={setAppState} setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer} focusContainer={focusContainer}
library={library}
id={id}
/> />
) : null; ) : null;

View File

@ -5,12 +5,13 @@ import { clearElementsForExport } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState } from "../types";
import { loadFromBlob } from "./blob"; import { loadFromBlob } from "./blob";
import { Library } from "./library";
import { import {
ExportedDataState, ExportedDataState,
ImportedDataState, ImportedDataState,
ExportedLibraryData, ExportedLibraryData,
} from "./types"; } from "./types";
import Library from "./library";
export const serializeAsJSON = ( export const serializeAsJSON = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -88,13 +89,13 @@ export const isValidLibrary = (json: any) => {
); );
}; };
export const saveLibraryAsJSON = async () => { export const saveLibraryAsJSON = async (library: Library) => {
const library = await Library.loadLibrary(); const libraryItems = await library.loadLibrary();
const data: ExportedLibraryData = { const data: ExportedLibraryData = {
type: EXPORT_DATA_TYPES.excalidrawLibrary, type: EXPORT_DATA_TYPES.excalidrawLibrary,
version: 1, version: 1,
source: EXPORT_SOURCE, source: EXPORT_SOURCE,
library, library: libraryItems,
}; };
const serialized = JSON.stringify(data, null, 2); const serialized = JSON.stringify(data, null, 2);
const fileName = "library.excalidrawlib"; const fileName = "library.excalidrawlib";
@ -108,7 +109,7 @@ export const saveLibraryAsJSON = async () => {
}); });
}; };
export const importLibraryFromJSON = async () => { export const importLibraryFromJSON = async (library: Library) => {
const blob = await fileOpen({ const blob = await fileOpen({
description: "Excalidraw library files", description: "Excalidraw library files",
// ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
@ -117,5 +118,5 @@ export const importLibraryFromJSON = async () => {
extensions: [".json", ".excalidrawlib"], extensions: [".json", ".excalidrawlib"],
*/ */
}); });
await Library.importLibrary(blob); await library.importLibrary(blob);
}; };

View File

@ -1,22 +1,25 @@
import { loadLibraryFromBlob } from "./blob"; import { loadLibraryFromBlob } from "./blob";
import { LibraryItems, LibraryItem } from "../types"; import { LibraryItems, LibraryItem } from "../types";
import { restoreElements } from "./restore"; import { restoreElements } from "./restore";
import { STORAGE_KEYS } from "../constants";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { NonDeleted, ExcalidrawElement } from "../element/types"; import { NonDeleted, ExcalidrawElement } from "../element/types";
import { nanoid } from "nanoid"; import App from "../components/App";
export class Library { class Library {
private static libraryCache: LibraryItems | null = null; private libraryCache: LibraryItems | null = null;
public static csrfToken = nanoid(); private app: App;
static resetLibrary = () => { constructor(app: App) {
Library.libraryCache = null; this.app = app;
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); }
resetLibrary = async () => {
await this.app.props.onLibraryChange?.([]);
this.libraryCache = [];
}; };
/** imports library (currently merges, removing duplicates) */ /** imports library (currently merges, removing duplicates) */
static async importLibrary(blob: Blob) { async importLibrary(blob: Blob) {
const libraryFile = await loadLibraryFromBlob(blob); const libraryFile = await loadLibraryFromBlob(blob);
if (!libraryFile || !libraryFile.library) { if (!libraryFile || !libraryFile.library) {
return; return;
@ -46,7 +49,7 @@ export class Library {
}); });
}; };
const existingLibraryItems = await Library.loadLibrary(); const existingLibraryItems = await this.loadLibrary();
const filtered = libraryFile.library!.reduce((acc, libraryItem) => { const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
const restored = getNonDeletedElements(restoreElements(libraryItem)); const restored = getNonDeletedElements(restoreElements(libraryItem));
@ -56,27 +59,27 @@ export class Library {
return acc; return acc;
}, [] as (readonly NonDeleted<ExcalidrawElement>[])[]); }, [] as (readonly NonDeleted<ExcalidrawElement>[])[]);
Library.saveLibrary([...existingLibraryItems, ...filtered]); await this.saveLibrary([...existingLibraryItems, ...filtered]);
} }
static loadLibrary = (): Promise<LibraryItems> => { loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (Library.libraryCache) { if (this.libraryCache) {
return resolve(JSON.parse(JSON.stringify(Library.libraryCache))); return resolve(JSON.parse(JSON.stringify(this.libraryCache)));
} }
try { try {
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); const libraryItems = this.app.libraryItemsFromStorage;
if (!data) { if (!libraryItems) {
return resolve([]); return resolve([]);
} }
const items = (JSON.parse(data) as LibraryItems).map((elements) => const items = libraryItems.map(
restoreElements(elements), (elements) => restoreElements(elements) as LibraryItem,
) as Mutable<LibraryItems>; );
// clone to ensure we don't mutate the cached library elements in the app // clone to ensure we don't mutate the cached library elements in the app
Library.libraryCache = JSON.parse(JSON.stringify(items)); this.libraryCache = JSON.parse(JSON.stringify(items));
resolve(items); resolve(items);
} catch (error) { } catch (error) {
@ -86,17 +89,19 @@ export class Library {
}); });
}; };
static saveLibrary = (items: LibraryItems) => { saveLibrary = async (items: LibraryItems) => {
const prevLibraryItems = Library.libraryCache; const prevLibraryItems = this.libraryCache;
try { try {
const serializedItems = JSON.stringify(items); const serializedItems = JSON.stringify(items);
// cache optimistically so that consumers have access to the latest // cache optimistically so that the app has access to the latest
// immediately // immediately
Library.libraryCache = JSON.parse(serializedItems); this.libraryCache = JSON.parse(serializedItems);
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); await this.app.props.onLibraryChange?.(items);
} catch (error) { } catch (error) {
Library.libraryCache = prevLibraryItems; this.libraryCache = prevLibraryItems;
console.error(error); throw error;
} }
}; };
} }
export default Library;

View File

@ -17,6 +17,7 @@ export interface ImportedDataState {
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<Partial<AppState>> | null; appState?: Readonly<Partial<AppState>> | null;
scrollToContent?: boolean; scrollToContent?: boolean;
libraryItems?: LibraryItems;
} }
export interface ExportedLibraryData { export interface ExportedLibraryData {

View File

@ -14,6 +14,7 @@ import { TopErrorBoundary } from "../components/TopErrorBoundary";
import { import {
APP_NAME, APP_NAME,
EVENT, EVENT,
STORAGE_KEYS,
TITLE_TIMEOUT, TITLE_TIMEOUT,
URL_HASH_KEYS, URL_HASH_KEYS,
VERSION_TIMEOUT, VERSION_TIMEOUT,
@ -30,7 +31,7 @@ import Excalidraw, {
defaultLang, defaultLang,
languages, languages,
} from "../packages/excalidraw/index"; } from "../packages/excalidraw/index";
import { AppState } from "../types"; import { AppState, LibraryItems } from "../types";
import { import {
debounce, debounce,
getVersion, getVersion,
@ -195,6 +196,18 @@ const ExcalidrawWrapper = () => {
} }
initializeScene({ collabAPI }).then((scene) => { initializeScene({ collabAPI }).then((scene) => {
if (scene) {
try {
scene.libraryItems =
JSON.parse(
localStorage.getItem(
STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
) as string,
) || [];
} catch (e) {
console.error(e);
}
}
initialStatePromiseRef.current.promise.resolve(scene); initialStatePromiseRef.current.promise.resolve(scene);
}); });
@ -310,6 +323,14 @@ 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);
};
return ( return (
<> <>
<Excalidraw <Excalidraw
@ -325,6 +346,7 @@ const ExcalidrawWrapper = () => {
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
detectScroll={false} detectScroll={false}
handleKeyboardGlobally={true} handleKeyboardGlobally={true}
onLibraryChange={onLibraryChange}
/> />
{excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
{errorMessage && ( {errorMessage && (

View File

@ -142,6 +142,8 @@
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?", "loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)", "collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
"errorLoadingLibrary": "There was an error loading the third party library.", "errorLoadingLibrary": "There was an error loading the third party library.",
"errorAddingToLibrary": "Couldn't add item to the library",
"errorRemovingFromLibrary": "Couldn't remove item from the library",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?", "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?", "imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
"cannotRestoreFromImage": "Scene couldn't be restored from this image file", "cannotRestoreFromImage": "Scene couldn't be restored from this image file",

View File

@ -17,9 +17,19 @@ Please add the latest change on the top under the correct section.
### Features ### Features
- Make library local to given excalidraw instance (previously, all instances on the same page shared one global library) [#3451](https://github.com/excalidraw/excalidraw/pull/3451).
- Added prop [onLibraryChange](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onLibraryChange) which if supplied will be called when library is updated.
- Added attribute `libraryItems` to prop [initialData](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#initialdata) which can be used to load excalidraw with existing library items.
- Assign a [unique id](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#Id) to the excalidraw component. The id can be accessed via [`ref`](https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx#L265).
#### BREAKING CHANGE
- From now on the host application is responsible for [persisting the library]((https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onLibraryChange) to LocalStorage (or elsewhere), and [importing it on mount]((https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#initialdata).
- Bind the keyboard events to component and added a prop [`handleKeyboardGlobally`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#handleKeyboardGlobally) which if set to true will bind the keyboard events to document [#3430](https://github.com/excalidraw/excalidraw/pull/3430). - Bind the keyboard events to component and added a prop [`handleKeyboardGlobally`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#handleKeyboardGlobally) which if set to true will bind the keyboard events to document [#3430](https://github.com/excalidraw/excalidraw/pull/3430).
#### BREAKING CHNAGE #### BREAKING CHANGE
- Earlier keyboard events were bind to document but now its bind to Excalidraw component by default. So you will need to set [`handleKeyboardGlobally`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#handleKeyboardGlobally) to true if you want the previous behaviour (bind the keyboard events to document). - Earlier keyboard events were bind to document but now its bind to Excalidraw component by default. So you will need to set [`handleKeyboardGlobally`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#handleKeyboardGlobally) to true if you want the previous behaviour (bind the keyboard events to document).

View File

@ -367,6 +367,7 @@ To view the full example visit :point_down:
| [`onPaste`](#onPaste) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L17">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</pre> | | Callback to be triggered if passed when the something is pasted in to the scene | | [`onPaste`](#onPaste) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L17">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</pre> | | Callback to be triggered if passed when the something is pasted in to the scene |
| [`detectScroll`](#detectScroll) | boolean | true | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`detectScroll`](#detectScroll) | boolean | true | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. | | [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. |
| [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> | | The callback if supplied is triggered when the library is updated and receives the library items. |
### Dimensions of Excalidraw ### Dimensions of Excalidraw
@ -395,6 +396,7 @@ This helps to load Excalidraw with `initialData`. It must be an object or a [pro
| `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) | The elements with which Excalidraw should be mounted. | | `elements` | [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78) | The elements with which Excalidraw should be mounted. |
| `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37) | The App state with which Excalidraw should be mounted. | | `appState` | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L37) | The App state with which Excalidraw should be mounted. |
| `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained | | `scrollToContent` | boolean | This attribute implies whether to scroll to the nearest element to center once Excalidraw is mounted. By default, it will not scroll the nearest element to the center. Make sure you pass `initialData.appState.scrollX` and `initialData.appState.scrollY` when `scrollToContent` is false so that scroll positions are retained |
| `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L151) | This library items with which Excalidraw should be mounted. |
```json ```json
{ {
@ -445,6 +447,7 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. | | refresh | `() => void` | Updates the offsets for the Excalidraw component so that the coordinates are computed correctly (for example the cursor position). You don't have to call this when the position is changed on page scroll or when the excalidraw container resizes (we handle that ourselves). For any other cases if the position of excalidraw is updated (example due to scroll on parent container and not page scroll) you should call this API. |
| [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL | | [importLibrary](#importlibrary) | `(url: string, token?: string) => void` | Imports library from given URL |
| setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. | | setToastMessage | `(message: string) => void` | This API can be used to show the toast with custom message. |
| [id](#id) | string | Unique ID for the excalidraw component. |
#### `readyPromise` #### `readyPromise`
@ -599,6 +602,20 @@ Indicates whether to bind keyboard events to `document`. Disabled by default, me
Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar). Enable this if you want Excalidraw to handle keyboard even if the component isn't focused (e.g. a user is interacting with the navbar, sidebar, or similar).
### onLibraryChange
Ths callback if supplied will get triggered when the library is updated and has the below signature.
<pre>
(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void | Promise<any>
</pre>
It is invoked with empty items when user clears the library. You can use this callback when you want to do something additional when library is updated for example persisting it to local storage.
### id
The unique id of the excalidraw component. This can be used to identify the excalidraw component, for example importing the library items to the excalidraw component from where it was initiated when you have multiple excalidraw components rendered on the same page as shown in [multiple excalidraw demo](https://codesandbox.io/s/multiple-excalidraw-k1xx5).
### Extra API's ### Extra API's
#### `getSceneVersion` #### `getSceneVersion`

View File

@ -32,6 +32,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
onPaste, onPaste,
detectScroll = true, detectScroll = true,
handleKeyboardGlobally = false, handleKeyboardGlobally = false,
onLibraryChange,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@ -84,6 +85,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
onPaste={onPaste} onPaste={onPaste}
detectScroll={detectScroll} detectScroll={detectScroll}
handleKeyboardGlobally={handleKeyboardGlobally} handleKeyboardGlobally={handleKeyboardGlobally}
onLibraryChange={onLibraryChange}
/> />
</InitializeApp> </InitializeApp>
); );

View File

@ -9,17 +9,17 @@ const { h } = window;
describe("library", () => { describe("library", () => {
beforeEach(async () => { beforeEach(async () => {
h.library.resetLibrary();
await render(<ExcalidrawApp />); await render(<ExcalidrawApp />);
h.app.library.resetLibrary();
}); });
it("import library via drag&drop", async () => { it("import library via drag&drop", async () => {
expect(await h.library.loadLibrary()).toEqual([]); expect(await h.app.library.loadLibrary()).toEqual([]);
await API.drop( await API.drop(
await API.loadFile("./fixtures/fixture_library.excalidrawlib"), await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
); );
await waitFor(async () => { await waitFor(async () => {
expect(await h.library.loadLibrary()).toEqual([ expect(await h.app.library.loadLibrary()).toEqual([
[expect.objectContaining({ id: "A" })], [expect.objectContaining({ id: "A" })],
]); ]);
}); });

View File

@ -197,6 +197,7 @@ export interface ExcalidrawProps {
UIOptions?: UIOptions; UIOptions?: UIOptions;
detectScroll?: boolean; detectScroll?: boolean;
handleKeyboardGlobally?: boolean; handleKeyboardGlobally?: boolean;
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
} }
export type SceneData = { export type SceneData = {