From cad6097d604d891e94e397af6bec0d2f3d2eed9b Mon Sep 17 00:00:00 2001 From: David Luzar Date: Wed, 11 May 2022 15:08:54 +0200 Subject: [PATCH] feat: factor out url library init & switch to `updateLibrary` API (#5115) Co-authored-by: Aakansha Doshi --- src/components/App.tsx | 125 +++------------- src/components/LibraryMenuItems.tsx | 25 +++- src/data/json.ts | 13 -- src/data/library.ts | 200 +++++++++++++++++++++---- src/data/types.ts | 9 +- src/excalidraw-app/index.tsx | 32 ++-- src/packages/excalidraw/CHANGELOG.md | 14 ++ src/packages/excalidraw/README_NEXT.md | 90 +++++++++-- src/packages/excalidraw/example/App.js | 99 +++++++----- src/packages/excalidraw/index.tsx | 5 + src/types.ts | 17 ++- 11 files changed, 394 insertions(+), 235 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 3c1f4788..b1134d16 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -70,14 +70,12 @@ import { TEXT_TO_CENTER_SNAP_THRESHOLD, THEME, TOUCH_CTX_MENU_TIMEOUT, - URL_HASH_KEYS, - URL_QUERY_KEYS, VERTICAL_ALIGN, ZOOM_STEP, } from "../constants"; import { loadFromBlob } from "../data"; import Library from "../data/library"; -import { restore, restoreElements, restoreLibraryItems } from "../data/restore"; +import { restore, restoreElements } from "../data/restore"; import { dragNewElement, dragSelectedElements, @@ -234,7 +232,6 @@ import { isSupportedImageFile, loadSceneOrLibraryFromBlob, normalizeFile, - loadLibraryFromBlob, resizeImageFile, SVGStringToFile, } from "../data/blob"; @@ -261,7 +258,6 @@ import { isPointHittingLinkIcon, isLocalLink, } from "../element/Hyperlink"; -import { AbortError } from "../errors"; const defaultDeviceTypeContext: DeviceType = { isMobile: false, @@ -361,6 +357,8 @@ class App extends React.Component { this.id = nanoid(); + this.library = new Library(this); + if (excalidrawRef) { const readyPromise = ("current" in excalidrawRef && excalidrawRef.current?.readyPromise) || @@ -370,6 +368,7 @@ class App extends React.Component { ready: true, readyPromise, updateScene: this.updateScene, + updateLibrary: this.library.updateLibrary, addFiles: this.addFiles, resetScene: this.resetScene, getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, @@ -381,7 +380,6 @@ class App extends React.Component { getAppState: () => this.state, getFiles: () => this.files, refresh: this.refresh, - importLibrary: this.importLibraryFromUrl, setToastMessage: this.setToastMessage, id: this.id, setActiveTool: this.setActiveTool, @@ -400,7 +398,6 @@ class App extends React.Component { }; this.scene = new Scene(); - this.library = new Library(this); this.history = new History(); this.actionManager = new ActionManager( this.syncActionResult, @@ -698,54 +695,6 @@ class App extends React.Component { this.onSceneUpdated(); }; - private importLibraryFromUrl = async (url: string, token?: string | null) => { - if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { - const hash = new URLSearchParams(window.location.hash.slice(1)); - hash.delete(URL_HASH_KEYS.addLibrary); - window.history.replaceState({}, APP_NAME, `#${hash.toString()}`); - } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) { - const query = new URLSearchParams(window.location.search); - query.delete(URL_QUERY_KEYS.addLibrary); - window.history.replaceState({}, APP_NAME, `?${query.toString()}`); - } - - const defaultStatus = "published"; - - this.setState({ isLibraryOpen: true }); - - try { - await this.library.importLibrary( - new Promise(async (resolve, reject) => { - try { - const request = await fetch(decodeURIComponent(url)); - const blob = await request.blob(); - const libraryItems = await loadLibraryFromBlob(blob, defaultStatus); - - if ( - token === this.id || - window.confirm( - t("alerts.confirmAddLibrary", { - numShapes: libraryItems.length, - }), - ) - ) { - resolve(libraryItems); - } else { - reject(new AbortError()); - } - } catch (error: any) { - reject(error); - } - }), - ); - } catch (error: any) { - console.error(error); - this.setState({ errorMessage: t("errors.importLibraryError") }); - } finally { - this.focusContainer(); - } - }; - private resetHistory = () => { this.history.clear(); }; @@ -790,7 +739,14 @@ class App extends React.Component { try { initialData = (await this.props.initialData) || null; if (initialData?.libraryItems) { - this.library.importLibrary(initialData.libraryItems, "unpublished"); + this.library + .updateLibrary({ + libraryItems: initialData.libraryItems, + merge: true, + }) + .catch((error) => { + console.error(error); + }); } } catch (error: any) { console.error(error); @@ -802,10 +758,10 @@ class App extends React.Component { }, }; } - const scene = restore(initialData, null, null); scene.appState = { ...scene.appState, + isLibraryOpen: this.state.isLibraryOpen, activeTool: scene.appState.activeTool.type === "image" ? { ...scene.appState.activeTool, type: "selection" } @@ -834,20 +790,6 @@ class App extends React.Component { ...scene, commitToHistory: true, }); - - const libraryUrl = - // current - new URLSearchParams(window.location.hash.slice(1)).get( - URL_HASH_KEYS.addLibrary, - ) || - // legacy, kept for compat reasons - new URLSearchParams(window.location.search).get( - URL_QUERY_KEYS.addLibrary, - ); - - if (libraryUrl) { - await this.importLibraryFromUrl(libraryUrl); - } }; public async componentDidMount() { @@ -1691,14 +1633,6 @@ class App extends React.Component { appState?: Pick | null; collaborators?: SceneData["collaborators"]; commitToHistory?: SceneData["commitToHistory"]; - libraryItems?: - | (( - currentLibraryItems: LibraryItems, - ) => - | Required["libraryItems"] - | Promise["libraryItems"]>) - | Required["libraryItems"] - | Promise["libraryItems"]>; }) => { if (sceneData.commitToHistory) { this.history.resumeRecording(); @@ -1715,23 +1649,6 @@ class App extends React.Component { if (sceneData.collaborators) { this.setState({ collaborators: sceneData.collaborators }); } - - if (sceneData.libraryItems) { - this.library.setLibrary((currentLibraryItems) => { - const nextItems = - typeof sceneData.libraryItems === "function" - ? sceneData.libraryItems(currentLibraryItems) - : sceneData.libraryItems; - - return new Promise(async (resolve, reject) => { - try { - resolve(restoreLibraryItems(await nextItems, "unpublished")); - } catch (error: any) { - reject(error); - } - }); - }); - } }, ); @@ -5335,19 +5252,15 @@ class App extends React.Component { commitToHistory: true, }); } else if (ret.type === MIME_TYPES.excalidrawlib) { - this.library - .importLibrary(file) - .then(() => { - this.setState({ - isLoading: false, - }); + await this.library + .updateLibrary({ + libraryItems: file, + merge: true, + openLibraryMenu: true, }) .catch((error) => { console.error(error); - this.setState({ - isLoading: false, - errorMessage: t("errors.importLibraryError"), - }); + this.setState({ errorMessage: t("errors.importLibraryError") }); }); } } catch (error: any) { diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index a97a9259..5cddf556 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -1,6 +1,6 @@ import { chunk } from "lodash"; import React, { useCallback, useState } from "react"; -import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json"; +import { saveLibraryAsJSON } from "../data/json"; import Library from "../data/library"; import { ExcalidrawElement, NonDeleted } from "../element/types"; import { t } from "../i18n"; @@ -23,6 +23,7 @@ import { Tooltip } from "./Tooltip"; import "./LibraryMenuItems.scss"; import { VERSIONS } from "../constants"; import Spinner from "./Spinner"; +import { fileOpen } from "../data/filesystem"; const LibraryMenuItems = ({ isLoading, @@ -107,13 +108,23 @@ const LibraryMenuItems = ({ title={t("buttons.load")} aria-label={t("buttons.load")} icon={load} - onClick={() => { - importLibraryFromJSON(library) - .catch(muteFSAbortError) - .catch((error) => { - console.error(error); - setAppState({ errorMessage: t("errors.importLibraryError") }); + onClick={async () => { + try { + await fileOpen({ + description: "Excalidraw library files", + // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 + // gets resolved. Else, iOS users cannot open `.excalidraw` files. + /* + extensions: [".json", ".excalidrawlib"], + */ }); + } catch (error: any) { + if (error?.name === "AbortError") { + console.warn(error); + return; + } + setAppState({ errorMessage: t("errors.importLibraryError") }); + } }} className="library-actions--load" /> diff --git a/src/data/json.ts b/src/data/json.ts index 03831b7c..958cbe23 100644 --- a/src/data/json.ts +++ b/src/data/json.ts @@ -17,7 +17,6 @@ import { ExportedLibraryData, ImportedLibraryData, } from "./types"; -import Library from "./library"; /** * Strips out files which are only referenced by deleted elements @@ -147,15 +146,3 @@ export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => { }, ); }; - -export const importLibraryFromJSON = async (library: Library) => { - const blob = await fileOpen({ - description: "Excalidraw library files", - // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442 - // gets resolved. Else, iOS users cannot open `.excalidraw` files. - /* - extensions: [".json", ".excalidrawlib"], - */ - }); - await library.importLibrary(blob); -}; diff --git a/src/data/library.ts b/src/data/library.ts index f552743a..b4bb1352 100644 --- a/src/data/library.ts +++ b/src/data/library.ts @@ -1,10 +1,18 @@ import { loadLibraryFromBlob } from "./blob"; -import { LibraryItems, LibraryItem } from "../types"; +import { + LibraryItems, + LibraryItem, + ExcalidrawImperativeAPI, + LibraryItemsSource, +} from "../types"; import { restoreLibraryItems } from "./restore"; import type App from "../components/App"; -import { ImportedDataState } from "./types"; import { atom } from "jotai"; import { jotaiStore } from "../jotai"; +import { AbortError } from "../errors"; +import { t } from "../i18n"; +import { useEffect, useRef } from "react"; +import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants"; export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; @@ -102,36 +110,6 @@ class Library { return this.setLibrary([]); }; - /** - * imports library (from blob or libraryItems), merging with current library - * (attempting to remove duplicates) - */ - importLibrary( - library: - | Blob - | Required["libraryItems"] - | Promise["libraryItems"]>, - defaultStatus: LibraryItem["status"] = "unpublished", - ): Promise { - return this.setLibrary( - () => - new Promise(async (resolve, reject) => { - try { - let libraryItems: LibraryItems; - if (library instanceof Blob) { - libraryItems = await loadLibraryFromBlob(library, defaultStatus); - } else { - libraryItems = restoreLibraryItems(await library, defaultStatus); - } - - resolve(mergeLibraryItems(this.lastLibraryItems, libraryItems)); - } catch (error) { - reject(error); - } - }), - ); - } - /** * @returns latest cloned libraryItems. Awaits all in-progress updates first. */ @@ -151,6 +129,65 @@ class Library { }); }; + // NOTE this is a high-level public API (exposed on ExcalidrawAPI) with + // a slight overhead (always restoring library items). For internal use + // where merging isn't needed, use `library.setLibrary()` directly. + updateLibrary = async ({ + libraryItems, + prompt = false, + merge = false, + openLibraryMenu = false, + defaultStatus = "unpublished", + }: { + libraryItems: LibraryItemsSource; + merge?: boolean; + prompt?: boolean; + openLibraryMenu?: boolean; + defaultStatus?: "unpublished" | "published"; + }): Promise => { + if (openLibraryMenu) { + this.app.setState({ isLibraryOpen: true }); + } + + return this.setLibrary(() => { + return new Promise(async (resolve, reject) => { + try { + const source = await (typeof libraryItems === "function" + ? libraryItems(this.lastLibraryItems) + : libraryItems); + + let nextItems; + + if (source instanceof Blob) { + nextItems = await loadLibraryFromBlob(source, defaultStatus); + } else { + nextItems = restoreLibraryItems(source, defaultStatus); + } + if ( + !prompt || + window.confirm( + t("alerts.confirmAddLibrary", { + numShapes: nextItems.length, + }), + ) + ) { + if (merge) { + resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); + } else { + resolve(nextItems); + } + } else { + reject(new AbortError()); + } + } catch (error: any) { + reject(error); + } + }); + }).finally(() => { + this.app.focusContainer(); + }); + }; + setLibrary = ( /** * LibraryItems that will replace current items. Can be a function which @@ -204,3 +241,102 @@ class Library { } export default Library; + +export const parseLibraryTokensFromUrl = () => { + const libraryUrl = + // current + new URLSearchParams(window.location.hash.slice(1)).get( + URL_HASH_KEYS.addLibrary, + ) || + // legacy, kept for compat reasons + new URLSearchParams(window.location.search).get(URL_QUERY_KEYS.addLibrary); + const idToken = libraryUrl + ? new URLSearchParams(window.location.hash.slice(1)).get("token") + : null; + + return libraryUrl ? { libraryUrl, idToken } : null; +}; + +export const useHandleLibrary = ({ + excalidrawAPI, + getInitialLibraryItems, +}: { + excalidrawAPI: ExcalidrawImperativeAPI | null; + getInitialLibraryItems?: () => LibraryItemsSource; +}) => { + const getInitialLibraryRef = useRef(getInitialLibraryItems); + + useEffect(() => { + if (!excalidrawAPI) { + return; + } + + const importLibraryFromURL = ({ + libraryUrl, + idToken, + }: { + libraryUrl: string; + idToken: string | null; + }) => { + if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { + const hash = new URLSearchParams(window.location.hash.slice(1)); + hash.delete(URL_HASH_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `#${hash.toString()}`); + } else if (window.location.search.includes(URL_QUERY_KEYS.addLibrary)) { + const query = new URLSearchParams(window.location.search); + query.delete(URL_QUERY_KEYS.addLibrary); + window.history.replaceState({}, APP_NAME, `?${query.toString()}`); + } + + excalidrawAPI.updateLibrary({ + libraryItems: new Promise(async (resolve, reject) => { + try { + const request = await fetch(decodeURIComponent(libraryUrl)); + const blob = await request.blob(); + resolve(blob); + } catch (error: any) { + reject(error); + } + }), + prompt: idToken !== excalidrawAPI.id, + merge: true, + defaultStatus: "published", + openLibraryMenu: true, + }); + }; + const onHashChange = (event: HashChangeEvent) => { + event.preventDefault(); + const libraryUrlTokens = parseLibraryTokensFromUrl(); + if (libraryUrlTokens) { + event.stopImmediatePropagation(); + // If hash changed and it contains library url, import it and replace + // the url to its previous state (important in case of collaboration + // and similar). + // Using history API won't trigger another hashchange. + window.history.replaceState({}, "", event.oldURL); + + importLibraryFromURL(libraryUrlTokens); + } + }; + + // ------------------------------------------------------------------------- + // ------ init load -------------------------------------------------------- + if (getInitialLibraryRef.current) { + excalidrawAPI.updateLibrary({ + libraryItems: getInitialLibraryRef.current(), + }); + } + + const libraryUrlTokens = parseLibraryTokensFromUrl(); + + if (libraryUrlTokens) { + importLibraryFromURL(libraryUrlTokens); + } + // --------------------------------------------------------- init load ----- + + window.addEventListener(EVENT.HASHCHANGE, onHashChange); + return () => { + window.removeEventListener(EVENT.HASHCHANGE, onHashChange); + }; + }, [excalidrawAPI]); +}; diff --git a/src/data/types.ts b/src/data/types.ts index 0c2140f2..413e8183 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -1,5 +1,10 @@ import { ExcalidrawElement } from "../element/types"; -import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types"; +import { + AppState, + BinaryFiles, + LibraryItems, + LibraryItems_anyVersion, +} from "../types"; import type { cleanAppStateForExport } from "../appState"; import { VERSIONS } from "../constants"; @@ -19,7 +24,7 @@ export interface ImportedDataState { elements?: readonly ExcalidrawElement[] | null; appState?: Readonly> | null; scrollToContent?: boolean; - libraryItems?: LibraryItems | LibraryItems_v1; + libraryItems?: LibraryItems_anyVersion; files?: BinaryFiles; } diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 75f5ae14..f4e4efe9 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -4,13 +4,7 @@ import { trackEvent } from "../analytics"; import { getDefaultAppState } from "../appState"; import { ErrorDialog } from "../components/ErrorDialog"; import { TopErrorBoundary } from "../components/TopErrorBoundary"; -import { - APP_NAME, - EVENT, - TITLE_TIMEOUT, - URL_HASH_KEYS, - VERSION_TIMEOUT, -} from "../constants"; +import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants"; import { loadFromBlob } from "../data/blob"; import { ExcalidrawElement, @@ -72,6 +66,7 @@ import { loadFilesFromFirebase } from "./data/firebase"; import { LocalData } from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import clsx from "clsx"; +import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; const languageDetector = new LanguageDetector(); languageDetector.init({ @@ -232,6 +227,11 @@ const ExcalidrawWrapper = () => { const collabAPI = useContext(CollabContext)?.api; + useHandleLibrary({ + excalidrawAPI, + getInitialLibraryItems: getLibraryItemsFromStorage, + }); + useEffect(() => { if (!collabAPI || !excalidrawAPI) { return; @@ -301,8 +301,6 @@ const ExcalidrawWrapper = () => { LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds }); } } - - data.scene.libraryItems = getLibraryItemsFromStorage(); }; initializeScene({ collabAPI }).then((data) => { @@ -310,18 +308,10 @@ const ExcalidrawWrapper = () => { initialStatePromiseRef.current.promise.resolve(data.scene); }); - const onHashChange = (event: HashChangeEvent) => { + const onHashChange = async (event: HashChangeEvent) => { event.preventDefault(); - const hash = new URLSearchParams(window.location.hash.slice(1)); - const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary); - if (libraryUrl) { - // If hash changed and it contains library url, import it and replace - // the url to its previous state (important in case of collaboration - // and similar). - // Using history API won't trigger another hashchange. - window.history.replaceState({}, "", event.oldURL); - excalidrawAPI.importLibrary(libraryUrl, hash.get("token")); - } else { + const libraryUrlTokens = parseLibraryTokensFromUrl(); + if (!libraryUrlTokens) { initializeScene({ collabAPI }).then((data) => { loadImages(data); if (data.scene) { @@ -355,6 +345,8 @@ const ExcalidrawWrapper = () => { setLangCode(langCode); excalidrawAPI.updateScene({ ...localDataState, + }); + excalidrawAPI.updateLibrary({ libraryItems: getLibraryItemsFromStorage(), }); collabAPI.setUsername(username || ""); diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index e3ca421f..5624e15d 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,20 @@ Please add the latest change on the top under the correct section. #### Features +- Added [`useHandleLibrary`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#useHandleLibrary) hook to automatically handle importing of libraries when `#addLibrary` URL hash key is present, and potentially for initializing library as well [#5115](https://github.com/excalidraw/excalidraw/pull/5115). + + Also added [`parseLibraryTokensFromUrl`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#parseLibraryTokensFromUrl) to help in manually importing library from URL if desired. + + ##### BREAKING CHANGE + + - Libraries are no longer automatically initialized from URL when `#addLibrary` hash key is present. Host apps now need to handle this themselves with the help of either of the above APIs (`useHandleLibrary` is recommended). + +- Added [`updateLibrary`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateLibrary) API to update (replace/merge) the library [#5115](https://github.com/excalidraw/excalidraw/pull/5115). + + ##### BREAKING CHANGE + + - `updateScene` API no longer supports passing `libraryItems`. Instead, use the `updateLibrary` API. + - Add support for integrating custom elements [#5164](https://github.com/excalidraw/excalidraw/pull/5164). - Add [`onPointerDown`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onPointerDown) callback which gets triggered on pointer down events. diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index d04bb0b0..6bec7e1c 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -480,20 +480,21 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the | --- | --- | --- | | ready | `boolean` | This is set to true once Excalidraw is rendered | | readyPromise | [resolvablePromise](https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317) | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readyPromise) | -| [updateScene](#updateScene) |
(scene: sceneData) => void 
| updates the scene with the sceneData | -| [addFiles](#addFiles) |
(files: BinaryFileData) => void 
| add files data to the appState | +| [updateScene](#updateScene) | (scene: sceneData) => void | updates the scene with the sceneData | +| [updateLibrary](#updateLibrary) | (opts) => Promise<LibraryItems> | updates the scene with the sceneData | +| [addFiles](#addFiles) | (files: BinaryFileData) => void | add files data to the appState | | resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | -| getSceneElementsIncludingDeleted |
 () => ExcalidrawElement[]
| Returns all the elements including the deleted in the scene | -| getSceneElements |
 () => ExcalidrawElement[]
| Returns all the elements excluding the deleted in the scene | -| getAppState |
 () => AppState
| Returns current appState | +| getSceneElementsIncludingDeleted | () => ExcalidrawElement[] | Returns all the elements including the deleted in the scene | +| getSceneElements | () => ExcalidrawElement[] | Returns all the elements excluding the deleted in the scene | +| getAppState | () => AppState | Returns current appState | | history | `{ clear: () => void }` | This is the history API. `history.clear()` will clear the history | -| scrollToContent |
 (target?: ExcalidrawElement | ExcalidrawElement[]) => void 
| Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene. | +| scrollToContent | (target?: ExcalidrawElement | ExcalidrawElement[]) => void | Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene. | | 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 | | 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. | -| [getFiles](#getFiles) |
() => files 
| This API can be used to get the files present in the scene. It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements. | -| [setActiveTool](#setActiveTool) |
(tool: { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }) => void
| This API can be used to set the active tool | +| [getFiles](#getFiles) | () => files | This API can be used to get the files present in the scene. It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements. | +| [setActiveTool](#setActiveTool) | (tool: { type: typeof SHAPES[number]["value"] | "eraser" } | { type: "custom"; customType: string }) => void | This API can be used to set the active tool | #### `readyPromise` @@ -519,6 +520,28 @@ You can use this function to update the scene with the sceneData. It accepts the | `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | | `libraryItems` | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)> | ((currentItems: [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) => [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200) | Promise<[LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200)>) | The `libraryItems` to be update in the scene. | +### `updateLibrary` + +
+(opts: {
+  libraryItems: LibraryItemsSource;
+  merge?: boolean;
+  prompt?: boolean;
+  openLibraryMenu?: boolean;
+  defaultStatus?: "unpublished" | "published";
+}) => Promise<LibraryItems>
+
+ +You can use this function to update the library. It accepts the below attributes. + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `libraryItems` | | [LibraryItems](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L224) | The `libraryItems` to be replaced/merged with current library | +| `merge` | boolean | `false` | Whether to merge with existing library items. | +| `prompt` | boolean | `false` | Whether to prompt user for confirmation. | +| `openLibraryMenu` | boolean | `false` | Whether to open the library menu before importing. | +| `defaultStatus` | "unpublished" | "published" | `"unpublished"` | Default library item's `status` if not present. | + ### `addFiles`
(files: BinaryFileData) => void 
@@ -1067,12 +1090,10 @@ This function loads the scene data from the blob (or file). If you pass `localAp import { loadSceneOrLibraryFromBlob, MIME_TYPES } from "@excalidraw/excalidraw"; const contents = await loadSceneOrLibraryFromBlob(file, null, null); -// if you need, you can check what data you're dealing with before -// passing down if (contents.type === MIME_TYPES.excalidraw) { excalidrawAPI.updateScene(contents.data); -} else { - excalidrawAPI.updateScene(contents.data); +} else if (contents.type === MIME_TYPES.excalidrawlib) { + excalidrawAPI.updateLibrary(contents.data); } ``` @@ -1151,6 +1172,51 @@ mergeLibraryItems(localItems: +parseLibraryTokensFromUrl(): { + libraryUrl: string; + idToken: string | null; +} | null + + +Parses library parameters from URL if present (expects the `#addLibrary` hash key), and returns an object with the `libraryUrl` and `idToken`. Returns `null` if `#addLibrary` hash key not found. + +#### `useHandleLibrary` + +**How to use** + +```js +import { useHandleLibrary } from "@excalidraw/excalidraw-next"; + +export const App = () => { + // ... + useHandleLibrary({ excalidrawAPI }); +}; +``` + +**Signature** + +
+useHandleLibrary(opts: {
+  excalidrawAPI: ExcalidrawAPI,
+  getInitialLibraryItems?: () => LibraryItemsSource
+});
+
+ +A hook that automatically imports library from url if `#addLibrary` hash key exists on initial load, or when it changes during the editing session (e.g. when a user installs a new library), and handles initial library load if `getInitialLibraryItems` getter is supplied. + +In the future, we will be adding support for handling library persistence to browser storage (or elsewhere). + ### Exported constants #### `FONT_FAMILY` diff --git a/src/packages/excalidraw/example/App.js b/src/packages/excalidraw/example/App.js index ed74bff6..d7e726c9 100644 --- a/src/packages/excalidraw/example/App.js +++ b/src/packages/excalidraw/example/App.js @@ -26,6 +26,7 @@ const { exportToBlob, exportToClipboard, Excalidraw, + useHandleLibrary, MIME_TYPES, } = window.ExcalidrawLib; @@ -63,9 +64,7 @@ const renderTopRightUI = () => { }; export default function App() { - const excalidrawRef = useRef(null); const appRef = useRef(null); - const [viewModeEnabled, setViewModeEnabled] = useState(false); const [zenModeEnabled, setZenModeEnabled] = useState(false); const [gridModeEnabled, setGridModeEnabled] = useState(false); @@ -82,7 +81,15 @@ export default function App() { if (!initialStatePromiseRef.current.promise) { initialStatePromiseRef.current.promise = resolvablePromise(); } + + const [excalidrawAPI, setExcalidrawAPI] = useState(null); + + useHandleLibrary({ excalidrawAPI }); + useEffect(() => { + if (!excalidrawAPI) { + return; + } const fetchData = async () => { const res = await fetch("/rocket.jpeg"); const imageData = await res.blob(); @@ -100,23 +107,12 @@ export default function App() { ]; initialStatePromiseRef.current.promise.resolve(InitialData); - excalidrawRef.current.addFiles(imagesArray); + excalidrawAPI.addFiles(imagesArray); }; }; fetchData(); + }, [excalidrawAPI]); - const onHashChange = () => { - const hash = new URLSearchParams(window.location.hash.slice(1)); - const libraryUrl = hash.get("addLibrary"); - if (libraryUrl) { - excalidrawRef.current.importLibrary(libraryUrl, hash.get("token")); - } - }; - window.addEventListener("hashchange", onHashChange, false); - return () => { - window.removeEventListener("hashchange", onHashChange); - }; - }, []); const renderFooter = () => { return ( <> @@ -124,7 +120,7 @@ export default function App() { +
+
x: {pointerData?.pointer.x ?? 0}
+
y: {pointerData?.pointer.y ?? 0}
+
setExcalidrawAPI(api)} initialData={initialStatePromiseRef.current.promise} onChange={(elements, state) => { console.info("Elements :", elements, "State : ", state); }} - onPointerUpdate={(payload) => console.info(payload)} + onPointerUpdate={(payload) => setPointerData(payload)} onCollabButtonClick={() => window.alert("You clicked on collab button") } @@ -571,7 +590,7 @@ export default function App() {