mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-05 23:30:01 +02:00
Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding
This commit is contained in:
@@ -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 = () => {
|
||||
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
<div className="alertalert--warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{localStorageQuotaExceeded && (
|
||||
<div className="alert alert--danger">
|
||||
{t("alerts.localStorageQuotaExceeded")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
|
@@ -26,6 +26,8 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
|
||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||
|
||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||
@@ -44,6 +46,8 @@ import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
export const localStorageQuotaExceededAtom = atom(false);
|
||||
|
||||
class LocalFileManager extends FileManager {
|
||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||
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 {
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
||||
|
||||
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<T> {
|
||||
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) {
|
||||
|
@@ -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++) {
|
||||
|
@@ -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",
|
||||
|
@@ -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([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -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({
|
||||
<div className="library-menu-items-container__header">
|
||||
{t("library.search.heading")}
|
||||
{!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
|
||||
</div>
|
||||
)}
|
||||
@@ -341,6 +361,15 @@ export default function LibraryMenuItems({
|
||||
<div className="library-menu-items__no-items__hint">
|
||||
{t("library.search.noResults")}
|
||||
</div>
|
||||
<Button
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onSelect={() => {
|
||||
setSearchInputValue("");
|
||||
}}
|
||||
style={{ width: "auto", marginTop: "1rem" }}
|
||||
>
|
||||
{t("library.search.clearSearch")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
@@ -518,7 +518,7 @@ const PublishLibrary = ({
|
||||
</div>
|
||||
<div className="publish-library__buttons">
|
||||
<DialogActionButton
|
||||
label={t("buttons.cancel")}
|
||||
label={t("buttons.saveLibNames")}
|
||||
onClick={onDialogClose}
|
||||
data-testid="cancel-clear-canvas-button"
|
||||
/>
|
||||
|
@@ -62,6 +62,7 @@ type LibraryUpdate = {
|
||||
deletedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||
/** newly added items in the library */
|
||||
addedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||
updatedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||
};
|
||||
|
||||
// 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<LibraryItem["id"], LibraryItem>(),
|
||||
addedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||
updatedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||
};
|
||||
|
||||
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()),
|
||||
);
|
||||
|
@@ -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.",
|
||||
|
Reference in New Issue
Block a user