diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 67536cc4d5..43cfeecb24 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -119,6 +119,7 @@ import { LibraryIndexedDBAdapter, LibraryLocalStorageMigrationAdapter, LocalData, + localStorageQuotaExceededAtom, } from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; @@ -727,6 +728,8 @@ const ExcalidrawWrapper = () => { const isOffline = useAtomValue(isOfflineAtom); + const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom); + const onCollabDialogOpen = useCallback( () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), [setShareDialogState], @@ -901,10 +904,15 @@ const ExcalidrawWrapper = () => { {isCollaborating && isOffline && ( -
+
{t("alerts.collabOfflineWarning")}
)} + {localStorageQuotaExceeded && ( +
+ {t("alerts.localStorageQuotaExceeded")} +
+ )} {latestShareableLink && ( { await entries(filesStore).then((entries) => { @@ -68,6 +72,9 @@ const saveDataStateToLocalStorage = ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { + const localStorageQuotaExceeded = appJotaiStore.get( + localStorageQuotaExceededAtom, + ); try { const _appState = clearAppStateForLocalStorage(appState); @@ -87,12 +94,22 @@ const saveDataStateToLocalStorage = ( JSON.stringify(_appState), ); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); + if (localStorageQuotaExceeded) { + appJotaiStore.set(localStorageQuotaExceededAtom, false); + } } catch (error: any) { // Unable to access window.localStorage console.error(error); + if (isQuotaExceededError(error) && !localStorageQuotaExceeded) { + appJotaiStore.set(localStorageQuotaExceededAtom, true); + } } }; +const isQuotaExceededError = (error: any) => { + return error instanceof DOMException && error.name === "QuotaExceededError"; +}; + type SavingLockTypes = "collaboration"; export class LocalData { diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index cfaaf9cea2..9f320775be 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -58,7 +58,7 @@ } } - .collab-offline-warning { + .alert { pointer-events: none; position: absolute; top: 6.5rem; @@ -69,10 +69,18 @@ text-align: center; line-height: 1.5; border-radius: var(--border-radius-md); - background-color: var(--color-warning); - color: var(--color-text-warning); z-index: 6; white-space: pre; + + &--warning { + background-color: var(--color-warning); + color: var(--color-text-warning); + } + + &--danger { + background-color: var(--color-danger-dark); + color: var(--color-danger-text); + } } } diff --git a/packages/common/src/binary-heap.ts b/packages/common/src/binary-heap.ts index 788a05c223..5abf484998 100644 --- a/packages/common/src/binary-heap.ts +++ b/packages/common/src/binary-heap.ts @@ -5,17 +5,18 @@ export class BinaryHeap { sinkDown(idx: number) { const node = this.content[idx]; + const nodeScore = this.scoreFunction(node); while (idx > 0) { const parentN = ((idx + 1) >> 1) - 1; const parent = this.content[parentN]; - if (this.scoreFunction(node) < this.scoreFunction(parent)) { - this.content[parentN] = node; + if (nodeScore < this.scoreFunction(parent)) { this.content[idx] = parent; idx = parentN; // TODO: Optimize } else { break; } } + this.content[idx] = node; } bubbleUp(idx: number) { @@ -24,35 +25,39 @@ export class BinaryHeap { const score = this.scoreFunction(node); while (true) { - const child2N = (idx + 1) << 1; - const child1N = child2N - 1; - let swap = null; - let child1Score = 0; + const child1N = ((idx + 1) << 1) - 1; + const child2N = child1N + 1; + let smallestIdx = idx; + let smallestScore = score; + // Check left child if (child1N < length) { - const child1 = this.content[child1N]; - child1Score = this.scoreFunction(child1); - if (child1Score < score) { - swap = child1N; + const child1Score = this.scoreFunction(this.content[child1N]); + if (child1Score < smallestScore) { + smallestIdx = child1N; + smallestScore = child1Score; } } + // Check right child if (child2N < length) { - const child2 = this.content[child2N]; - const child2Score = this.scoreFunction(child2); - if (child2Score < (swap === null ? score : child1Score)) { - swap = child2N; + const child2Score = this.scoreFunction(this.content[child2N]); + if (child2Score < smallestScore) { + smallestIdx = child2N; } } - if (swap !== null) { - this.content[idx] = this.content[swap]; - this.content[swap] = node; - idx = swap; // TODO: Optimize - } else { + if (smallestIdx === idx) { break; } + + // Move the smaller child up, continue finding position for node + this.content[idx] = this.content[smallestIdx]; + idx = smallestIdx; } + + // Place node in its final position + this.content[idx] = node; } push(node: T) { diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index b8427a2923..a365c517de 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -28,6 +28,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => { // string hash function (using djb2). Not cryptographically secure, use only // for versioning and such. +// note: hashes individual code units (not code points), +// but for hashing purposes this is fine as it iterates through every code unit +// (as such, no need to encode to byte string first) export const hashString = (s: string): number => { let hash: number = 5381; for (let i = 0; i < s.length; i++) { diff --git a/packages/excalidraw/components/InlineIcon.tsx b/packages/excalidraw/components/InlineIcon.tsx index f2fe9d5252..04c04fb688 100644 --- a/packages/excalidraw/components/InlineIcon.tsx +++ b/packages/excalidraw/components/InlineIcon.tsx @@ -12,8 +12,9 @@ export const InlineIcon = ({ className={className} style={{ width: size, + height: "100%", margin: "0 0.5ex 0 0.5ex", - display: "inline-block", + display: "inline-flex", lineHeight: 0, verticalAlign: "middle", flex: "0 0 auto", diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 0aa6071aa0..9a4f29f179 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -281,19 +281,29 @@ export const LibraryMenu = memo(() => { if (target.closest(`.${CLASSES.SIDEBAR}`)) { // stop propagation so that we don't prevent it downstream // (default browser behavior is to clear search input on ESC) - event.stopPropagation(); if (selectedItems.length > 0) { + event.stopPropagation(); setSelectedItems([]); } else if ( isWritableElement(target) && target instanceof HTMLInputElement && !target.value ) { + event.stopPropagation(); // if search input empty -> close library // (maybe not a good idea?) setAppState({ openSidebar: null }); app.focusContainer(); } + } else if (selectedItems.length > 0) { + const { x, y } = app.lastViewportPosition; + const elementUnderCursor = document.elementFromPoint(x, y); + // also deselect elements if sidebar doesn't have focus but the + // cursor is over it + if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) { + event.stopPropagation(); + setSelectedItems([]); + } } } }, diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index 3a78bbec4e..c64351b1b3 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -34,6 +34,8 @@ import { TextField } from "./TextField"; import { useDevice } from "./App"; +import { Button } from "./Button"; + import type { ExcalidrawLibraryIds } from "../data/types"; import type { @@ -136,10 +138,13 @@ export default function LibraryMenuItems({ } const selectedItemsMap = arrayToMap(selectedItems); + // Support both top-down and bottom-up selection by using min/max + const minRange = Math.min(rangeStart, rangeEnd); + const maxRange = Math.max(rangeStart, rangeEnd); const nextSelectedIds = orderedItems.reduce( (acc: LibraryItem["id"][], item, idx) => { if ( - (idx >= rangeStart && idx <= rangeEnd) || + (idx >= minRange && idx <= maxRange) || selectedItemsMap.has(item.id) ) { acc.push(item.id); @@ -167,6 +172,14 @@ export default function LibraryMenuItems({ ], ); + useEffect(() => { + // if selection is removed (e.g. via esc), reset last selected item + // so that subsequent shift+clicks don't select a large range + if (!selectedItems.length) { + setLastSelectedItem(null); + } + }, [selectedItems]); + const getInsertedElements = useCallback( (id: string) => { let targetElements; @@ -319,7 +332,14 @@ export default function LibraryMenuItems({
{t("library.search.heading")} {!isLoading && ( -
+
e.preventDefault()} + onClick={(event) => { + setSearchInputValue(""); + }} + > esc to clear
)} @@ -341,6 +361,15 @@ export default function LibraryMenuItems({
{t("library.search.noResults")}
+
)} diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index 076b303d70..cdc038dac3 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -518,7 +518,7 @@ const PublishLibrary = ({
diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 429ba1046c..abe2fec853 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -62,6 +62,7 @@ type LibraryUpdate = { deletedItems: Map; /** newly added items in the library */ addedItems: Map; + updatedItems: Map; }; // an object so that we can later add more properties to it without breaking, @@ -170,6 +171,7 @@ const createLibraryUpdate = ( const update: LibraryUpdate = { deletedItems: new Map(), addedItems: new Map(), + updatedItems: new Map(), }; for (const item of prevLibraryItems) { @@ -181,8 +183,11 @@ const createLibraryUpdate = ( const prevItemsMap = arrayToMap(prevLibraryItems); for (const item of nextLibraryItems) { - if (!prevItemsMap.has(item.id)) { + const prevItem = prevItemsMap.get(item.id); + if (!prevItem) { update.addedItems.set(item.id, item); + } else if (getLibraryItemHash(prevItem) !== getLibraryItemHash(item)) { + update.updatedItems.set(item.id, item); } } @@ -586,12 +591,14 @@ class AdapterTransaction { let lastSavedLibraryItemsHash = 0; let librarySaveCounter = 0; +const getLibraryItemHash = (item: LibraryItem) => { + return `${item.id}:${item.name || ""}:${hashElementsVersion(item.elements)}`; +}; + export const getLibraryItemsHash = (items: LibraryItems) => { return hashString( items - .map((item) => { - return `${item.id}:${hashElementsVersion(item.elements)}`; - }) + .map((item) => getLibraryItemHash(item)) .sort() .join(), ); @@ -641,6 +648,13 @@ const persistLibraryUpdate = async ( } } + // replace existing items with their updated versions + if (update.updatedItems) { + for (const [id, item] of update.updatedItems) { + nextLibraryItemsMap.set(id, item); + } + } + const nextLibraryItems = addedItems.concat( Array.from(nextLibraryItemsMap.values()), ); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index cd03cfe2f8..b0058575d2 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -185,7 +185,8 @@ "search": { "inputPlaceholder": "Search library", "heading": "Library matches", - "noResults": "No matching items found..." + "noResults": "No matching items found...", + "clearSearch": "Clear search" } }, "search": { @@ -229,10 +230,11 @@ "objectsSnapMode": "Snap to objects", "exitZenMode": "Exit zen mode", "cancel": "Cancel", + "saveLibNames": "Save name(s) and exit", "clear": "Clear", "remove": "Remove", "embed": "Toggle embedding", - "publishLibrary": "Publish selected", + "publishLibrary": "Rename or publish", "submit": "Submit", "confirm": "Confirm", "embeddableInteractionButton": "Click to interact" @@ -258,7 +260,8 @@ "resetLibrary": "This will clear your library. Are you sure?", "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?", "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.", - "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!" + "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!", + "localStorageQuotaExceeded": "Browser storage quota exceeded. Changes will not be saved." }, "errors": { "unsupportedFileType": "Unsupported file type.",