Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding

This commit is contained in:
Mark Tolmacs
2025-10-01 10:46:38 +02:00
11 changed files with 133 additions and 35 deletions

View File

@@ -119,6 +119,7 @@ import {
LibraryIndexedDBAdapter, LibraryIndexedDBAdapter,
LibraryLocalStorageMigrationAdapter, LibraryLocalStorageMigrationAdapter,
LocalData, LocalData,
localStorageQuotaExceededAtom,
} from "./data/LocalData"; } from "./data/LocalData";
import { isBrowserStorageStateNewer } from "./data/tabSync"; import { isBrowserStorageStateNewer } from "./data/tabSync";
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
@@ -727,6 +728,8 @@ const ExcalidrawWrapper = () => {
const isOffline = useAtomValue(isOfflineAtom); const isOffline = useAtomValue(isOfflineAtom);
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
const onCollabDialogOpen = useCallback( const onCollabDialogOpen = useCallback(
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }), () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
[setShareDialogState], [setShareDialogState],
@@ -901,10 +904,15 @@ const ExcalidrawWrapper = () => {
<TTDDialogTrigger /> <TTDDialogTrigger />
{isCollaborating && isOffline && ( {isCollaborating && isOffline && (
<div className="collab-offline-warning"> <div className="alertalert--warning">
{t("alerts.collabOfflineWarning")} {t("alerts.collabOfflineWarning")}
</div> </div>
)} )}
{localStorageQuotaExceeded && (
<div className="alert alert--danger">
{t("alerts.localStorageQuotaExceeded")}
</div>
)}
{latestShareableLink && ( {latestShareableLink && (
<ShareableLinkDialog <ShareableLinkDialog
link={latestShareableLink} link={latestShareableLink}

View File

@@ -26,6 +26,8 @@ import {
get, get,
} from "idb-keyval"; } from "idb-keyval";
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types"; import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
@@ -44,6 +46,8 @@ import { updateBrowserStateVersion } from "./tabSync";
const filesStore = createStore("files-db", "files-store"); const filesStore = createStore("files-db", "files-store");
export const localStorageQuotaExceededAtom = atom(false);
class LocalFileManager extends FileManager { class LocalFileManager extends FileManager {
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
await entries(filesStore).then((entries) => { await entries(filesStore).then((entries) => {
@@ -68,6 +72,9 @@ const saveDataStateToLocalStorage = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
const localStorageQuotaExceeded = appJotaiStore.get(
localStorageQuotaExceededAtom,
);
try { try {
const _appState = clearAppStateForLocalStorage(appState); const _appState = clearAppStateForLocalStorage(appState);
@@ -87,12 +94,22 @@ const saveDataStateToLocalStorage = (
JSON.stringify(_appState), JSON.stringify(_appState),
); );
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
if (localStorageQuotaExceeded) {
appJotaiStore.set(localStorageQuotaExceededAtom, false);
}
} catch (error: any) { } catch (error: any) {
// Unable to access window.localStorage // Unable to access window.localStorage
console.error(error); 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"; type SavingLockTypes = "collaboration";
export class LocalData { export class LocalData {

View File

@@ -58,7 +58,7 @@
} }
} }
.collab-offline-warning { .alert {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;
top: 6.5rem; top: 6.5rem;
@@ -69,10 +69,18 @@
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
background-color: var(--color-warning);
color: var(--color-text-warning);
z-index: 6; z-index: 6;
white-space: pre; 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);
}
} }
} }

View File

@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
sinkDown(idx: number) { sinkDown(idx: number) {
const node = this.content[idx]; const node = this.content[idx];
const nodeScore = this.scoreFunction(node);
while (idx > 0) { while (idx > 0) {
const parentN = ((idx + 1) >> 1) - 1; const parentN = ((idx + 1) >> 1) - 1;
const parent = this.content[parentN]; const parent = this.content[parentN];
if (this.scoreFunction(node) < this.scoreFunction(parent)) { if (nodeScore < this.scoreFunction(parent)) {
this.content[parentN] = node;
this.content[idx] = parent; this.content[idx] = parent;
idx = parentN; // TODO: Optimize idx = parentN; // TODO: Optimize
} else { } else {
break; break;
} }
} }
this.content[idx] = node;
} }
bubbleUp(idx: number) { bubbleUp(idx: number) {
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
const score = this.scoreFunction(node); const score = this.scoreFunction(node);
while (true) { while (true) {
const child2N = (idx + 1) << 1; const child1N = ((idx + 1) << 1) - 1;
const child1N = child2N - 1; const child2N = child1N + 1;
let swap = null; let smallestIdx = idx;
let child1Score = 0; let smallestScore = score;
// Check left child
if (child1N < length) { if (child1N < length) {
const child1 = this.content[child1N]; const child1Score = this.scoreFunction(this.content[child1N]);
child1Score = this.scoreFunction(child1); if (child1Score < smallestScore) {
if (child1Score < score) { smallestIdx = child1N;
swap = child1N; smallestScore = child1Score;
} }
} }
// Check right child
if (child2N < length) { if (child2N < length) {
const child2 = this.content[child2N]; const child2Score = this.scoreFunction(this.content[child2N]);
const child2Score = this.scoreFunction(child2); if (child2Score < smallestScore) {
if (child2Score < (swap === null ? score : child1Score)) { smallestIdx = child2N;
swap = child2N;
} }
} }
if (swap !== null) { if (smallestIdx === idx) {
this.content[idx] = this.content[swap];
this.content[swap] = node;
idx = swap; // TODO: Optimize
} else {
break; 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) { push(node: T) {

View File

@@ -28,6 +28,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
// string hash function (using djb2). Not cryptographically secure, use only // string hash function (using djb2). Not cryptographically secure, use only
// for versioning and such. // 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 => { export const hashString = (s: string): number => {
let hash: number = 5381; let hash: number = 5381;
for (let i = 0; i < s.length; i++) { for (let i = 0; i < s.length; i++) {

View File

@@ -12,8 +12,9 @@ export const InlineIcon = ({
className={className} className={className}
style={{ style={{
width: size, width: size,
height: "100%",
margin: "0 0.5ex 0 0.5ex", margin: "0 0.5ex 0 0.5ex",
display: "inline-block", display: "inline-flex",
lineHeight: 0, lineHeight: 0,
verticalAlign: "middle", verticalAlign: "middle",
flex: "0 0 auto", flex: "0 0 auto",

View File

@@ -281,19 +281,29 @@ export const LibraryMenu = memo(() => {
if (target.closest(`.${CLASSES.SIDEBAR}`)) { if (target.closest(`.${CLASSES.SIDEBAR}`)) {
// stop propagation so that we don't prevent it downstream // stop propagation so that we don't prevent it downstream
// (default browser behavior is to clear search input on ESC) // (default browser behavior is to clear search input on ESC)
event.stopPropagation();
if (selectedItems.length > 0) { if (selectedItems.length > 0) {
event.stopPropagation();
setSelectedItems([]); setSelectedItems([]);
} else if ( } else if (
isWritableElement(target) && isWritableElement(target) &&
target instanceof HTMLInputElement && target instanceof HTMLInputElement &&
!target.value !target.value
) { ) {
event.stopPropagation();
// if search input empty -> close library // if search input empty -> close library
// (maybe not a good idea?) // (maybe not a good idea?)
setAppState({ openSidebar: null }); setAppState({ openSidebar: null });
app.focusContainer(); 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([]);
}
} }
} }
}, },

View File

@@ -34,6 +34,8 @@ import { TextField } from "./TextField";
import { useDevice } from "./App"; import { useDevice } from "./App";
import { Button } from "./Button";
import type { ExcalidrawLibraryIds } from "../data/types"; import type { ExcalidrawLibraryIds } from "../data/types";
import type { import type {
@@ -136,10 +138,13 @@ export default function LibraryMenuItems({
} }
const selectedItemsMap = arrayToMap(selectedItems); 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( const nextSelectedIds = orderedItems.reduce(
(acc: LibraryItem["id"][], item, idx) => { (acc: LibraryItem["id"][], item, idx) => {
if ( if (
(idx >= rangeStart && idx <= rangeEnd) || (idx >= minRange && idx <= maxRange) ||
selectedItemsMap.has(item.id) selectedItemsMap.has(item.id)
) { ) {
acc.push(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( const getInsertedElements = useCallback(
(id: string) => { (id: string) => {
let targetElements; let targetElements;
@@ -319,7 +332,14 @@ export default function LibraryMenuItems({
<div className="library-menu-items-container__header"> <div className="library-menu-items-container__header">
{t("library.search.heading")} {t("library.search.heading")}
{!isLoading && ( {!isLoading && (
<div className="library-menu-items-container__header__hint"> <div
className="library-menu-items-container__header__hint"
style={{ cursor: "pointer" }}
onPointerDown={(e) => e.preventDefault()}
onClick={(event) => {
setSearchInputValue("");
}}
>
<kbd>esc</kbd> to clear <kbd>esc</kbd> to clear
</div> </div>
)} )}
@@ -341,6 +361,15 @@ export default function LibraryMenuItems({
<div className="library-menu-items__no-items__hint"> <div className="library-menu-items__no-items__hint">
{t("library.search.noResults")} {t("library.search.noResults")}
</div> </div>
<Button
onPointerDown={(e) => e.preventDefault()}
onSelect={() => {
setSearchInputValue("");
}}
style={{ width: "auto", marginTop: "1rem" }}
>
{t("library.search.clearSearch")}
</Button>
</div> </div>
)} )}
</> </>

View File

@@ -518,7 +518,7 @@ const PublishLibrary = ({
</div> </div>
<div className="publish-library__buttons"> <div className="publish-library__buttons">
<DialogActionButton <DialogActionButton
label={t("buttons.cancel")} label={t("buttons.saveLibNames")}
onClick={onDialogClose} onClick={onDialogClose}
data-testid="cancel-clear-canvas-button" data-testid="cancel-clear-canvas-button"
/> />

View File

@@ -62,6 +62,7 @@ type LibraryUpdate = {
deletedItems: Map<LibraryItem["id"], LibraryItem>; deletedItems: Map<LibraryItem["id"], LibraryItem>;
/** newly added items in the library */ /** newly added items in the library */
addedItems: Map<LibraryItem["id"], LibraryItem>; addedItems: Map<LibraryItem["id"], LibraryItem>;
updatedItems: Map<LibraryItem["id"], LibraryItem>;
}; };
// an object so that we can later add more properties to it without breaking, // an object so that we can later add more properties to it without breaking,
@@ -170,6 +171,7 @@ const createLibraryUpdate = (
const update: LibraryUpdate = { const update: LibraryUpdate = {
deletedItems: new Map<LibraryItem["id"], LibraryItem>(), deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
addedItems: new Map<LibraryItem["id"], LibraryItem>(), addedItems: new Map<LibraryItem["id"], LibraryItem>(),
updatedItems: new Map<LibraryItem["id"], LibraryItem>(),
}; };
for (const item of prevLibraryItems) { for (const item of prevLibraryItems) {
@@ -181,8 +183,11 @@ const createLibraryUpdate = (
const prevItemsMap = arrayToMap(prevLibraryItems); const prevItemsMap = arrayToMap(prevLibraryItems);
for (const item of nextLibraryItems) { for (const item of nextLibraryItems) {
if (!prevItemsMap.has(item.id)) { const prevItem = prevItemsMap.get(item.id);
if (!prevItem) {
update.addedItems.set(item.id, item); 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 lastSavedLibraryItemsHash = 0;
let librarySaveCounter = 0; let librarySaveCounter = 0;
const getLibraryItemHash = (item: LibraryItem) => {
return `${item.id}:${item.name || ""}:${hashElementsVersion(item.elements)}`;
};
export const getLibraryItemsHash = (items: LibraryItems) => { export const getLibraryItemsHash = (items: LibraryItems) => {
return hashString( return hashString(
items items
.map((item) => { .map((item) => getLibraryItemHash(item))
return `${item.id}:${hashElementsVersion(item.elements)}`;
})
.sort() .sort()
.join(), .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( const nextLibraryItems = addedItems.concat(
Array.from(nextLibraryItemsMap.values()), Array.from(nextLibraryItemsMap.values()),
); );

View File

@@ -185,7 +185,8 @@
"search": { "search": {
"inputPlaceholder": "Search library", "inputPlaceholder": "Search library",
"heading": "Library matches", "heading": "Library matches",
"noResults": "No matching items found..." "noResults": "No matching items found...",
"clearSearch": "Clear search"
} }
}, },
"search": { "search": {
@@ -229,10 +230,11 @@
"objectsSnapMode": "Snap to objects", "objectsSnapMode": "Snap to objects",
"exitZenMode": "Exit zen mode", "exitZenMode": "Exit zen mode",
"cancel": "Cancel", "cancel": "Cancel",
"saveLibNames": "Save name(s) and exit",
"clear": "Clear", "clear": "Clear",
"remove": "Remove", "remove": "Remove",
"embed": "Toggle embedding", "embed": "Toggle embedding",
"publishLibrary": "Publish selected", "publishLibrary": "Rename or publish",
"submit": "Submit", "submit": "Submit",
"confirm": "Confirm", "confirm": "Confirm",
"embeddableInteractionButton": "Click to interact" "embeddableInteractionButton": "Click to interact"
@@ -258,7 +260,8 @@
"resetLibrary": "This will clear your library. Are you sure?", "resetLibrary": "This will clear your library. Are you sure?",
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?", "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.", "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": { "errors": {
"unsupportedFileType": "Unsupported file type.", "unsupportedFileType": "Unsupported file type.",