mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-12 10:40:12 +02:00
Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding
This commit is contained in:
@@ -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}
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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++) {
|
||||||
|
@@ -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",
|
||||||
|
@@ -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([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
@@ -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()),
|
||||||
);
|
);
|
||||||
|
@@ -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.",
|
||||||
|
Reference in New Issue
Block a user