@excalidraw/excalidraw 0.17.1-a38e82f → 0.17.1-b7babe5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/dist/browser/dev/excalidraw-assets-dev/{chunk-IM4WTX2M.js → chunk-6NMK7JTV.js} +2 -1
  3. package/dist/browser/dev/excalidraw-assets-dev/chunk-6NMK7JTV.js.map +7 -0
  4. package/dist/browser/dev/excalidraw-assets-dev/{chunk-5VWQDKDR.js → chunk-CX3RATXT.js} +50 -5
  5. package/dist/browser/dev/excalidraw-assets-dev/chunk-CX3RATXT.js.map +7 -0
  6. package/dist/browser/dev/excalidraw-assets-dev/{en-IOBA4CS2.js → en-BZY7JRTM.js} +2 -2
  7. package/dist/browser/dev/excalidraw-assets-dev/{image-VKDAL6BQ.js → image-CVN3YKRW.js} +2 -2
  8. package/dist/browser/dev/index.js +332 -76
  9. package/dist/browser/dev/index.js.map +4 -4
  10. package/dist/browser/prod/excalidraw-assets/{chunk-N2C5DK3B.js → chunk-VJAIK3AX.js} +15 -15
  11. package/dist/browser/prod/excalidraw-assets/{chunk-LIG3S5TN.js → chunk-YYO5DFUW.js} +3 -3
  12. package/dist/browser/prod/excalidraw-assets/{en-WFZVQ7I6.js → en-O2YCQM2W.js} +1 -1
  13. package/dist/browser/prod/excalidraw-assets/image-6FKY54X5.js +1 -0
  14. package/dist/browser/prod/index.js +16 -16
  15. package/dist/{prod/en-TDNWCAOT.json → dev/en-EY7E2L5O.json} +1 -0
  16. package/dist/dev/index.js +372 -77
  17. package/dist/dev/index.js.map +3 -3
  18. package/dist/excalidraw/data/library.d.ts +60 -8
  19. package/dist/excalidraw/data/library.js +302 -33
  20. package/dist/excalidraw/element/index.d.ts +8 -0
  21. package/dist/excalidraw/element/index.js +23 -0
  22. package/dist/excalidraw/element/textElement.d.ts +16 -1
  23. package/dist/excalidraw/element/textElement.js +10 -3
  24. package/dist/excalidraw/index.d.ts +2 -2
  25. package/dist/excalidraw/index.js +2 -2
  26. package/dist/excalidraw/locales/en.json +1 -0
  27. package/dist/excalidraw/queue.d.ts +9 -0
  28. package/dist/excalidraw/queue.js +27 -0
  29. package/dist/excalidraw/types.d.ts +6 -6
  30. package/dist/excalidraw/utility-types.d.ts +2 -0
  31. package/dist/excalidraw/utils.d.ts +3 -1
  32. package/dist/excalidraw/utils.js +6 -0
  33. package/dist/{dev/en-TDNWCAOT.json → prod/en-EY7E2L5O.json} +1 -0
  34. package/dist/prod/index.js +26 -26
  35. package/package.json +1 -1
  36. package/dist/browser/dev/excalidraw-assets-dev/chunk-5VWQDKDR.js.map +0 -7
  37. package/dist/browser/dev/excalidraw-assets-dev/chunk-IM4WTX2M.js.map +0 -7
  38. package/dist/browser/prod/excalidraw-assets/image-4AT7LYMR.js +0 -1
  39. /package/dist/browser/dev/excalidraw-assets-dev/{en-IOBA4CS2.js.map → en-BZY7JRTM.js.map} +0 -0
  40. /package/dist/browser/dev/excalidraw-assets-dev/{image-VKDAL6BQ.js.map → image-CVN3YKRW.js.map} +0 -0
@@ -1,13 +1,53 @@
1
- import { LibraryItems, ExcalidrawImperativeAPI, LibraryItemsSource } from "../types";
1
+ import { LibraryItems, ExcalidrawImperativeAPI, LibraryItemsSource, LibraryItems_anyVersion } from "../types";
2
2
  import type App from "../components/App";
3
3
  import { ExcalidrawElement } from "../element/types";
4
+ import { MaybePromise } from "../utility-types";
5
+ export type LibraryPersistedData = {
6
+ libraryItems: LibraryItems;
7
+ };
8
+ export type LibraryAdatapterSource = "load" | "save";
9
+ export interface LibraryPersistenceAdapter {
10
+ /**
11
+ * Should load data that were previously saved into the database using the
12
+ * `save` method. Should throw if saving fails.
13
+ *
14
+ * Will be used internally in multiple places, such as during save to
15
+ * in order to reconcile changes with latest store data.
16
+ */
17
+ load(metadata: {
18
+ /**
19
+ * Indicates whether we're loading data for save purposes, or reading
20
+ * purposes, in which case host app can implement more aggressive caching.
21
+ */
22
+ source: LibraryAdatapterSource;
23
+ }): MaybePromise<{
24
+ libraryItems: LibraryItems_anyVersion;
25
+ } | null>;
26
+ /** Should persist to the database as is (do no change the data structure). */
27
+ save(libraryData: LibraryPersistedData): MaybePromise<void>;
28
+ }
29
+ export interface LibraryMigrationAdapter {
30
+ /**
31
+ * loads data from legacy data source. Returns `null` if no data is
32
+ * to be migrated.
33
+ */
34
+ load(): MaybePromise<{
35
+ libraryItems: LibraryItems_anyVersion;
36
+ } | null>;
37
+ /** clears entire storage afterwards */
38
+ clear(): MaybePromise<void>;
39
+ }
4
40
  export declare const libraryItemsAtom: import("jotai").PrimitiveAtom<{
5
41
  status: "loading" | "loaded";
42
+ /** indicates whether library is initialized with library items (has gone
43
+ * through at least one update). Used in UI. Specific to this atom only. */
6
44
  isInitialized: boolean;
7
45
  libraryItems: LibraryItems;
8
46
  }> & {
9
47
  init: {
10
48
  status: "loading" | "loaded";
49
+ /** indicates whether library is initialized with library items (has gone
50
+ * through at least one update). Used in UI. Specific to this atom only. */
11
51
  isInitialized: boolean;
12
52
  libraryItems: LibraryItems;
13
53
  };
@@ -17,10 +57,9 @@ export declare const libraryItemsAtom: import("jotai").PrimitiveAtom<{
17
57
  export declare const mergeLibraryItems: (localItems: LibraryItems, otherItems: LibraryItems) => LibraryItems;
18
58
  declare class Library {
19
59
  /** latest libraryItems */
20
- private lastLibraryItems;
21
- /** indicates whether library is initialized with library items (has gone
22
- * though at least one update) */
23
- private isInitialized;
60
+ private currLibraryItems;
61
+ /** snapshot of library items since last onLibraryChange call */
62
+ private prevLibraryItems;
24
63
  private app;
25
64
  constructor(app: App);
26
65
  private updateQueue;
@@ -48,7 +87,20 @@ export declare const parseLibraryTokensFromUrl: () => {
48
87
  libraryUrl: string;
49
88
  idToken: string | null;
50
89
  } | null;
51
- export declare const useHandleLibrary: ({ excalidrawAPI, getInitialLibraryItems, }: {
90
+ export declare const getLibraryItemsHash: (items: LibraryItems) => number;
91
+ export declare const useHandleLibrary: (opts: {
52
92
  excalidrawAPI: ExcalidrawImperativeAPI | null;
53
- getInitialLibraryItems?: (() => LibraryItemsSource) | undefined;
54
- }) => void;
93
+ } & ({
94
+ /** @deprecated we recommend using `opts.adapter` instead */
95
+ getInitialLibraryItems?: () => MaybePromise<LibraryItemsSource>;
96
+ } | {
97
+ adapter: LibraryPersistenceAdapter;
98
+ /**
99
+ * Adapter that takes care of loading data from legacy data store.
100
+ * Supply this if you want to migrate data on initial load from legacy
101
+ * data store.
102
+ *
103
+ * Can be a different LibraryPersistenceAdapter.
104
+ */
105
+ migrationAdapter?: LibraryMigrationAdapter;
106
+ })) => void;
@@ -8,8 +8,12 @@ import { t } from "../i18n";
8
8
  import { useEffect, useRef } from "react";
9
9
  import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB, } from "../constants";
10
10
  import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg";
11
- import { cloneJSON } from "../utils";
12
- export const libraryItemsAtom = atom({ status: "loaded", isInitialized: true, libraryItems: [] });
11
+ import { arrayToMap, cloneJSON, preventUnload, promiseTry, resolvablePromise, } from "../utils";
12
+ import { Emitter } from "../emitter";
13
+ import { Queue } from "../queue";
14
+ import { hashElementsVersion, hashString } from "../element";
15
+ const onLibraryUpdateEmitter = new Emitter();
16
+ export const libraryItemsAtom = atom({ status: "loaded", isInitialized: false, libraryItems: [] });
13
17
  const cloneLibraryItems = (libraryItems) => cloneJSON(libraryItems);
14
18
  /**
15
19
  * checks if library item does not exist already in current library
@@ -39,12 +43,36 @@ export const mergeLibraryItems = (localItems, otherItems) => {
39
43
  }
40
44
  return [...newItems, ...localItems];
41
45
  };
46
+ /**
47
+ * Returns { deletedItems, addedItems } maps of all added and deleted items
48
+ * since last onLibraryChange event.
49
+ *
50
+ * Host apps are recommended to diff with the latest state they have.
51
+ */
52
+ const createLibraryUpdate = (prevLibraryItems, nextLibraryItems) => {
53
+ const nextItemsMap = arrayToMap(nextLibraryItems);
54
+ const update = {
55
+ deletedItems: new Map(),
56
+ addedItems: new Map(),
57
+ };
58
+ for (const item of prevLibraryItems) {
59
+ if (!nextItemsMap.has(item.id)) {
60
+ update.deletedItems.set(item.id, item);
61
+ }
62
+ }
63
+ const prevItemsMap = arrayToMap(prevLibraryItems);
64
+ for (const item of nextLibraryItems) {
65
+ if (!prevItemsMap.has(item.id)) {
66
+ update.addedItems.set(item.id, item);
67
+ }
68
+ }
69
+ return update;
70
+ };
42
71
  class Library {
43
72
  /** latest libraryItems */
44
- lastLibraryItems = [];
45
- /** indicates whether library is initialized with library items (has gone
46
- * though at least one update) */
47
- isInitialized = false;
73
+ currLibraryItems = [];
74
+ /** snapshot of library items since last onLibraryChange call */
75
+ prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
48
76
  app;
49
77
  constructor(app) {
50
78
  this.app = app;
@@ -55,21 +83,25 @@ class Library {
55
83
  };
56
84
  notifyListeners = () => {
57
85
  if (this.updateQueue.length > 0) {
58
- jotaiStore.set(libraryItemsAtom, {
86
+ jotaiStore.set(libraryItemsAtom, (s) => ({
59
87
  status: "loading",
60
- libraryItems: this.lastLibraryItems,
61
- isInitialized: this.isInitialized,
62
- });
88
+ libraryItems: this.currLibraryItems,
89
+ isInitialized: s.isInitialized,
90
+ }));
63
91
  }
64
92
  else {
65
- this.isInitialized = true;
66
93
  jotaiStore.set(libraryItemsAtom, {
67
94
  status: "loaded",
68
- libraryItems: this.lastLibraryItems,
69
- isInitialized: this.isInitialized,
95
+ libraryItems: this.currLibraryItems,
96
+ isInitialized: true,
70
97
  });
71
98
  try {
72
- this.app.props.onLibraryChange?.(cloneLibraryItems(this.lastLibraryItems));
99
+ const prevLibraryItems = this.prevLibraryItems;
100
+ this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
101
+ const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
102
+ this.app.props.onLibraryChange?.(nextLibraryItems);
103
+ // for internal use in `useHandleLibrary` hook
104
+ onLibraryUpdateEmitter.trigger(createLibraryUpdate(prevLibraryItems, nextLibraryItems), nextLibraryItems);
73
105
  }
74
106
  catch (error) {
75
107
  console.error(error);
@@ -78,9 +110,8 @@ class Library {
78
110
  };
79
111
  /** call on excalidraw instance unmount */
80
112
  destroy = () => {
81
- this.isInitialized = false;
82
113
  this.updateQueue = [];
83
- this.lastLibraryItems = [];
114
+ this.currLibraryItems = [];
84
115
  jotaiStore.set(libraryItemSvgsCache, new Map());
85
116
  // TODO uncomment after/if we make jotai store scoped to each excal instance
86
117
  // jotaiStore.set(libraryItemsAtom, {
@@ -99,7 +130,7 @@ class Library {
99
130
  return new Promise(async (resolve) => {
100
131
  try {
101
132
  const libraryItems = await (this.getLastUpdateTask() ||
102
- this.lastLibraryItems);
133
+ this.currLibraryItems);
103
134
  if (this.updateQueue.length > 0) {
104
135
  resolve(this.getLatestLibrary());
105
136
  }
@@ -108,7 +139,7 @@ class Library {
108
139
  }
109
140
  }
110
141
  catch (error) {
111
- return resolve(this.lastLibraryItems);
142
+ return resolve(this.currLibraryItems);
112
143
  }
113
144
  });
114
145
  };
@@ -126,7 +157,7 @@ class Library {
126
157
  try {
127
158
  const source = await (typeof libraryItems === "function" &&
128
159
  !(libraryItems instanceof Blob)
129
- ? libraryItems(this.lastLibraryItems)
160
+ ? libraryItems(this.currLibraryItems)
130
161
  : libraryItems);
131
162
  let nextItems;
132
163
  if (source instanceof Blob) {
@@ -146,7 +177,7 @@ class Library {
146
177
  this.app.focusContainer();
147
178
  }
148
179
  if (merge) {
149
- resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
180
+ resolve(mergeLibraryItems(this.currLibraryItems, nextItems));
150
181
  }
151
182
  else {
152
183
  resolve(nextItems);
@@ -178,10 +209,10 @@ class Library {
178
209
  try {
179
210
  await this.getLastUpdateTask();
180
211
  if (typeof libraryItems === "function") {
181
- libraryItems = libraryItems(this.lastLibraryItems);
212
+ libraryItems = libraryItems(this.currLibraryItems);
182
213
  }
183
- this.lastLibraryItems = cloneLibraryItems(await libraryItems);
184
- resolve(this.lastLibraryItems);
214
+ this.currLibraryItems = cloneLibraryItems(await libraryItems);
215
+ resolve(this.currLibraryItems);
185
216
  }
186
217
  catch (error) {
187
218
  reject(error);
@@ -190,7 +221,7 @@ class Library {
190
221
  .catch((error) => {
191
222
  if (error.name === "AbortError") {
192
223
  console.warn("Library update aborted by user");
193
- return this.lastLibraryItems;
224
+ return this.currLibraryItems;
194
225
  }
195
226
  throw error;
196
227
  })
@@ -291,12 +322,105 @@ export const parseLibraryTokensFromUrl = () => {
291
322
  : null;
292
323
  return libraryUrl ? { libraryUrl, idToken } : null;
293
324
  };
294
- export const useHandleLibrary = ({ excalidrawAPI, getInitialLibraryItems, }) => {
295
- const getInitialLibraryRef = useRef(getInitialLibraryItems);
325
+ class AdapterTransaction {
326
+ static queue = new Queue();
327
+ static async getLibraryItems(adapter, source, _queue = true) {
328
+ const task = () => new Promise(async (resolve, reject) => {
329
+ try {
330
+ const data = await adapter.load({ source });
331
+ resolve(restoreLibraryItems(data?.libraryItems || [], "published"));
332
+ }
333
+ catch (error) {
334
+ reject(error);
335
+ }
336
+ });
337
+ if (_queue) {
338
+ return AdapterTransaction.queue.push(task);
339
+ }
340
+ return task();
341
+ }
342
+ static run = async (adapter, fn) => {
343
+ const transaction = new AdapterTransaction(adapter);
344
+ return AdapterTransaction.queue.push(() => fn(transaction));
345
+ };
346
+ // ------------------
347
+ adapter;
348
+ constructor(adapter) {
349
+ this.adapter = adapter;
350
+ }
351
+ getLibraryItems(source) {
352
+ return AdapterTransaction.getLibraryItems(this.adapter, source, false);
353
+ }
354
+ }
355
+ let lastSavedLibraryItemsHash = 0;
356
+ let librarySaveCounter = 0;
357
+ export const getLibraryItemsHash = (items) => {
358
+ return hashString(items
359
+ .map((item) => {
360
+ return `${item.id}:${hashElementsVersion(item.elements)}`;
361
+ })
362
+ .sort()
363
+ .join());
364
+ };
365
+ const persistLibraryUpdate = async (adapter, update) => {
366
+ try {
367
+ librarySaveCounter++;
368
+ return await AdapterTransaction.run(adapter, async (transaction) => {
369
+ const nextLibraryItemsMap = arrayToMap(await transaction.getLibraryItems("save"));
370
+ for (const [id] of update.deletedItems) {
371
+ nextLibraryItemsMap.delete(id);
372
+ }
373
+ const addedItems = [];
374
+ // we want to merge current library items with the ones stored in the
375
+ // DB so that we don't lose any elements that for some reason aren't
376
+ // in the current editor library, which could happen when:
377
+ //
378
+ // 1. we haven't received an update deleting some elements
379
+ // (in which case it's still better to keep them in the DB lest
380
+ // it was due to a different reason)
381
+ // 2. we keep a single DB for all active editors, but the editors'
382
+ // libraries aren't synced or there's a race conditions during
383
+ // syncing
384
+ // 3. some other race condition, e.g. during init where emit updates
385
+ // for partial updates (e.g. you install a 3rd party library and
386
+ // init from DB only after — we emit events for both updates)
387
+ for (const [id, item] of update.addedItems) {
388
+ if (nextLibraryItemsMap.has(id)) {
389
+ // replace item with latest version
390
+ // TODO we could prefer the newer item instead
391
+ nextLibraryItemsMap.set(id, item);
392
+ }
393
+ else {
394
+ // we want to prepend the new items with the ones that are already
395
+ // in DB to preserve the ordering we do in editor (newly added
396
+ // items are added to the beginning)
397
+ addedItems.push(item);
398
+ }
399
+ }
400
+ const nextLibraryItems = addedItems.concat(Array.from(nextLibraryItemsMap.values()));
401
+ const version = getLibraryItemsHash(nextLibraryItems);
402
+ if (version !== lastSavedLibraryItemsHash) {
403
+ await adapter.save({ libraryItems: nextLibraryItems });
404
+ }
405
+ lastSavedLibraryItemsHash = version;
406
+ return nextLibraryItems;
407
+ });
408
+ }
409
+ finally {
410
+ librarySaveCounter--;
411
+ }
412
+ };
413
+ export const useHandleLibrary = (opts) => {
414
+ const { excalidrawAPI } = opts;
415
+ const optsRef = useRef(opts);
416
+ optsRef.current = opts;
417
+ const isLibraryLoadedRef = useRef(false);
296
418
  useEffect(() => {
297
419
  if (!excalidrawAPI) {
298
420
  return;
299
421
  }
422
+ // reset on editor remount (excalidrawAPI changed)
423
+ isLibraryLoadedRef.current = false;
300
424
  const importLibraryFromURL = async ({ libraryUrl, idToken, }) => {
301
425
  const libraryPromise = new Promise(async (resolve, reject) => {
302
426
  try {
@@ -357,20 +481,165 @@ export const useHandleLibrary = ({ excalidrawAPI, getInitialLibraryItems, }) =>
357
481
  }
358
482
  };
359
483
  // -------------------------------------------------------------------------
360
- // ------ init load --------------------------------------------------------
361
- if (getInitialLibraryRef.current) {
362
- excalidrawAPI.updateLibrary({
363
- libraryItems: getInitialLibraryRef.current(),
364
- });
365
- }
484
+ // ---------------------------------- init ---------------------------------
485
+ // -------------------------------------------------------------------------
366
486
  const libraryUrlTokens = parseLibraryTokensFromUrl();
367
487
  if (libraryUrlTokens) {
368
488
  importLibraryFromURL(libraryUrlTokens);
369
489
  }
490
+ // ------ (A) init load (legacy) -------------------------------------------
491
+ if ("getInitialLibraryItems" in optsRef.current &&
492
+ optsRef.current.getInitialLibraryItems) {
493
+ console.warn("useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.");
494
+ Promise.resolve(optsRef.current.getInitialLibraryItems())
495
+ .then((libraryItems) => {
496
+ excalidrawAPI.updateLibrary({
497
+ libraryItems,
498
+ // merge with current library items because we may have already
499
+ // populated it (e.g. by installing 3rd party library which can
500
+ // happen before the DB data is loaded)
501
+ merge: true,
502
+ });
503
+ })
504
+ .catch((error) => {
505
+ console.error(`UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`);
506
+ });
507
+ }
508
+ // -------------------------------------------------------------------------
370
509
  // --------------------------------------------------------- init load -----
510
+ // -------------------------------------------------------------------------
511
+ // ------ (B) data source adapter ------------------------------------------
512
+ if ("adapter" in optsRef.current && optsRef.current.adapter) {
513
+ const adapter = optsRef.current.adapter;
514
+ const migrationAdapter = optsRef.current.migrationAdapter;
515
+ const initDataPromise = resolvablePromise();
516
+ // migrate from old data source if needed
517
+ // (note, if `migrate` function is defined, we always migrate even
518
+ // if the data has already been migrated. In that case it'll be a no-op,
519
+ // though with several unnecessary steps — we will still load latest
520
+ // DB data during the `persistLibraryChange()` step)
521
+ // -----------------------------------------------------------------------
522
+ if (migrationAdapter) {
523
+ initDataPromise.resolve(promiseTry(migrationAdapter.load)
524
+ .then(async (libraryData) => {
525
+ let restoredData = null;
526
+ try {
527
+ // if no library data to migrate, assume no migration needed
528
+ // and skip persisting to new data store, as well as well
529
+ // clearing the old store via `migrationAdapter.clear()`
530
+ if (!libraryData) {
531
+ return AdapterTransaction.getLibraryItems(adapter, "load");
532
+ }
533
+ restoredData = restoreLibraryItems(libraryData.libraryItems || [], "published");
534
+ // we don't queue this operation because it's running inside
535
+ // a promise that's running inside Library update queue itself
536
+ const nextItems = await persistLibraryUpdate(adapter, createLibraryUpdate([], restoredData));
537
+ try {
538
+ await migrationAdapter.clear();
539
+ }
540
+ catch (error) {
541
+ console.error(`couldn't delete legacy library data: ${error.message}`);
542
+ }
543
+ // migration suceeded, load migrated data
544
+ return nextItems;
545
+ }
546
+ catch (error) {
547
+ console.error(`couldn't migrate legacy library data: ${error.message}`);
548
+ // migration failed, load data from previous store, if any
549
+ return restoredData;
550
+ }
551
+ })
552
+ // errors caught during `migrationAdapter.load()`
553
+ .catch((error) => {
554
+ console.error(`error during library migration: ${error.message}`);
555
+ // as a default, load latest library from current data source
556
+ return AdapterTransaction.getLibraryItems(adapter, "load");
557
+ }));
558
+ }
559
+ else {
560
+ initDataPromise.resolve(promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"));
561
+ }
562
+ // load initial (or migrated) library
563
+ excalidrawAPI
564
+ .updateLibrary({
565
+ libraryItems: initDataPromise.then((libraryItems) => {
566
+ const _libraryItems = libraryItems || [];
567
+ lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
568
+ return _libraryItems;
569
+ }),
570
+ // merge with current library items because we may have already
571
+ // populated it (e.g. by installing 3rd party library which can
572
+ // happen before the DB data is loaded)
573
+ merge: true,
574
+ })
575
+ .finally(() => {
576
+ isLibraryLoadedRef.current = true;
577
+ });
578
+ }
579
+ // ---------------------------------------------- data source datapter -----
371
580
  window.addEventListener(EVENT.HASHCHANGE, onHashChange);
372
581
  return () => {
373
582
  window.removeEventListener(EVENT.HASHCHANGE, onHashChange);
374
583
  };
375
- }, [excalidrawAPI]);
584
+ }, [
585
+ // important this useEffect only depends on excalidrawAPI so it only reruns
586
+ // on editor remounts (the excalidrawAPI changes)
587
+ excalidrawAPI,
588
+ ]);
589
+ // This effect is run without excalidrawAPI dependency so that host apps
590
+ // can run this hook outside of an active editor instance and the library
591
+ // update queue/loop survives editor remounts
592
+ //
593
+ // This effect is still only meant to be run if host apps supply an persitence
594
+ // adapter. If we don't have access to it, it the update listener doesn't
595
+ // do anything.
596
+ useEffect(() => {
597
+ // on update, merge with current library items and persist
598
+ // -----------------------------------------------------------------------
599
+ const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on(async (update, nextLibraryItems) => {
600
+ const isLoaded = isLibraryLoadedRef.current;
601
+ // we want to operate with the latest adapter, but we don't want this
602
+ // effect to rerun on every adapter change in case host apps' adapter
603
+ // isn't stable
604
+ const adapter = ("adapter" in optsRef.current && optsRef.current.adapter) || null;
605
+ try {
606
+ if (adapter) {
607
+ if (
608
+ // if nextLibraryItems hash identical to previously saved hash,
609
+ // exit early, even if actual upstream state ends up being
610
+ // different (e.g. has more data than we have locally), as it'd
611
+ // be low-impact scenario.
612
+ lastSavedLibraryItemsHash !==
613
+ getLibraryItemsHash(nextLibraryItems)) {
614
+ await persistLibraryUpdate(adapter, update);
615
+ }
616
+ }
617
+ }
618
+ catch (error) {
619
+ console.error(`couldn't persist library update: ${error.message}`, update);
620
+ // currently we only show error if an editor is loaded
621
+ if (isLoaded && optsRef.current.excalidrawAPI) {
622
+ optsRef.current.excalidrawAPI.updateScene({
623
+ appState: {
624
+ errorMessage: t("errors.saveLibraryError"),
625
+ },
626
+ });
627
+ }
628
+ }
629
+ });
630
+ const onUnload = (event) => {
631
+ if (librarySaveCounter) {
632
+ preventUnload(event);
633
+ }
634
+ };
635
+ window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload);
636
+ return () => {
637
+ window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
638
+ unsubOnLibraryUpdate();
639
+ lastSavedLibraryItemsHash = 0;
640
+ librarySaveCounter = 0;
641
+ };
642
+ }, [
643
+ // this effect must not have any deps so it doesn't rerun
644
+ ]);
376
645
  };
@@ -10,7 +10,15 @@ export { isTextElement, isExcalidrawElement } from "./typeChecks";
10
10
  export { redrawTextBoundingBox } from "./textElement";
11
11
  export { getPerfectElementSize, getLockedLinearCursorAlignSize, isInvisiblySmallElement, resizePerfectLineForNWHandler, getNormalizedDimensions, } from "./sizeHelpers";
12
12
  export { showSelectedShapeActions } from "./showSelectedShapeActions";
13
+ /**
14
+ * @deprecated unsafe, use hashElementsVersion instead
15
+ */
13
16
  export declare const getSceneVersion: (elements: readonly ExcalidrawElement[]) => number;
17
+ /**
18
+ * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
19
+ */
20
+ export declare const hashElementsVersion: (elements: readonly ExcalidrawElement[]) => number;
21
+ export declare const hashString: (s: string) => number;
14
22
  export declare const getVisibleElements: (elements: readonly ExcalidrawElement[]) => readonly NonDeletedExcalidrawElement[];
15
23
  export declare const getNonDeletedElements: <T extends ExcalidrawElement>(elements: readonly T[]) => readonly NonDeleted<T>[];
16
24
  export declare const isNonDeletedElement: <T extends ExcalidrawElement>(element: T) => element is NonDeleted<T>;
@@ -11,7 +11,30 @@ export { isTextElement, isExcalidrawElement } from "./typeChecks";
11
11
  export { redrawTextBoundingBox } from "./textElement";
12
12
  export { getPerfectElementSize, getLockedLinearCursorAlignSize, isInvisiblySmallElement, resizePerfectLineForNWHandler, getNormalizedDimensions, } from "./sizeHelpers";
13
13
  export { showSelectedShapeActions } from "./showSelectedShapeActions";
14
+ /**
15
+ * @deprecated unsafe, use hashElementsVersion instead
16
+ */
14
17
  export const getSceneVersion = (elements) => elements.reduce((acc, el) => acc + el.version, 0);
18
+ /**
19
+ * Hashes elements' versionNonce (using djb2 algo). Order of elements matters.
20
+ */
21
+ export const hashElementsVersion = (elements) => {
22
+ let hash = 5381;
23
+ for (let i = 0; i < elements.length; i++) {
24
+ hash = (hash << 5) + hash + elements[i].versionNonce;
25
+ }
26
+ return hash >>> 0; // Ensure unsigned 32-bit integer
27
+ };
28
+ // string hash function (using djb2). Not cryptographically secure, use only
29
+ // for versioning and such.
30
+ export const hashString = (s) => {
31
+ let hash = 5381;
32
+ for (let i = 0; i < s.length; i++) {
33
+ const char = s.charCodeAt(i);
34
+ hash = (hash << 5) + hash + char;
35
+ }
36
+ return hash >>> 0; // Ensure unsigned 32-bit integer
37
+ };
15
38
  export const getVisibleElements = (elements) => elements.filter((el) => !el.isDeleted && !isInvisiblySmallElement(el));
16
39
  export const getNonDeletedElements = (elements) => elements.filter((element) => !element.isDeleted);
17
40
  export const isNonDeletedElement = (element) => !element.isDeleted;
@@ -1,7 +1,7 @@
1
1
  import { ElementsMap, ExcalidrawElement, ExcalidrawElementType, ExcalidrawTextContainer, ExcalidrawTextElement, ExcalidrawTextElementWithContainer, FontFamilyValues, FontString, NonDeletedExcalidrawElement } from "./types";
2
2
  import { MaybeTransformHandleType } from "./transformHandles";
3
3
  import { AppState } from "../types";
4
- import { ExtractSetType } from "../utility-types";
4
+ import { ExtractSetType, MakeBrand } from "../utility-types";
5
5
  export declare const normalizeText: (text: string) => string;
6
6
  export declare const redrawTextBoundingBox: (textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, elementsMap: ElementsMap) => void;
7
7
  export declare const bindTextToShapeAfterDuplication: (newElements: ExcalidrawElement[], oldElements: ExcalidrawElement[], oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>) => void;
@@ -70,6 +70,21 @@ export declare const computeContainerDimensionForBoundText: (dimension: number,
70
70
  export declare const getBoundTextMaxWidth: (container: ExcalidrawElement, boundTextElement: ExcalidrawTextElement | null) => number;
71
71
  export declare const getBoundTextMaxHeight: (container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer) => number;
72
72
  export declare const isMeasureTextSupported: () => boolean;
73
+ /** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
74
+ type sTypoAscender = number & MakeBrand<"sTypoAscender">;
75
+ /** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
76
+ type sTypoDescender = number & MakeBrand<"sTypoDescender">;
77
+ /**
78
+ * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
79
+ * For custom fonts, read these metrics from OS/2 table and extend this object.
80
+ *
81
+ * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
82
+ */
83
+ export declare const FONT_METRICS: Record<number, {
84
+ unitsPerEm: number;
85
+ ascender: sTypoAscender;
86
+ descender: sTypoDescender;
87
+ }>;
73
88
  export declare const getDefaultLineHeight: (fontFamily: FontFamilyValues) => number & {
74
89
  _brand: "unitlessLineHeight";
75
90
  };
@@ -203,7 +203,7 @@ export const getLineHeightInPx = (fontSize, lineHeight) => {
203
203
  * Calculates vertical offset for a text with alphabetic baseline.
204
204
  */
205
205
  export const getVerticalOffset = (fontFamily, fontSize, lineHeightPx) => {
206
- const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily];
206
+ const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
207
207
  const fontSizeEm = fontSize / unitsPerEm;
208
208
  const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
209
209
  const verticalOffset = fontSizeEm * ascender + lineGap;
@@ -658,9 +658,11 @@ const DEFAULT_LINE_HEIGHT = {
658
658
  };
659
659
  /**
660
660
  * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
661
- * For custom fonts, read these metrics on load and extend this object.
661
+ * For custom fonts, read these metrics from OS/2 table and extend this object.
662
+ *
663
+ * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
662
664
  */
663
- const FONT_METRICS = {
665
+ export const FONT_METRICS = {
664
666
  [FONT_FAMILY.Virgil]: {
665
667
  unitsPerEm: 1000,
666
668
  ascender: 886,
@@ -676,6 +678,11 @@ const FONT_METRICS = {
676
678
  ascender: 1977,
677
679
  descender: -480,
678
680
  },
681
+ [FONT_FAMILY.Assistant]: {
682
+ unitsPerEm: 1000,
683
+ ascender: 1021,
684
+ descender: -287,
685
+ },
679
686
  };
680
687
  export const getDefaultLineHeight = (fontFamily) => {
681
688
  if (fontFamily in DEFAULT_LINE_HEIGHT) {
@@ -8,14 +8,14 @@ import MainMenu from "./components/main-menu/MainMenu";
8
8
  import WelcomeScreen from "./components/welcome-screen/WelcomeScreen";
9
9
  import LiveCollaborationTrigger from "./components/live-collaboration/LiveCollaborationTrigger";
10
10
  export declare const Excalidraw: React.MemoExoticComponent<(props: ExcalidrawProps) => JSX.Element>;
11
- export { getSceneVersion, isInvisiblySmallElement, getNonDeletedElements, } from "./element";
11
+ export { getSceneVersion, hashElementsVersion, hashString, isInvisiblySmallElement, getNonDeletedElements, } from "./element";
12
12
  export { defaultLang, useI18n, languages } from "./i18n";
13
13
  export { restore, restoreAppState, restoreElements, restoreLibraryItems, } from "./data/restore";
14
14
  export { exportToCanvas, exportToBlob, exportToSvg, exportToClipboard, } from "../utils/export";
15
15
  export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json";
16
16
  export { loadFromBlob, loadSceneOrLibraryFromBlob, loadLibraryFromBlob, } from "./data/blob";
17
17
  export { getFreeDrawSvgPath } from "./renderer/renderElement";
18
- export { mergeLibraryItems } from "./data/library";
18
+ export { mergeLibraryItems, getLibraryItemsHash } from "./data/library";
19
19
  export { isLinearElement } from "./element/typeChecks";
20
20
  export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants";
21
21
  export { mutateElement, newElementWith, bumpVersion, } from "./element/mutateElement";