@ -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 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 : {
/ * *
* Priority 1 indicates we ' re loading latest data with intent
* to reconcile with before save .
* Priority 2 indicates we ' re loading for read - only purposes , so
* host app can implement more aggressive caching strategy .
* /
priority : 1 | 2 ;
} ) : 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" ;
/ * * i n d i c a t e s w h e t h e r l i b r a r y i s i n i t i a l i z e d w i t h l i b r a r y i t e m s ( h a s g o n e
* 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 : fals e, 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 = [ ] ;
/ * * i n d i c a t e s w h e t h e r l i b r a r y i s i n i t i a l i z e d w i t h l i b r a r y i t e m s ( h a s g o n e
* 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. last LibraryItems,
isInitialized : thi s.isInitialized,
} ) ;
libraryItems : this. curr LibraryItems,
isInitialized : s.isInitialized,
} ) ) ;
} else {
this . isInitialized = true ;
jotaiStore . set ( libraryItemsAtom , {
status : "loaded" ,
libraryItems : this. last LibraryItems,
isInitialized : t his.isInitialized ,
libraryItems : this. curr LibraryItems,
isInitialized : t rue ,
} ) ;
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 . last LibraryItems = [ ] ;
this . curr LibraryItems = [ ] ;
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 . last LibraryItems) ;
this . curr LibraryItems) ;
if ( this . updateQueue . length > 0 ) {
resolve ( this . getLatestLibrary ( ) ) ;
} else {
resolve ( cloneLibraryItems ( libraryItems ) ) ;
}
} catch ( error ) {
return resolve ( this . last LibraryItems) ;
return resolve ( this . curr LibraryItems) ;
}
} ) ;
} ;
@ -181,7 +281,7 @@ class Library {
try {
const source = await ( typeof libraryItems === "function" &&
! ( libraryItems instanceof Blob )
? libraryItems ( this . last LibraryItems)
? libraryItems ( this . curr LibraryItems)
: libraryItems ) ;
let nextItems ;
@ -207,7 +307,7 @@ class Library {
}
if ( merge ) {
resolve ( mergeLibraryItems ( this . last LibraryItems, nextItems ) ) ;
resolve ( mergeLibraryItems ( this . curr LibraryItems, nextItems ) ) ;
} else {
resolve ( nextItems ) ;
}
@ -244,12 +344,12 @@ class Library {
await this . getLastUpdateTask ( ) ;
if ( typeof libraryItems === "function" ) {
libraryItems = libraryItems ( this . last LibraryItems) ;
libraryItems = libraryItems ( this . curr LibraryItems) ;
}
this . last LibraryItems = cloneLibraryItems ( await libraryItems ) ;
this . curr LibraryItems = cloneLibraryItems ( await libraryItems ) ;
resolve ( this . last LibraryItems) ;
resolve ( this . curr LibraryItems) ;
} 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 . last LibraryItems;
return this . curr LibraryItems;
}
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 ,
priority : 1 | 2 ,
_queue = true ,
) : Promise < LibraryItems > {
const task = ( ) = >
new Promise < LibraryItems > ( async ( resolve , reject ) = > {
try {
const data = await adapter . load ( { priority } ) ;
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 ( priority : 1 | 2 ) {
return AdapterTransaction . getLibraryItems ( this . adapter , priority , 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 ( 1 ) ,
) ;
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 ) = > {
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 , 2 ) ;
}
// 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 (
[ ] ,
restoreLibraryItems (
libraryData . libraryItems || [ ] ,
"published" ,
) ,
) ,
) ;
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 empty library
return [ ] ;
}
} )
// 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 , 2 ) ;
} ) ,
) ;
} else {
initDataPromise . resolve (
promiseTry ( AdapterTransaction . getLibraryItems , adapter , 2 ) ,
) ;
}
// 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
] ,
) ;
} ;