From a8acc8212df612da3e4b5d302d759214d5826552 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:26:58 +0200 Subject: [PATCH 1/8] feat: better file normalization (#10024) * feat: better file normalization * fix lint * fix png detection * optimize * fix type --- packages/excalidraw/clipboard.ts | 5 +- packages/excalidraw/data/blob.ts | 82 +++++++++++++------------- packages/excalidraw/data/filesystem.ts | 15 ++++- packages/excalidraw/data/json.ts | 9 +-- 4 files changed, 57 insertions(+), 54 deletions(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 007a02161b..ae532a6c27 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -470,13 +470,14 @@ export const parseDataTransferEvent = async ( Array.from(items || []).map( async (item): Promise => { if (item.kind === "file") { - const file = item.getAsFile(); + let file = item.getAsFile(); if (file) { const fileHandle = await getFileHandle(item); + file = await normalizeFile(file); return { type: file.type, kind: "file", - file: await normalizeFile(file), + file, fileHandle, }; } diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 2b6829a938..e8a5401a7a 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -25,7 +25,7 @@ import { restore, restoreLibraryItems } from "./restore"; import type { AppState, DataURL, LibraryItem } from "../types"; -import type { FileSystemHandle } from "./filesystem"; +import type { FileSystemHandle } from "browser-fs-access"; import type { ImportedLibraryData } from "./types"; const parseFileContents = async (blob: Blob | File): Promise => { @@ -416,37 +416,42 @@ export const getFileHandle = async ( /** * attempts to detect if a buffer is a valid image by checking its leading bytes */ -const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { - let mimeType: ValueOf> | null = - null; +const getActualMimeTypeFromImage = async (file: Blob | File) => { + let mimeType: ValueOf< + Pick + > | null = null; - const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `; + const leadingBytes = [ + ...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))), + ].join(" "); // uint8 leading bytes - const headerBytes = { + const bytes = { // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header - png: "137 80 78 71 13 10 26 10 ", + png: /^137 80 78 71 13 10 26 10\b/, // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure // jpg is a bit wonky. Checking the first three bytes should be enough, // but may yield false positives. (https://stackoverflow.com/a/23360709/927631) - jpg: "255 216 255 ", + jpg: /^255 216 255\b/, // https://en.wikipedia.org/wiki/GIF#Example_GIF_file - gif: "71 73 70 56 57 97 ", + gif: /^71 73 70 56 57 97\b/, + // 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier + webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56\b/, }; - if (first8Bytes === headerBytes.png) { - mimeType = MIME_TYPES.png; - } else if (first8Bytes.startsWith(headerBytes.jpg)) { - mimeType = MIME_TYPES.jpg; - } else if (first8Bytes.startsWith(headerBytes.gif)) { - mimeType = MIME_TYPES.gif; + for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) { + if (leadingBytes.match(bytes[type])) { + mimeType = MIME_TYPES[type]; + break; + } } - return mimeType; + + return mimeType || file.type || null; }; export const createFile = ( blob: File | Blob | ArrayBuffer, - mimeType: ValueOf, + mimeType: string, name: string | undefined, ) => { return new File([blob], name || "", { @@ -454,40 +459,33 @@ export const createFile = ( }); }; +const normalizedFileSymbol = Symbol("fileNormalized"); + /** attempts to detect correct mimeType if none is set, or if an image * has an incorrect extension. * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */ export const normalizeFile = async (file: File) => { - if (!file.type) { - if (file?.name?.endsWith(".excalidrawlib")) { - file = createFile( - await blobToArrayBuffer(file), - MIME_TYPES.excalidrawlib, - file.name, - ); - } else if (file?.name?.endsWith(".excalidraw")) { - file = createFile( - await blobToArrayBuffer(file), - MIME_TYPES.excalidraw, - file.name, - ); - } else { - const buffer = await blobToArrayBuffer(file); - const mimeType = getActualMimeTypeFromImage(buffer); - if (mimeType) { - file = createFile(buffer, mimeType, file.name); - } - } + // to prevent double normalization (perf optim) + if ((file as any)[normalizedFileSymbol]) { + return file; + } + + if (file?.name?.endsWith(".excalidrawlib")) { + file = createFile(file, MIME_TYPES.excalidrawlib, file.name); + } else if (file?.name?.endsWith(".excalidraw")) { + file = createFile(file, MIME_TYPES.excalidraw, file.name); + } else if (!file.type || file.type?.startsWith("image/")) { // when the file is an image, make sure the extension corresponds to the - // actual mimeType (this is an edge case, but happens sometime) - } else if (isSupportedImageFile(file)) { - const buffer = await blobToArrayBuffer(file); - const mimeType = getActualMimeTypeFromImage(buffer); + // actual mimeType (this is an edge case, but happens - especially + // with AI generated images) + const mimeType = await getActualMimeTypeFromImage(file); if (mimeType && mimeType !== file.type) { - file = createFile(buffer, mimeType, file.name); + file = createFile(file, mimeType, file.name); } } + (file as any)[normalizedFileSymbol] = true; + return file; }; diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index 0f4ae745f9..44474a6f61 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common"; import { AbortError } from "../errors"; +import { normalizeFile } from "./blob"; + import type { FileSystemHandle } from "browser-fs-access"; type FILE_EXTENSION = Exclude; const INPUT_CHANGE_INTERVAL_MS = 500; -export const fileOpen = (opts: { +export const fileOpen = async (opts: { extensions?: FILE_EXTENSION[]; description: string; multiple?: M; @@ -35,7 +37,7 @@ export const fileOpen = (opts: { return acc.concat(`.${ext}`); }, [] as string[]); - return _fileOpen({ + const files = await _fileOpen({ description: opts.description, extensions, mimeTypes, @@ -74,7 +76,14 @@ export const fileOpen = (opts: { } }; }, - }) as Promise; + }); + + if (Array.isArray(files)) { + return (await Promise.all( + files.map((file) => normalizeFile(file)), + )) as RetType; + } + return (await normalizeFile(files)) as RetType; }; export const fileSave = ( diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 52cbf99581..b8fb0f62cc 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -15,7 +15,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; -import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob"; +import { isImageFileHandle, loadFromBlob } from "./blob"; import { fileOpen, fileSave } from "./filesystem"; import type { AppState, BinaryFiles, LibraryItems } from "../types"; @@ -108,12 +108,7 @@ export const loadFromJSON = async ( // gets resolved. Else, iOS users cannot open `.excalidraw` files. // extensions: ["json", "excalidraw", "png", "svg"], }); - return loadFromBlob( - await normalizeFile(file), - localAppState, - localElements, - file.handle, - ); + return loadFromBlob(file, localAppState, localElements, file.handle); }; export const isValidExcalidrawData = (data?: { From dcdeb2be5781cb8d1b0834c8251add578e0fc04f Mon Sep 17 00:00:00 2001 From: Davide Wietlisbach Date: Fri, 26 Sep 2025 16:30:23 +0200 Subject: [PATCH 2/8] fix: increase rejection delay for opening files with legacy api (#8961) * Increased input change interval to 1000 ms to fix IOS 18 file opening issue * increase more --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/data/filesystem.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index 44474a6f61..4a8d43c35f 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -14,7 +14,7 @@ import type { FileSystemHandle } from "browser-fs-access"; type FILE_EXTENSION = Exclude; -const INPUT_CHANGE_INTERVAL_MS = 500; +const INPUT_CHANGE_INTERVAL_MS = 5000; export const fileOpen = async (opts: { extensions?: FILE_EXTENSION[]; From ec070911b8a1e729ebff0e6e90e4c3ac0a6bce1e Mon Sep 17 00:00:00 2001 From: Archie Sengupta <71402528+ArchishmanSengupta@users.noreply.github.com> Date: Sun, 28 Sep 2025 13:16:28 -0700 Subject: [PATCH 3/8] feat: library search (#9903) * feat(utils): add support for search input type in isWritableElement * feat(i18n): add search text * feat(cmdp+lib): add search functionality for command pallete and lib menu items * chore: fix formats, and whitespaces * fix: opt to optimal code changes * chore: fix for linting * focus input on mount * tweak placeholder * design and UX changes * tweak item hover/active/seletected states * unrelated: move publish button above delete/clear to keep it more stable * esc to clear search input / close sidebar * refactor command pallete library stuff * make library commands bigger --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 1 + packages/common/src/utils.ts | 8 +- .../components/ColorPicker/ColorPicker.tsx | 5 +- .../CommandPalette/CommandPalette.scss | 27 +- .../CommandPalette/CommandPalette.tsx | 89 +++++- packages/excalidraw/components/InlineIcon.tsx | 13 +- .../excalidraw/components/LibraryMenu.scss | 8 +- .../excalidraw/components/LibraryMenu.tsx | 38 ++- .../components/LibraryMenuHeaderContent.tsx | 16 +- .../components/LibraryMenuItems.scss | 67 +++- .../components/LibraryMenuItems.tsx | 293 +++++++++++------- .../components/LibraryMenuSection.tsx | 2 +- .../excalidraw/components/LibraryUnit.scss | 6 +- .../excalidraw/components/LibraryUnit.tsx | 20 +- .../excalidraw/components/Sidebar/Sidebar.tsx | 14 +- packages/excalidraw/components/TextField.scss | 4 + packages/excalidraw/components/TextField.tsx | 3 + .../excalidraw/hooks/useLibraryItemSvg.ts | 17 + packages/excalidraw/locales/en.json | 9 +- 19 files changed, 458 insertions(+), 182 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 646fb08bff..3ac7a52b93 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -125,6 +125,7 @@ export const ENV = { }; export const CLASSES = { + SIDEBAR: "sidebar", SHAPE_ACTIONS_MENU: "App-menu__left", ZOOM_ACTIONS: "zoom-actions", SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper", diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 8130482db5..c65efaacf9 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -93,7 +93,8 @@ export const isWritableElement = ( (target instanceof HTMLInputElement && (target.type === "text" || target.type === "number" || - target.type === "password")); + target.type === "password" || + target.type === "search")); export const getFontFamilyString = ({ fontFamily, @@ -121,6 +122,11 @@ export const getFontString = ({ return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; }; +/** executes callback in the frame that's after the current one */ +export const nextAnimationFrame = async (cb: () => any) => { + requestAnimationFrame(() => requestAnimationFrame(cb)); +}; + export const debounce = ( fn: (...args: T) => void, timeout: number, diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index 51c7bbd2c5..ad0bea3610 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -6,6 +6,7 @@ import { COLOR_OUTLINE_CONTRAST_THRESHOLD, COLOR_PALETTE, isTransparent, + isWritableElement, } from "@excalidraw/common"; import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common"; @@ -132,7 +133,9 @@ const ColorPickerPopupContent = ({ preventAutoFocusOnTouch={!!appState.editingTextElement} onFocusOutside={(event) => { // refocus due to eye dropper - focusPickerContent(); + if (!isWritableElement(event.target)) { + focusPickerContent(); + } event.preventDefault(); }} onPointerDownOutside={(event) => { diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index 90db95db69..0a02c23b04 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -100,6 +100,19 @@ $verticalBreakpoint: 861px; border-radius: var(--border-radius-lg); cursor: pointer; + --icon-size: 1rem; + + &.command-item-large { + height: 2.75rem; + --icon-size: 1.5rem; + + .icon { + width: var(--icon-size); + height: var(--icon-size); + margin-right: 0.625rem; + } + } + &:active { background-color: var(--color-surface-low); } @@ -130,9 +143,17 @@ $verticalBreakpoint: 861px; } .icon { - width: 16px; - height: 16px; - margin-right: 6px; + width: var(--icon-size, 1rem); + height: var(--icon-size, 1rem); + margin-right: 0.375rem; + + .library-item-icon { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } } } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 3c6f110d27..03f9c93cb8 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import fuzzy from "fuzzy"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useMemo, useState } from "react"; import { DEFAULT_SIDEBAR, @@ -61,12 +61,21 @@ import { useStable } from "../../hooks/useStable"; import { Ellipsify } from "../Ellipsify"; -import * as defaultItems from "./defaultCommandPaletteItems"; +import { + distributeLibraryItemsOnSquareGrid, + libraryItemsAtom, +} from "../../data/library"; +import { + useLibraryCache, + useLibraryItemSvg, +} from "../../hooks/useLibraryItemSvg"; + +import * as defaultItems from "./defaultCommandPaletteItems"; import "./CommandPalette.scss"; import type { CommandPaletteItem } from "./types"; -import type { AppProps, AppState, UIAppState } from "../../types"; +import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types"; import type { ShortcutName } from "../../actions/shortcuts"; import type { TranslationKeys } from "../../i18n"; import type { Action } from "../../actions/types"; @@ -80,6 +89,7 @@ export const DEFAULT_CATEGORIES = { editor: "Editor", elements: "Elements", links: "Links", + library: "Library", }; const getCategoryOrder = (category: string) => { @@ -207,6 +217,34 @@ function CommandPaletteInner({ appProps, }); + const [libraryItemsData] = useAtom(libraryItemsAtom); + const libraryCommands: CommandPaletteItem[] = useMemo(() => { + return ( + libraryItemsData.libraryItems + ?.filter( + (libraryItem): libraryItem is MarkRequired => + !!libraryItem.name, + ) + .map((libraryItem) => ({ + label: libraryItem.name, + icon: ( + + ), + category: "Library", + order: getCategoryOrder("Library"), + haystack: deburr(libraryItem.name), + perform: () => { + app.onInsertElements( + distributeLibraryItemsOnSquareGrid([libraryItem]), + ); + }, + })) || [] + ); + }, [app, libraryItemsData.libraryItems]); + useEffect(() => { // these props change often and we don't want them to re-run the effect // which would renew `allCommands`, cascading down and resetting state. @@ -588,8 +626,9 @@ function CommandPaletteInner({ setAllCommands(allCommands); setLastUsed( - allCommands.find((command) => command.label === lastUsed?.label) ?? - null, + [...allCommands, ...libraryCommands].find( + (command) => command.label === lastUsed?.label, + ) ?? null, ); } }, [ @@ -600,6 +639,7 @@ function CommandPaletteInner({ lastUsed?.label, setLastUsed, setAppState, + libraryCommands, ]); const [commandSearch, setCommandSearch] = useState(""); @@ -796,9 +836,12 @@ function CommandPaletteInner({ return nextCommandsByCategory; }; - let matchingCommands = allCommands - .filter(isCommandAvailable) - .sort((a, b) => a.order - b.order); + let matchingCommands = + commandSearch?.length > 1 + ? [...allCommands, ...libraryCommands] + : allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); const showLastUsed = !commandSearch && lastUsed && isCommandAvailable(lastUsed); @@ -822,14 +865,20 @@ function CommandPaletteInner({ ); matchingCommands = fuzzy .filter(_query, matchingCommands, { - extract: (command) => command.haystack, + extract: (command) => command.haystack ?? "", }) .sort((a, b) => b.score - a.score) .map((item) => item.original); setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCurrentCommand(matchingCommands[0] ?? null); - }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + }, [ + commandSearch, + allCommands, + isCommandAvailable, + lastUsed, + libraryCommands, + ]); return ( setCurrentCommand(command)} showShortcut={!app.device.viewport.isMobile} appState={uiAppState} + size={category === "Library" ? "large" : "small"} /> ))} @@ -919,6 +969,20 @@ function CommandPaletteInner({ ); } +const LibraryItemIcon = ({ + id, + elements, +}: { + id: LibraryItem["id"] | null; + elements: LibraryItem["elements"] | undefined; +}) => { + const ref = useRef(null); + const { svgCache } = useLibraryCache(); + + useLibraryItemSvg(id, elements, svgCache, ref); + + return
; +}; const CommandItem = ({ command, @@ -928,6 +992,7 @@ const CommandItem = ({ onClick, showShortcut, appState, + size = "small", }: { command: CommandPaletteItem; isSelected: boolean; @@ -936,6 +1001,7 @@ const CommandItem = ({ onClick: (event: React.MouseEvent) => void; showShortcut: boolean; appState: UIAppState; + size?: "small" | "large"; }) => { const noop = () => {}; @@ -944,6 +1010,7 @@ const CommandItem = ({ className={clsx("command-item", { "item-selected": isSelected, "item-disabled": disabled, + "command-item-large": size === "large", })} ref={(ref) => { if (isSelected && !disabled) { @@ -959,6 +1026,8 @@ const CommandItem = ({
{command.icon && ( { +export const InlineIcon = ({ + className, + icon, + size = "1em", +}: { + className?: string; + icon: React.ReactNode; + size?: string; +}) => { return ( { const memoizedLibrary = useMemo(() => app.library, [app.library]); const pendingElements = usePendingElementsMemo(appState, app); + useEffect(() => { + return addEventListener( + document, + EVENT.KEYDOWN, + (event) => { + if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) { + const target = event.target; + 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) { + setSelectedItems([]); + } else if ( + isWritableElement(target) && + target instanceof HTMLInputElement && + !target.value + ) { + // if search input empty -> close library + // (maybe not a good idea?) + setAppState({ openSidebar: null }); + app.focusContainer(); + } + } + } + }, + { capture: true }, + ); + }, [selectedItems, setAppState, app]); + const onInsertLibraryItems = useCallback( (libraryItems: LibraryItems) => { onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + app.focusContainer(); }, - [onInsertElements], + [onInsertElements, app], ); const deselectItems = useCallback(() => { diff --git a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx index 5b003effa1..9d7e0d1c84 100644 --- a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx +++ b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx @@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{ {t("buttons.export")} )} - {!!items.length && ( - setShowRemoveLibAlert(true)} - icon={TrashIcon} - > - {resetLabel} - - )} {itemsSelected && ( )} + {!!items.length && ( + setShowRemoveLibAlert(true)} + icon={TrashIcon} + > + {resetLabel} + + )} ); diff --git a/packages/excalidraw/components/LibraryMenuItems.scss b/packages/excalidraw/components/LibraryMenuItems.scss index 59cd9f1cf9..3e67774348 100644 --- a/packages/excalidraw/components/LibraryMenuItems.scss +++ b/packages/excalidraw/components/LibraryMenuItems.scss @@ -1,24 +1,42 @@ @import "open-color/open-color"; .excalidraw { - --container-padding-y: 1.5rem; + --container-padding-y: 1rem; --container-padding-x: 0.75rem; + .library-menu-items-header { + display: flex; + padding-top: 1rem; + padding-bottom: 0.5rem; + gap: 0.5rem; + } + .library-menu-items__no-items { text-align: center; color: var(--color-gray-70); line-height: 1.5; font-size: 0.875rem; width: 100%; + min-height: 55px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; &__label { color: var(--color-primary); font-weight: 700; font-size: 1.125rem; - margin-bottom: 0.75rem; + margin-bottom: 0.25rem; } } + .library-menu-items__no-items__hint { + color: var(--color-border-outline); + padding: 0.75rem 1rem; + } + &.theme--dark { .library-menu-items__no-items { color: var(--color-gray-40); @@ -34,7 +52,7 @@ overflow-y: auto; flex-direction: column; height: 100%; - justify-content: center; + justify-content: flex-start; margin: 0; position: relative; @@ -51,26 +69,45 @@ } &__items { + // so that spinner is relative-positioned to this container + position: relative; + row-gap: 0.5rem; - padding: var(--container-padding-y) 0; + padding: 1rem 0 var(--container-padding-y) 0; flex: 1; overflow-y: auto; overflow-x: hidden; - margin-bottom: 1rem; } &__header { + display: flex; + align-items: center; + flex: 1 1 auto; + color: var(--color-primary); font-size: 1.125rem; font-weight: 700; margin-bottom: 0.75rem; width: 100%; - padding-right: 4rem; // due to dropdown button box-sizing: border-box; &--excal { margin-top: 2rem; } + + &__hint { + margin-left: auto; + font-size: 10px; + color: var(--color-border-outline); + font-weight: 400; + + kbd { + font-family: monospace; + border: 1px solid var(--color-border-outline); + border-radius: 4px; + padding: 1px 3px; + } + } } &__grid { @@ -79,6 +116,24 @@ grid-gap: 1rem; } + &__search { + flex: 1 1 auto; + margin: 0; + + .ExcTextField__input { + height: var(--lg-button-size); + input { + font-size: 0.875rem; + } + } + + &.hideCancelButton input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + display: none; + } + } + .separator { width: 100%; display: flex; diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index eb82dde550..3a78bbec4e 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -6,10 +6,14 @@ import React, { useState, } from "react"; -import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; +import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common"; import { duplicateElements } from "@excalidraw/element"; +import clsx from "clsx"; + +import { deburr } from "../deburr"; + import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; @@ -26,6 +30,10 @@ import Stack from "./Stack"; import "./LibraryMenuItems.scss"; +import { TextField } from "./TextField"; + +import { useDevice } from "./App"; + import type { ExcalidrawLibraryIds } from "../data/types"; import type { @@ -65,6 +73,7 @@ export default function LibraryMenuItems({ selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; }) { + const device = useDevice(); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -76,6 +85,30 @@ export default function LibraryMenuItems({ }, []); // eslint-disable-line react-hooks/exhaustive-deps const { svgCache } = useLibraryCache(); + const [lastSelectedItem, setLastSelectedItem] = useState< + LibraryItem["id"] | null + >(null); + + const [searchInputValue, setSearchInputValue] = useState(""); + + const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length; + + const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim(); + + const filteredItems = useMemo(() => { + const searchQuery = deburr(searchInputValue.trim().toLowerCase()); + if (!searchQuery) { + return []; + } + + return libraryItems.filter((item) => { + const itemName = item.name || ""; + return ( + itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery) + ); + }); + }, [libraryItems, searchInputValue]); + const unpublishedItems = useMemo( () => libraryItems.filter((item) => item.status !== "published"), [libraryItems], @@ -86,23 +119,10 @@ export default function LibraryMenuItems({ [libraryItems], ); - const showBtn = !libraryItems.length && !pendingElements.length; - - const isLibraryEmpty = - !pendingElements.length && - !unpublishedItems.length && - !publishedItems.length; - - const [lastSelectedItem, setLastSelectedItem] = useState< - LibraryItem["id"] | null - >(null); - const onItemSelectToggle = useCallback( (id: LibraryItem["id"], event: React.MouseEvent) => { const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; - if (shouldSelect) { if (event.shiftKey && lastSelectedItem) { const rangeStart = orderedItems.findIndex( @@ -128,7 +148,6 @@ export default function LibraryMenuItems({ }, [], ); - onSelectItems(nextSelectedIds); } else { onSelectItems([...selectedItems, id]); @@ -194,7 +213,6 @@ export default function LibraryMenuItems({ if (!id) { return false; } - return selectedItems.includes(id); }, [selectedItems], @@ -214,10 +232,120 @@ export default function LibraryMenuItems({ ); const itemsRenderedPerBatch = - svgCache.size >= libraryItems.length + svgCache.size >= + (filteredItems.length ? filteredItems : libraryItems).length ? CACHED_ITEMS_RENDERED_PER_BATCH : ITEMS_RENDERED_PER_BATCH; + const searchInputRef = useRef(null); + useEffect(() => { + // focus could be stolen by tab trigger button + nextAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + }, []); + + const JSX_whenNotSearching = !IS_SEARCHING && ( + <> + {!IS_LIBRARY_EMPTY && ( +
+ {t("labels.personalLib")} +
+ )} + {!pendingElements.length && !unpublishedItems.length ? ( +
+ {!publishedItems.length && ( +
+ {t("library.noItems")} +
+ )} +
+ {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
+
+ ) : ( + + {pendingElements.length > 0 && ( + + )} + + + )} + + {publishedItems.length > 0 && ( +
+ {t("labels.excalidrawLib")} +
+ )} + {publishedItems.length > 0 && ( + + + + )} + + ); + + const JSX_whenSearching = IS_SEARCHING && ( + <> +
+ {t("library.search.heading")} + {!isLoading && ( +
+ esc to clear +
+ )} +
+ {filteredItems.length > 0 ? ( + + + + ) : ( +
+
+ {t("library.search.noResults")} +
+
+ )} + + ); + return (
- {!isLibraryEmpty && ( +
+ {!IS_LIBRARY_EMPTY && ( + setSearchInputValue(value)} + /> + )} - )} +
0 ? 1 : "0 1 auto", - marginBottom: 0, + margin: IS_LIBRARY_EMPTY ? "auto" : 0, }} ref={libraryContainerRef} > - <> - {!isLibraryEmpty && ( -
- {t("labels.personalLib")} -
- )} - {isLoading && ( -
- -
- )} - {!pendingElements.length && !unpublishedItems.length ? ( -
-
- {t("library.noItems")} -
-
- {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} -
-
- ) : ( - - {pendingElements.length > 0 && ( - - )} - - - )} - + {isLoading && ( +
+ +
+ )} - <> - {(publishedItems.length > 0 || - pendingElements.length > 0 || - unpublishedItems.length > 0) && ( -
- {t("labels.excalidrawLib")} -
- )} - {publishedItems.length > 0 ? ( - - - - ) : unpublishedItems.length > 0 ? ( -
- {t("library.noItems")} -
- ) : null} - + {JSX_whenNotSearching} + {JSX_whenSearching} - {showBtn && ( + {IS_LIBRARY_EMPTY && ( - - + /> )}
diff --git a/packages/excalidraw/components/LibraryMenuSection.tsx b/packages/excalidraw/components/LibraryMenuSection.tsx index d98b413fbb..9ff84f5724 100644 --- a/packages/excalidraw/components/LibraryMenuSection.tsx +++ b/packages/excalidraw/components/LibraryMenuSection.tsx @@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg"; import type { LibraryItem } from "../types"; import type { ReactNode } from "react"; -type LibraryOrPendingItem = ( +type LibraryOrPendingItem = readonly ( | LibraryItem | /* pending library item */ { id: null; diff --git a/packages/excalidraw/components/LibraryUnit.scss b/packages/excalidraw/components/LibraryUnit.scss index 5ebe83f414..a0d2161c21 100644 --- a/packages/excalidraw/components/LibraryUnit.scss +++ b/packages/excalidraw/components/LibraryUnit.scss @@ -18,12 +18,12 @@ } &--hover { - border-color: var(--color-primary); + background-color: var(--color-surface-mid); } + &:active:not(:has(.library-unit__checkbox:hover)), &--selected { - border-color: var(--color-primary); - border-width: 1px; + background-color: var(--color-surface-high); } &--skeleton { diff --git a/packages/excalidraw/components/LibraryUnit.tsx b/packages/excalidraw/components/LibraryUnit.tsx index 9cd891715c..36607910e5 100644 --- a/packages/excalidraw/components/LibraryUnit.tsx +++ b/packages/excalidraw/components/LibraryUnit.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useRef, useState } from "react"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; @@ -33,23 +33,7 @@ export const LibraryUnit = memo( svgCache: SvgCache; }) => { const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements, svgCache); - - useEffect(() => { - const node = ref.current; - - if (!node) { - return; - } - - if (svg) { - node.innerHTML = svg.outerHTML; - } - - return () => { - node.innerHTML = ""; - }; - }, [svg]); + const svg = useLibraryItemSvg(id, elements, svgCache, ref); const [isHovered, setIsHovered] = useState(false); const isMobile = useDevice().editor.isMobile; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx index d08ba5f597..5f0ca487f2 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx @@ -9,7 +9,13 @@ import React, { useCallback, } from "react"; -import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common"; +import { + CLASSES, + EVENT, + isDevEnv, + KEYS, + updateObject, +} from "@excalidraw/common"; import { useUIAppState } from "../../context/ui-appState"; import { atom, useSetAtom } from "../../editor-jotai"; @@ -137,7 +143,11 @@ export const SidebarInner = forwardRef( return ( diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss index c46cd2fe8c..fefea7e802 100644 --- a/packages/excalidraw/components/TextField.scss +++ b/packages/excalidraw/components/TextField.scss @@ -12,6 +12,10 @@ --ExcTextField--border-active: var(--color-brand-active); --ExcTextField--placeholder: var(--color-border-outline-variant); + &.theme--dark { + --ExcTextField--border: var(--color-border-outline-variant); + } + .ExcTextField { position: relative; diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index d6bc315b18..4e724aceda 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/packages/excalidraw/components/TextField.tsx @@ -28,6 +28,7 @@ type TextFieldProps = { className?: string; placeholder?: string; isRedacted?: boolean; + type?: "text" | "search"; } & ({ value: string } | { defaultValue: string }); export const TextField = forwardRef( @@ -43,6 +44,7 @@ export const TextField = forwardRef( isRedacted = false, icon, className, + type, ...rest }, ref, @@ -96,6 +98,7 @@ export const TextField = forwardRef( ref={innerRef} onChange={(event) => onChange?.(event.target.value)} onKeyDown={onKeyDown} + type={type} /> {isRedacted && (
)} diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 9a9a7b9cac..8279cb4344 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": { From f1b097ad06d9cacc764405004b4d2786d5d5d38e Mon Sep 17 00:00:00 2001 From: Omar Eltomy <97570527+omareltomy@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:46:42 +0300 Subject: [PATCH 5/8] fix: support bidirectional shift+click selection in library items (#10034) * fix: support bidirectional shift+click selection in library items - Enable bottom-up multi-selection (previously only top-down worked) - Use Math.min/max to handle selection range in both directions - Maintains existing behavior for preserving non-contiguous selections - Fixes issue where shift+clicking items above last selected item failed * improve deselection behavior --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/components/LibraryMenu.tsx | 12 +++++++++++- packages/excalidraw/components/LibraryMenuItems.tsx | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) 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 2d111b7f7b..c64351b1b3 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -138,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); @@ -169,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; From 7c4194485647fcd3a1aaa75612c0193208d65bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yago=20D=C3=B3rea?= Date: Tue, 30 Sep 2025 12:09:20 -0300 Subject: [PATCH 6/8] fix: small improvement on binary heap implementation (#9992) --- packages/common/src/binary-heap.ts | 43 +++++++++++++++++------------- 1 file changed, 24 insertions(+), 19 deletions(-) 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) { From fde796a7a00d43dc2bb434b5ec98fb57afe9e896 Mon Sep 17 00:00:00 2001 From: zsviczian Date: Tue, 30 Sep 2025 20:38:10 +0200 Subject: [PATCH 7/8] feat: Make naming of library items discoverable (#10041) * updated library relevant strings * fix: detect name changes * clarify hashing function --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/element/src/index.ts | 3 +++ .../excalidraw/components/PublishLibrary.tsx | 2 +- packages/excalidraw/data/library.ts | 22 +++++++++++++++---- packages/excalidraw/locales/en.json | 3 ++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 4fc1ef5579..d677859ad5 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -29,6 +29,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/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 8279cb4344..4bd76fe876 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -230,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" From 835eb8d2fdf21afd93175367838e6b9ecd9e9271 Mon Sep 17 00:00:00 2001 From: Emil <73137047+h0lm1@users.noreply.github.com> Date: Tue, 30 Sep 2025 23:54:43 +0200 Subject: [PATCH 8/8] fix: display error message when local storage quota is exceeded (#9961) * fix: display error message when local storage quota is exceeded * add danger alert instead of toast * tweak text --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 10 +++++++++- excalidraw-app/data/LocalData.ts | 17 +++++++++++++++++ excalidraw-app/index.scss | 14 +++++++++++--- packages/excalidraw/locales/en.json | 3 ++- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index b972e6e5b0..a5d01769cc 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) => { @@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { + const localStorageQuotaExceeded = appJotaiStore.get( + localStorageQuotaExceededAtom, + ); try { const _appState = clearAppStateForLocalStorage(appState); @@ -88,12 +95,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/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 4bd76fe876..feebe6da0f 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -260,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.",