feat: Allow publishing libraries from UI (#4115)
* feat: Allow publishing libraries from UI * Add status for each library item and show publish only for unpublished libs * Add publish library dialog * Pass the data to publish the library * pass lib blob * Handle old and new libraries when importing * Better error handling * Show publish success when library submitted for review * don't close library when publish success dialog open * Support multiple libs deletion and publish * Set status to published once library submitted for review * Save to LS after library published * unique key for publish and delete * fix layout shift when hover and also highlight selected library items * design improvements * migrate old library to the new one * fix * fix tests * use i18n * Support submit type in toolbutton * Use html5 form validation, add asteriks for required fields, add twitter handle, mark github handle optional * Add twitter handle in form state * revert html5 validation as fetch is giving some issues :/ * clarify types around LibraryItems * Add website optional field * event.preventDefault to make htm5 form validationw work * improve png generation by drawing a bounding box rect and aligining pngs to support multiple libs png * remove ts-ignore * add placeholders for fields * decrease clickable area for checkbox by 0.5em * add checkbox background color * rename `items` to `elements` * improve checkbox hit area * show selected library items in publish dialog * decrease dimensions by 3px to improve jerky experience when opening/closing library menu * Don't close publish dialog when clicked outside * Show selected library actions only when any library item selected and use icons instead of button * rename library to libraryItems in excalidrawLib and added migration * change icon and swap bg/color * use blue brand color for hover/selected states * prompt for confirmation when deleting library items * separate unpublished items from published * factor `LibraryMenu` into own file * i18n and minor fixes for unpublished items * fix not rendering empty cells when library empty * don't render published section if empty and unpublished is not * Add edit name functionality for library items * fix * edit lib name with onchange/blur * bump library version * prefer response error message * add library urls to ENV vars * mark lib item name as required * Use input only for lib item name * better error validation for lib items * fix label styling for lib items * design and i18n fixes * Save publish dialog data to local storage and clear once published * Add a note about MIT License * Add note for guidelines * Add tooltip for publish button * Show spinner in submit button when submission is in progress * assign id for older lib items when installed and set status as published for all lib when installed * update export icon and support export library for selected items * move LibraryMenuItems into its own component as its best to keep one comp per file * fix spec * Refactoring the library actions for reusablility * show only load when items not present * close on click outside in publish dialog * ad dialog description and tweak copy * vertically center input labels * align input styles * move author name input to other usernames * rename param * inline to simplify * fix to not inline `undefined` class names * fix version & include only latest lib schema in library export type * await response callback * refactor types * refactor * i18n * align casing & tweaks * move ls logic to publishLibrary * support removal of item inside publish dialog * fix labels for trash icon when items selected * replace window.confirm for removal libs with confirm dialog * fix input/textarea styling * move library item menu scss to its own file * use blue for load and cyan for publish * reduce margin for submit and make submit => Submit * Make library items header sticky * move publish icon to left so there is no jerkiness when unpublish items selected * update url * fix grid gap between lib items * Mark older items imported from initial data as unpublished * add text to publish button on non-mobile * add items counter * fix test * show personal and excal libs sections and personal goes first * show toast on adding to library via contextMenu * Animate plus icon and not the pending item * fix snap * use i18n when no item in publish dialog * tweak style of new lib item * show empty cells for both sections and set status as published for installed libs * fix * push selected item first in unpublished section * set status as published for imported from webiste but unpublished for json * Add items to the begining of library * add `created` library item attr * fix test * use `defaultValue` instead of `value` * fix dark theme styles * fix toggle button not closing library * close library menu on Escape * tweak publish dialog item remove style * fix remove icon in publish dialog Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
3ff9744b39
commit
84d1d9993c
4
.env
4
.env
@ -1,6 +1,8 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
# dev values
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
|
||||
|
@ -1,6 +1,11 @@
|
||||
REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
|
||||
REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
|
||||
|
||||
REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
|
||||
REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
|
||||
|
||||
# production-only vars
|
||||
REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
|
||||
|
@ -2,22 +2,46 @@ import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
return app.library
|
||||
.loadLibrary()
|
||||
.then((items) => {
|
||||
return app.library.saveLibrary([
|
||||
{
|
||||
id: randomId(),
|
||||
status: "unpublished",
|
||||
elements: getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
app.library.loadLibrary().then((items) => {
|
||||
app.library.saveLibrary([
|
||||
).map(deepCopyElement),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
selectedElements.map(deepCopyElement),
|
||||
]);
|
||||
})
|
||||
.then(() => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
toastMessage: t("toast.addedToLibrary"),
|
||||
},
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
});
|
||||
return false;
|
||||
},
|
||||
contextItemLabel: "labels.addToLibrary",
|
||||
});
|
||||
|
14
src/align.ts
14
src/align.ts
@ -1,13 +1,6 @@
|
||||
import { ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { getCommonBounds } from "./element";
|
||||
|
||||
interface Box {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||
|
||||
export interface Alignment {
|
||||
position: "start" | "center" | "end";
|
||||
@ -88,8 +81,3 @@ const calculateTranslation = (
|
||||
(groupBoundingBox[min] + groupBoundingBox[max]) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return { minX, minY, maxX, maxY };
|
||||
};
|
||||
|
@ -72,7 +72,7 @@ import {
|
||||
import { loadFromBlob } from "../data";
|
||||
import { isValidLibrary } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { restore, restoreElements } from "../data/restore";
|
||||
import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
|
||||
import {
|
||||
dragNewElement,
|
||||
dragSelectedElements,
|
||||
@ -658,7 +658,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
|
||||
)
|
||||
) {
|
||||
await this.library.importLibrary(blob);
|
||||
await this.library.importLibrary(blob, "published");
|
||||
// hack to rerender the library items after import
|
||||
if (this.state.isLibraryOpen) {
|
||||
this.setState({ isLibraryOpen: false });
|
||||
@ -732,7 +732,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
try {
|
||||
initialData = (await this.props.initialData) || null;
|
||||
if (initialData?.libraryItems) {
|
||||
this.libraryItemsFromStorage = initialData.libraryItems;
|
||||
this.libraryItemsFromStorage = restoreLibraryItems(
|
||||
initialData.libraryItems,
|
||||
"unpublished",
|
||||
) as LibraryItems;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
|
@ -7,10 +7,11 @@ import "./CheckboxItem.scss";
|
||||
export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}> = ({ children, checked, onChange }) => {
|
||||
className?: string;
|
||||
}> = ({ children, checked, onChange, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx("Checkbox", { "is-checked": checked })}
|
||||
className={clsx("Checkbox", className, { "is-checked": checked })}
|
||||
onClick={(event) => {
|
||||
onChange(!checked);
|
||||
(
|
||||
|
@ -18,7 +18,9 @@ export interface DialogProps {
|
||||
title: React.ReactNode;
|
||||
autofocus?: boolean;
|
||||
theme?: AppState["theme"];
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
export const Dialog = (props: DialogProps) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
@ -82,6 +84,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
maxWidth={props.small ? 550 : 800}
|
||||
onCloseRequest={onClose}
|
||||
theme={props.theme}
|
||||
closeOnClickOutside={props.closeOnClickOutside}
|
||||
>
|
||||
<Island ref={setIslandNode}>
|
||||
<h2 id={`${id}-dialog-title`} className="Dialog__title">
|
||||
|
@ -1,42 +1,6 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0;
|
||||
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
a {
|
||||
margin-inline-start: auto;
|
||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||
padding-inline-end: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.layer-ui__library-items {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layer-ui__wrapper {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
|
||||
|
@ -1,29 +1,15 @@
|
||||
import clsx from "clsx";
|
||||
import React, {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { CLASSES } from "../constants";
|
||||
import { exportCanvas } from "../data";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import { isTextElement, showSelectedShapeActions } from "../element";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { Language, t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import {
|
||||
AppProps,
|
||||
AppState,
|
||||
ExcalidrawProps,
|
||||
BinaryFiles,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
import { exportFile, load, trash } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./LayerUI.scss";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog";
|
||||
import { Section } from "./Section";
|
||||
import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { LibraryMenu } from "./LibraryMenu";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -81,302 +65,6 @@ interface LayerUIProps {
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
(ref.current.contains(event.target) ||
|
||||
!document.body.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cb(event);
|
||||
};
|
||||
document.addEventListener("pointerdown", listener, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", listener);
|
||||
};
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
setLibraryItems,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
}: {
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem;
|
||||
onRemoveFromLibrary: (index: number) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: (elements: LibraryItem) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
setLibraryItems: (library: LibraryItems) => void;
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
|
||||
const rows = [];
|
||||
let addedPendingElements = false;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
|
||||
rows.push(
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!!libraryItems.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportFile}
|
||||
onClick={() => {
|
||||
saveLibraryAsJSON(library)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={t("buttons.resetLibrary")}
|
||||
aria-label={t("buttons.resetLibrary")}
|
||||
icon={trash}
|
||||
onClick={() => {
|
||||
if (window.confirm(t("alerts.resetLibrary"))) {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href={`https://libraries.excalidraw.com?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>,
|
||||
);
|
||||
|
||||
for (let row = 0; row < numRows; row++) {
|
||||
const y = CELLS_PER_ROW * row;
|
||||
const children = [];
|
||||
for (let x = 0; x < CELLS_PER_ROW; x++) {
|
||||
const shouldAddPendingElements: boolean =
|
||||
pendingElements.length > 0 &&
|
||||
!addedPendingElements &&
|
||||
y + x >= libraryItems.length;
|
||||
addedPendingElements = addedPendingElements || shouldAddPendingElements;
|
||||
|
||||
children.push(
|
||||
<Stack.Col key={x}>
|
||||
<LibraryUnit
|
||||
elements={libraryItems[y + x]}
|
||||
files={files}
|
||||
pendingElements={
|
||||
shouldAddPendingElements ? pendingElements : undefined
|
||||
}
|
||||
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
|
||||
onClick={
|
||||
shouldAddPendingElements
|
||||
? onAddToLibrary.bind(null, pendingElements)
|
||||
: onInsertShape.bind(null, libraryItems[y + x])
|
||||
}
|
||||
/>
|
||||
</Stack.Col>,
|
||||
);
|
||||
}
|
||||
rows.push(
|
||||
<Stack.Row align="center" gap={1} key={row}>
|
||||
{children}
|
||||
</Stack.Row>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Col align="start" gap={1} className="layer-ui__library-items">
|
||||
{rows}
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryMenu = ({
|
||||
onClickOutside,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
}: {
|
||||
pendingElements: LibraryItem;
|
||||
onClickOutside: (event: MouseEvent) => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useOnClickOutside(ref, (event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
|
||||
return;
|
||||
}
|
||||
onClickOutside(event);
|
||||
});
|
||||
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||
|
||||
const [loadingState, setIsLoading] = useState<
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
]).then((data) => {
|
||||
if (data === "loading") {
|
||||
setIsLoading("loading");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
|
||||
const removeFromLibrary = useCallback(
|
||||
async (indexToRemove) => {
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = items.filter((_, index) => index !== indexToRemove);
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[library, setAppState],
|
||||
);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem) => {
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
});
|
||||
}
|
||||
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems = [...items, elements];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{loadingState === "loading" ? (
|
||||
<div className="layer-ui__library-message">
|
||||
{t("labels.libraryLoadingMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
setLibraryItems={setLibraryItems}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
@ -561,12 +249,15 @@ const LayerUI = ({
|
||||
</Section>
|
||||
);
|
||||
|
||||
const closeLibrary = useCallback(
|
||||
(event) => {
|
||||
const closeLibrary = useCallback(() => {
|
||||
const isDialogOpen = !!document.querySelector(".Dialog");
|
||||
|
||||
// Prevent closing if any dialog is open
|
||||
if (isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
setAppState({ isLibraryOpen: false });
|
||||
},
|
||||
[setAppState],
|
||||
);
|
||||
}, [setAppState]);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
setAppState({
|
||||
@ -578,7 +269,7 @@ const LayerUI = ({
|
||||
const libraryMenu = appState.isLibraryOpen ? (
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState)}
|
||||
onClickOutside={closeLibrary}
|
||||
onClose={closeLibrary}
|
||||
onInsertShape={onInsertElements}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
@ -588,6 +279,7 @@ const LayerUI = ({
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
appState={appState}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
55
src/components/LibraryMenu.scss
Normal file
55
src/components/LibraryMenu.scss
Normal file
@ -0,0 +1,55 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.layer-ui__library {
|
||||
margin: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.layer-ui__library-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 2px 0;
|
||||
|
||||
button {
|
||||
// 2px from the left to account for focus border of left-most button
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
a {
|
||||
margin-inline-start: auto;
|
||||
// 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
|
||||
padding-inline-end: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__library-message {
|
||||
padding: 10px 20px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.publish-library-success {
|
||||
.Dialog__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-close.ToolIcon_type_button {
|
||||
background-color: $oc-blue-6;
|
||||
align-self: flex-end;
|
||||
&:hover {
|
||||
background-color: $oc-blue-8;
|
||||
}
|
||||
.ToolIcon__icon {
|
||||
width: auto;
|
||||
font-size: 1rem;
|
||||
color: $oc-white;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
287
src/components/LibraryMenu.tsx
Normal file
287
src/components/LibraryMenu.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { useRef, useState, useEffect, useCallback, RefObject } from "react";
|
||||
import Library from "../data/library";
|
||||
import { t } from "../i18n";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
LibraryItems,
|
||||
LibraryItem,
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
} from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { Island } from "./Island";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./LibraryMenu.scss";
|
||||
import LibraryMenuItems from "./LibraryMenuItems";
|
||||
import { EVENT } from "../constants";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
const useOnClickOutside = (
|
||||
ref: RefObject<HTMLElement>,
|
||||
cb: (event: MouseEvent) => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent) => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.target instanceof Element &&
|
||||
(ref.current.contains(event.target) ||
|
||||
!document.body.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
cb(event);
|
||||
};
|
||||
document.addEventListener("pointerdown", listener, false);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", listener);
|
||||
};
|
||||
}, [ref, cb]);
|
||||
};
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
selectedItems: LibraryItem["id"][],
|
||||
) => libraryItems.filter((item) => selectedItems.includes(item.id));
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
focusContainer,
|
||||
library,
|
||||
id,
|
||||
appState,
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
focusContainer: () => void;
|
||||
library: Library;
|
||||
id: string;
|
||||
appState: AppState;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useOnClickOutside(ref, (event) => {
|
||||
// If click on the library icon, do nothing.
|
||||
if ((event.target as Element).closest(".ToolIcon__library")) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
|
||||
|
||||
const [loadingState, setIsLoading] = useState<
|
||||
"preloading" | "loading" | "ready"
|
||||
>("preloading");
|
||||
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
|
||||
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
|
||||
useState(false);
|
||||
const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
|
||||
url: string;
|
||||
authorName: string;
|
||||
}>(null);
|
||||
const loadingTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.race([
|
||||
new Promise((resolve) => {
|
||||
loadingTimerRef.current = window.setTimeout(() => {
|
||||
resolve("loading");
|
||||
}, 100);
|
||||
}),
|
||||
library.loadLibrary().then((items) => {
|
||||
setLibraryItems(items);
|
||||
setIsLoading("ready");
|
||||
}),
|
||||
]).then((data) => {
|
||||
if (data === "loading") {
|
||||
setIsLoading("loading");
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(loadingTimerRef.current!);
|
||||
};
|
||||
}, [library]);
|
||||
|
||||
const removeFromLibrary = useCallback(async () => {
|
||||
const items = await library.loadLibrary();
|
||||
|
||||
const nextItems = items.filter((item) => !selectedItems.includes(item.id));
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setLibraryItems(nextItems);
|
||||
}, [library, setAppState, selectedItems, setSelectedItems]);
|
||||
|
||||
const resetLibrary = useCallback(() => {
|
||||
library.resetLibrary();
|
||||
setLibraryItems([]);
|
||||
focusContainer();
|
||||
}, [library, focusContainer]);
|
||||
|
||||
const addToLibrary = useCallback(
|
||||
async (elements: LibraryItem["elements"]) => {
|
||||
if (elements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
});
|
||||
}
|
||||
const items = await library.loadLibrary();
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
status: "unpublished",
|
||||
elements,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
},
|
||||
...items,
|
||||
];
|
||||
onAddToLibrary();
|
||||
library.saveLibrary(nextItems).catch((error) => {
|
||||
setLibraryItems(items);
|
||||
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
|
||||
});
|
||||
setLibraryItems(nextItems);
|
||||
},
|
||||
[onAddToLibrary, library, setAppState],
|
||||
);
|
||||
|
||||
const renderPublishSuccess = useCallback(() => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={() => setPublishLibSuccess(null)}
|
||||
title={t("publishSuccessDialog.title")}
|
||||
className="publish-library-success"
|
||||
small={true}
|
||||
>
|
||||
<p>
|
||||
{t("publishSuccessDialog.content", {
|
||||
authorName: publishLibSuccess!.authorName,
|
||||
})}{" "}
|
||||
<a
|
||||
href={publishLibSuccess?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishSuccessDialog.link")}
|
||||
</a>
|
||||
</p>
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
label={t("buttons.close")}
|
||||
onClick={() => setPublishLibSuccess(null)}
|
||||
data-testid="publish-library-success-close"
|
||||
className="publish-library-success-close"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
nextLibItems.forEach((libItem) => {
|
||||
if (selectedItems.includes(libItem.id)) {
|
||||
libItem.status = "published";
|
||||
}
|
||||
});
|
||||
library.saveLibrary(nextLibItems);
|
||||
setLibraryItems(nextLibItems);
|
||||
},
|
||||
[
|
||||
setShowPublishLibraryDialog,
|
||||
setPublishLibSuccess,
|
||||
libraryItems,
|
||||
selectedItems,
|
||||
library,
|
||||
],
|
||||
);
|
||||
|
||||
return loadingState === "preloading" ? null : (
|
||||
<Island padding={1} ref={ref} className="layer-ui__library">
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(libraryItems, selectedItems)}
|
||||
appState={appState}
|
||||
onSuccess={onPublishLibSuccess}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() => library.saveLibrary(libraryItems)}
|
||||
onRemove={(id: string) =>
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
|
||||
{loadingState === "loading" ? (
|
||||
<div className="layer-ui__library-message">
|
||||
{t("labels.libraryLoadingMessage")}
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuItems
|
||||
libraryItems={libraryItems}
|
||||
onRemoveFromLibrary={removeFromLibrary}
|
||||
onAddToLibrary={addToLibrary}
|
||||
onInsertShape={onInsertShape}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
onToggle={(id) => {
|
||||
if (!selectedItems.includes(id)) {
|
||||
setSelectedItems([...selectedItems, id]);
|
||||
} else {
|
||||
setSelectedItems(selectedItems.filter((_id) => _id !== id));
|
||||
}
|
||||
}}
|
||||
onPublish={() => setShowPublishLibraryDialog(true)}
|
||||
resetLibrary={resetLibrary}
|
||||
/>
|
||||
)}
|
||||
</Island>
|
||||
);
|
||||
};
|
102
src/components/LibraryMenuItems.scss
Normal file
102
src/components/LibraryMenuItems.scss
Normal file
@ -0,0 +1,102 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
.library-menu-items-container {
|
||||
.library-actions {
|
||||
display: flex;
|
||||
|
||||
button .library-actions-counter {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
padding: 1px;
|
||||
font-size: 0.7rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
&--remove {
|
||||
background-color: $oc-red-7;
|
||||
&:hover {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-red-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
&--export {
|
||||
background-color: $oc-lime-5;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-lime-7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $oc-lime-8;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-lime-5;
|
||||
}
|
||||
}
|
||||
|
||||
&--publish {
|
||||
background-color: $oc-cyan-6;
|
||||
&:hover {
|
||||
background-color: $oc-cyan-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-cyan-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
label {
|
||||
margin-left: -0.2em;
|
||||
margin-right: 1.1em;
|
||||
color: $oc-white;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
.library-actions-counter {
|
||||
color: $oc-cyan-6;
|
||||
}
|
||||
}
|
||||
|
||||
&--load {
|
||||
background-color: $oc-blue-6;
|
||||
&:hover {
|
||||
background-color: $oc-blue-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-blue-9;
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
&__items {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.6em 0.2em;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
322
src/components/LibraryMenuItems.tsx
Normal file
322
src/components/LibraryMenuItems.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
import { chunk } from "lodash";
|
||||
import { useCallback, useState } from "react";
|
||||
import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
} from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { useIsMobile } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
|
||||
import { LibraryUnit } from "./LibraryUnit";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
|
||||
const LibraryMenuItems = ({
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
libraryReturnUrl,
|
||||
library,
|
||||
files,
|
||||
id,
|
||||
selectedItems,
|
||||
onToggle,
|
||||
onPublish,
|
||||
resetLibrary,
|
||||
}: {
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
library: Library;
|
||||
id: string;
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onToggle: (id: LibraryItem["id"]) => void;
|
||||
onPublish: () => void;
|
||||
resetLibrary: () => void;
|
||||
}) => {
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
: t("alerts.resetLibrary");
|
||||
const title = selectedItems.length
|
||||
? t("confirmDialog.removeItemsFromLib")
|
||||
: t("confirmDialog.resetLibrary");
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
if (selectedItems.length) {
|
||||
onRemoveFromLibrary();
|
||||
} else {
|
||||
resetLibrary();
|
||||
}
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowRemoveLibAlert(false);
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
<p>{content}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}, [selectedItems, onRemoveFromLibrary, resetLibrary]);
|
||||
|
||||
const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const renderLibraryActions = () => {
|
||||
const itemsSelected = !!selectedItems.length;
|
||||
const items = itemsSelected
|
||||
? libraryItems.filter((item) => selectedItems.includes(item.id))
|
||||
: libraryItems;
|
||||
const resetLabel = itemsSelected
|
||||
? t("buttons.remove")
|
||||
: t("buttons.resetLibrary");
|
||||
return (
|
||||
<div className="library-actions">
|
||||
{(!itemsSelected || !isMobile) && (
|
||||
<ToolButton
|
||||
key="import"
|
||||
type="button"
|
||||
title={t("buttons.load")}
|
||||
aria-label={t("buttons.load")}
|
||||
icon={load}
|
||||
onClick={() => {
|
||||
importLibraryFromJSON(library)
|
||||
.then(() => {
|
||||
// Close and then open to get the libraries updated
|
||||
setAppState({ isLibraryOpen: false });
|
||||
setAppState({ isLibraryOpen: true });
|
||||
})
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
className="library-actions--load"
|
||||
/>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<ToolButton
|
||||
key="export"
|
||||
type="button"
|
||||
title={t("buttons.export")}
|
||||
aria-label={t("buttons.export")}
|
||||
icon={exportToFileIcon}
|
||||
onClick={async () => {
|
||||
const libraryItems = itemsSelected
|
||||
? items
|
||||
: await library.loadLibrary();
|
||||
saveLibraryAsJSON(libraryItems)
|
||||
.catch(muteFSAbortError)
|
||||
.catch((error) => {
|
||||
setAppState({ errorMessage: error.message });
|
||||
});
|
||||
}}
|
||||
className="library-actions--export"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
<ToolButton
|
||||
key="reset"
|
||||
type="button"
|
||||
title={resetLabel}
|
||||
aria-label={resetLabel}
|
||||
icon={trash}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
className="library-actions--remove"
|
||||
>
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && !isPublished && (
|
||||
<Tooltip label={t("hints.publishLibrary")}>
|
||||
<ToolButton
|
||||
type="button"
|
||||
aria-label={t("buttons.publishLibrary")}
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
className="library-actions--publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
{!isMobile && <label>{t("buttons.publishLibrary")}</label>}
|
||||
{selectedItems.length > 0 && (
|
||||
<span className="library-actions-counter">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
)}
|
||||
</ToolButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CELLS_PER_ROW = isMobile ? 4 : 6;
|
||||
|
||||
const referrer =
|
||||
libraryReturnUrl || window.location.origin + window.location.pathname;
|
||||
const isPublished = selectedItems.some(
|
||||
(id) => libraryItems.find((item) => item.id === id)?.status === "published",
|
||||
);
|
||||
|
||||
const createLibraryItemCompo = (params: {
|
||||
item:
|
||||
| LibraryItem
|
||||
| /* pending library item */ {
|
||||
id: null;
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
}
|
||||
| null;
|
||||
onClick?: () => void;
|
||||
key: string;
|
||||
}) => {
|
||||
return (
|
||||
<Stack.Col key={params.key}>
|
||||
<LibraryUnit
|
||||
elements={params.item?.elements}
|
||||
files={files}
|
||||
isPending={!params.item?.id && !!params.item?.elements}
|
||||
onClick={params.onClick || (() => {})}
|
||||
id={params.item?.id || null}
|
||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||
onToggle={() => {
|
||||
if (params.item?.id) {
|
||||
onToggle(params.item.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLibrarySection = (
|
||||
items: (
|
||||
| LibraryItem
|
||||
| /* pending library item */ {
|
||||
id: null;
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
}
|
||||
)[],
|
||||
) => {
|
||||
const _items = items.map((item) => {
|
||||
if (item.id) {
|
||||
return createLibraryItemCompo({
|
||||
item,
|
||||
onClick: () => onInsertShape(item.elements),
|
||||
key: item.id,
|
||||
});
|
||||
}
|
||||
return createLibraryItemCompo({
|
||||
key: "__pending__item__",
|
||||
item,
|
||||
onClick: () => onAddToLibrary(pendingElements),
|
||||
});
|
||||
});
|
||||
|
||||
// ensure we render all empty cells if no items are present
|
||||
let rows = chunk(_items, CELLS_PER_ROW);
|
||||
if (!rows.length) {
|
||||
rows = [[]];
|
||||
}
|
||||
|
||||
return rows.map((rowItems, index, rows) => {
|
||||
if (index === rows.length - 1) {
|
||||
// pad row with empty cells
|
||||
rowItems = rowItems.concat(
|
||||
new Array(CELLS_PER_ROW - rowItems.length)
|
||||
.fill(null)
|
||||
.map((_, index) => {
|
||||
return createLibraryItemCompo({
|
||||
key: `empty_${index}`,
|
||||
item: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack.Row align="center" gap={1} key={index}>
|
||||
{rowItems}
|
||||
</Stack.Row>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const publishedItems = libraryItems.filter(
|
||||
(item) => item.status === "published",
|
||||
);
|
||||
const unpublishedItems = [
|
||||
// append pending library item
|
||||
...(pendingElements.length
|
||||
? [{ id: null, elements: pendingElements }]
|
||||
: []),
|
||||
...libraryItems.filter((item) => item.status !== "published"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="library-menu-items-container">
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
<div className="layer-ui__library-header" key="library-header">
|
||||
{renderLibraryActions()}
|
||||
<a
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
|
||||
target="_excalidraw_libraries"
|
||||
>
|
||||
{t("labels.libraries")}
|
||||
</a>
|
||||
</div>
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
gap={1}
|
||||
>
|
||||
<>
|
||||
<div className="separator">{t("labels.personalLib")}</div>
|
||||
{renderLibrarySection(unpublishedItems)}
|
||||
</>
|
||||
|
||||
<>
|
||||
<div className="separator">{t("labels.excalidrawLib")} </div>
|
||||
|
||||
{renderLibrarySection(publishedItems)}
|
||||
</>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryMenuItems;
|
@ -1,3 +1,5 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.library-unit {
|
||||
align-items: center;
|
||||
@ -7,6 +9,20 @@
|
||||
position: relative;
|
||||
width: 63px;
|
||||
height: 63px; // match width
|
||||
|
||||
&--hover {
|
||||
box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
|
||||
border-color: $oc-blue-5;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
|
||||
border-color: $oc-blue-8;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark .library-unit {
|
||||
border-color: rgb(48, 48, 48);
|
||||
}
|
||||
|
||||
.library-unit__dragger {
|
||||
@ -22,9 +38,9 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.library-unit__removeFromLibrary,
|
||||
.library-unit__removeFromLibrary:hover,
|
||||
.library-unit__removeFromLibrary:active {
|
||||
.library-unit__checkbox-container,
|
||||
.library-unit__checkbox-container:hover,
|
||||
.library-unit__checkbox-container:active {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
@ -32,10 +48,35 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding: 0.5rem;
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
left: 2rem;
|
||||
bottom: 2rem;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.library-unit__checkbox {
|
||||
position: absolute;
|
||||
left: 2.3rem;
|
||||
bottom: 2.3rem;
|
||||
|
||||
.Checkbox-box {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
margin: 0.5em 0.5em 0.2em 0.2em;
|
||||
background-color: $oc-blue-1;
|
||||
}
|
||||
|
||||
&.Checkbox:hover {
|
||||
.Checkbox-box {
|
||||
background-color: $oc-blue-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-unit__removeFromLibrary > svg {
|
||||
@ -43,29 +84,32 @@
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.library-unit__pulse {
|
||||
.library-unit__adder {
|
||||
transform: scale(1);
|
||||
animation: library-unit__pulse-animation 1s ease-in infinite;
|
||||
animation: library-unit__adder-animation 1s ease-in infinite;
|
||||
}
|
||||
|
||||
.library-unit__adder {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
left: 40%;
|
||||
top: 40%;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.library-unit--hover .library-unit__adder {
|
||||
color: $oc-blue-7;
|
||||
}
|
||||
|
||||
.library-unit__active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes library-unit__pulse-animation {
|
||||
@keyframes library-unit__adder-animation {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
50% {
|
||||
@ -73,7 +117,7 @@
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
transform: scale(0.85);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { close } from "../components/icons";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { useIsMobile } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
import "./LibraryUnit.scss";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
// fa-plus
|
||||
const PLUS_ICON = (
|
||||
@ -20,17 +19,21 @@ const PLUS_ICON = (
|
||||
);
|
||||
|
||||
export const LibraryUnit = ({
|
||||
id,
|
||||
elements,
|
||||
files,
|
||||
pendingElements,
|
||||
onRemoveFromLibrary,
|
||||
isPending,
|
||||
onClick,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
elements?: LibraryItem;
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
files: BinaryFiles;
|
||||
pendingElements?: LibraryItem;
|
||||
onRemoveFromLibrary: () => void;
|
||||
isPending?: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@ -40,12 +43,11 @@ export const LibraryUnit = ({
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const elementsToRender = elements || pendingElements;
|
||||
if (!elementsToRender) {
|
||||
if (!elements) {
|
||||
return;
|
||||
}
|
||||
const svg = await exportToSvg(
|
||||
elementsToRender,
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
@ -58,30 +60,31 @@ export const LibraryUnit = ({
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [elements, pendingElements, files]);
|
||||
}, [elements, files]);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const adder = (isHovered || isMobile) && pendingElements && (
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PLUS_ICON}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("library-unit", {
|
||||
"library-unit__active": elements || pendingElements,
|
||||
"library-unit__active": elements,
|
||||
"library-unit--hover": elements && isHovered,
|
||||
"library-unit--selected": selected,
|
||||
})}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={clsx("library-unit__dragger", {
|
||||
"library-unit__pulse": !!pendingElements,
|
||||
"library-unit__pulse": !!isPending,
|
||||
})}
|
||||
ref={ref}
|
||||
draggable={!!elements}
|
||||
onClick={!!elements || !!pendingElements ? onClick : undefined}
|
||||
onClick={!!elements || !!isPending ? onClick : undefined}
|
||||
onDragStart={(event) => {
|
||||
setIsHovered(false);
|
||||
event.dataTransfer.setData(
|
||||
@ -91,14 +94,12 @@ export const LibraryUnit = ({
|
||||
}}
|
||||
/>
|
||||
{adder}
|
||||
{elements && (isHovered || isMobile) && (
|
||||
<button
|
||||
className="library-unit__removeFromLibrary"
|
||||
aria-label={t("labels.removeFromLibrary")}
|
||||
onClick={onRemoveFromLibrary}
|
||||
>
|
||||
{close}
|
||||
</button>
|
||||
{id && elements && (isHovered || isMobile || selected) && (
|
||||
<CheckboxItem
|
||||
checked={selected}
|
||||
onChange={() => onToggle(id)}
|
||||
className="library-unit__checkbox"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -15,8 +15,9 @@ export const Modal = (props: {
|
||||
onCloseRequest(): void;
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
closeOnClickOutside?: boolean;
|
||||
}) => {
|
||||
const { theme = THEME.LIGHT } = props;
|
||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
if (!modalRoot) {
|
||||
@ -39,7 +40,10 @@ export const Modal = (props: {
|
||||
onKeyDown={handleKeydown}
|
||||
aria-labelledby={props.labelledBy}
|
||||
>
|
||||
<div className="Modal__background" onClick={props.onCloseRequest}></div>
|
||||
<div
|
||||
className="Modal__background"
|
||||
onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
|
||||
></div>
|
||||
<div
|
||||
className="Modal__content"
|
||||
style={{ "--max-width": `${props.maxWidth}px` }}
|
||||
|
@ -82,7 +82,7 @@ export const PasteChartDialog = ({
|
||||
appState: AppState;
|
||||
onClose: () => void;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onInsertChart: (elements: LibraryItem) => void;
|
||||
onInsertChart: (elements: LibraryItem["elements"]) => void;
|
||||
}) => {
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (onClose) {
|
||||
|
@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
|
||||
</label>
|
||||
{props.isNameEditable ? (
|
||||
<input
|
||||
type="text"
|
||||
className="TextInput"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
92
src/components/PublishLibrary.scss
Normal file
92
src/components/PublishLibrary.scss
Normal file
@ -0,0 +1,92 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.publish-library {
|
||||
&__fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
label {
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
span {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
color: $oc-gray-6;
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
width: 70%;
|
||||
padding: 0.6em;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: $oc-red-8;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
padding: 0.2rem 0;
|
||||
justify-content: flex-end;
|
||||
|
||||
.ToolIcon__icon {
|
||||
min-width: 2.5rem;
|
||||
width: auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ToolIcon_type_button {
|
||||
margin-left: 1rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
&--confirm.ToolIcon_type_button {
|
||||
background-color: $oc-blue-6;
|
||||
|
||||
&:hover {
|
||||
background-color: $oc-blue-8;
|
||||
}
|
||||
}
|
||||
|
||||
&--cancel.ToolIcon_type_button {
|
||||
background-color: $oc-gray-5;
|
||||
&:hover {
|
||||
background-color: $oc-gray-6;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
color: $oc-white;
|
||||
.Spinner {
|
||||
--spinner-color: #fff;
|
||||
svg {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-library-items {
|
||||
display: flex;
|
||||
padding: 0 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.single-library-item-wrapper {
|
||||
width: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-note {
|
||||
padding: 1em;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
430
src/components/PublishLibrary.tsx
Normal file
430
src/components/PublishLibrary.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import oc from "open-color";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||
import { exportToBlob } from "../packages/utils";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
|
||||
import { ExportedLibraryData } from "../data/types";
|
||||
|
||||
import "./PublishLibrary.scss";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import SingleLibraryItem from "./SingleLibraryItem";
|
||||
|
||||
interface PublishLibraryDataParams {
|
||||
authorName: string;
|
||||
githubHandle: string;
|
||||
name: string;
|
||||
description: string;
|
||||
twitterHandle: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
|
||||
|
||||
const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
|
||||
JSON.stringify(data),
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const importPublishLibDataFromStorage = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||
if (data) {
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const PublishLibrary = ({
|
||||
onClose,
|
||||
libraryItems,
|
||||
appState,
|
||||
onSuccess,
|
||||
onError,
|
||||
updateItemsInStorage,
|
||||
onRemove,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
libraryItems: LibraryItems;
|
||||
appState: AppState;
|
||||
onSuccess: (data: {
|
||||
url: string;
|
||||
authorName: string;
|
||||
items: LibraryItems;
|
||||
}) => void;
|
||||
|
||||
onError: (error: Error) => void;
|
||||
updateItemsInStorage: (items: LibraryItems) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) => {
|
||||
const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
|
||||
authorName: "",
|
||||
githubHandle: "",
|
||||
name: "",
|
||||
description: "",
|
||||
twitterHandle: "",
|
||||
website: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const data = importPublishLibDataFromStorage();
|
||||
if (data) {
|
||||
setLibraryData(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
|
||||
libraryItems.slice(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setClonedLibItems(libraryItems.slice());
|
||||
}, [libraryItems]);
|
||||
|
||||
const onInputChange = (event: any) => {
|
||||
setLibraryData({
|
||||
...libraryData,
|
||||
[event.target.name]: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const erroredLibItems: LibraryItem[] = [];
|
||||
let isError = false;
|
||||
clonedLibItems.forEach((libItem) => {
|
||||
let error = "";
|
||||
if (!libItem.name) {
|
||||
error = t("publishDialog.errors.required");
|
||||
isError = true;
|
||||
} else if (!/^[a-zA-Z\s]+$/i.test(libItem.name)) {
|
||||
error = t("publishDialog.errors.letter&Spaces");
|
||||
isError = true;
|
||||
}
|
||||
erroredLibItems.push({ ...libItem, error });
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
setClonedLibItems(erroredLibItems);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
clonedLibItems.forEach((libItem) => {
|
||||
const boundingBox = getCommonBoundingBox(libItem.elements);
|
||||
const width = boundingBox.maxX - boundingBox.minX + 30;
|
||||
const height = boundingBox.maxY - boundingBox.minY + 30;
|
||||
const offset = {
|
||||
x: prevBoundingBox.maxX - boundingBox.minX,
|
||||
y: prevBoundingBox.maxY - boundingBox.minY,
|
||||
};
|
||||
|
||||
const itemsWithUpdatedCoords = libItem.elements.map((element) => {
|
||||
element = mutateElement(element, {
|
||||
x: element.x + offset.x + 15,
|
||||
y: element.y + offset.y + 15,
|
||||
});
|
||||
return element;
|
||||
});
|
||||
const items = [
|
||||
...itemsWithUpdatedCoords,
|
||||
newElement({
|
||||
type: "rectangle",
|
||||
width,
|
||||
height,
|
||||
x: prevBoundingBox.maxX,
|
||||
y: prevBoundingBox.maxY,
|
||||
strokeColor: "#ced4da",
|
||||
backgroundColor: "transparent",
|
||||
strokeStyle: "solid",
|
||||
opacity: 100,
|
||||
roughness: 0,
|
||||
strokeSharpness: "sharp",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
}),
|
||||
];
|
||||
elements.push(...items);
|
||||
prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
|
||||
});
|
||||
const png = await exportToBlob({
|
||||
elements,
|
||||
mimeType: "image/png",
|
||||
appState: {
|
||||
...appState,
|
||||
viewBackgroundColor: oc.white,
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
});
|
||||
|
||||
const libContent: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 2,
|
||||
source: EXPORT_SOURCE,
|
||||
libraryItems: clonedLibItems,
|
||||
};
|
||||
const content = JSON.stringify(libContent, null, 2);
|
||||
const lib = new Blob([content], { type: "application/json" });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("excalidrawLib", lib);
|
||||
formData.append("excalidrawPng", png!);
|
||||
formData.append("title", libraryData.name);
|
||||
formData.append("authorName", libraryData.authorName);
|
||||
formData.append("githubHandle", libraryData.githubHandle);
|
||||
formData.append("name", libraryData.name);
|
||||
formData.append("description", libraryData.description);
|
||||
formData.append("twitterHandle", libraryData.twitterHandle);
|
||||
formData.append("website", libraryData.website);
|
||||
|
||||
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
||||
method: "post",
|
||||
body: formData,
|
||||
})
|
||||
.then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
return response.json().then(({ url }) => {
|
||||
// flush data from local storage
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
|
||||
onSuccess({
|
||||
url,
|
||||
authorName: libraryData.authorName,
|
||||
items: clonedLibItems,
|
||||
});
|
||||
});
|
||||
}
|
||||
return response
|
||||
.json()
|
||||
.catch(() => {
|
||||
throw new Error(response.statusText || "something went wrong");
|
||||
})
|
||||
.then((error) => {
|
||||
throw new Error(
|
||||
error.message || response.statusText || "something went wrong",
|
||||
);
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
onError(err);
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
onError(err);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const renderLibraryItems = () => {
|
||||
const items: ReactNode[] = [];
|
||||
clonedLibItems.forEach((libItem, index) => {
|
||||
items.push(
|
||||
<div className="single-library-item-wrapper" key={index}>
|
||||
<SingleLibraryItem
|
||||
libItem={libItem}
|
||||
appState={appState}
|
||||
index={index}
|
||||
onChange={(val, index) => {
|
||||
const items = clonedLibItems.slice();
|
||||
items[index].name = val;
|
||||
setClonedLibItems(items);
|
||||
}}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
return <div className="selected-library-items">{items}</div>;
|
||||
};
|
||||
|
||||
const onDialogClose = useCallback(() => {
|
||||
updateItemsInStorage(clonedLibItems);
|
||||
savePublishLibDataToStorage(libraryData);
|
||||
onClose();
|
||||
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
|
||||
|
||||
const shouldRenderForm = !!libraryItems.length;
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onDialogClose}
|
||||
title={t("publishDialog.title")}
|
||||
className="publish-library"
|
||||
>
|
||||
{shouldRenderForm ? (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteDescription.pre")}
|
||||
<a
|
||||
href="https://libraries.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteDescription.link")}
|
||||
</a>{" "}
|
||||
{t("publishDialog.noteDescription.post")}
|
||||
</div>
|
||||
<span className="publish-library-note">
|
||||
{t("publishDialog.noteGuidelines.pre")}
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteGuidelines.link")}
|
||||
</a>
|
||||
{t("publishDialog.noteGuidelines.post")}
|
||||
</span>
|
||||
|
||||
<div className="publish-library-note">
|
||||
{t("publishDialog.noteItems")}
|
||||
</div>
|
||||
{renderLibraryItems()}
|
||||
<div className="publish-library__fields">
|
||||
<label>
|
||||
<div>
|
||||
<span>{t("publishDialog.libraryName")}</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
value={libraryData.name}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.libraryName")}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<span>{t("publishDialog.libraryDesc")}</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={4}
|
||||
required
|
||||
value={libraryData.description}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.libraryDesc")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div>
|
||||
<span>{t("publishDialog.authorName")}</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="authorName"
|
||||
required
|
||||
value={libraryData.authorName}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.authorName")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("publishDialog.githubUsername")}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="githubHandle"
|
||||
value={libraryData.githubHandle}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.githubHandle")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("publishDialog.twitterUsername")}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="twitterHandle"
|
||||
value={libraryData.twitterHandle}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.twitterHandle")}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t("publishDialog.website")}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={libraryData.website}
|
||||
onChange={onInputChange}
|
||||
placeholder={t("publishDialog.placeholder.website")}
|
||||
/>
|
||||
</label>
|
||||
<span className="publish-library-note">
|
||||
{t("publishDialog.noteLicense.pre")}
|
||||
<a
|
||||
href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("publishDialog.noteLicense.link")}
|
||||
</a>
|
||||
{t("publishDialog.noteLicense.post")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="publish-library__buttons">
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.cancel")}
|
||||
aria-label={t("buttons.cancel")}
|
||||
label={t("buttons.cancel")}
|
||||
onClick={onDialogClose}
|
||||
data-testid="cancel-clear-canvas-button"
|
||||
className="publish-library__buttons--cancel"
|
||||
/>
|
||||
<ToolButton
|
||||
type="submit"
|
||||
title={t("buttons.submit")}
|
||||
aria-label={t("buttons.submit")}
|
||||
label={t("buttons.submit")}
|
||||
className="publish-library__buttons--confirm"
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
|
||||
{t("publishDialog.atleastOneLibItem")}
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishLibrary;
|
66
src/components/SingleLibraryItem.scss
Normal file
66
src/components/SingleLibraryItem.scss
Normal file
@ -0,0 +1,66 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.single-library-item {
|
||||
position: relative;
|
||||
&__svg {
|
||||
width: 7.5rem;
|
||||
height: 7.5rem;
|
||||
border: 1px solid var(--button-gray-2);
|
||||
margin: 0.3rem;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon__icon {
|
||||
background-color: $oc-white;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.ToolIcon,
|
||||
.ToolIcon_type_button:hover {
|
||||
background-color: white;
|
||||
}
|
||||
.required,
|
||||
.error {
|
||||
color: $oc-red-8;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.error {
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
padding: 0.3em 0;
|
||||
}
|
||||
|
||||
&--remove {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 1.3rem;
|
||||
|
||||
.ToolIcon__icon {
|
||||
margin: 0;
|
||||
}
|
||||
.ToolIcon__icon {
|
||||
background-color: $oc-red-6;
|
||||
&:hover {
|
||||
background-color: $oc-red-7;
|
||||
}
|
||||
&:active {
|
||||
background-color: $oc-red-8;
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: $oc-white;
|
||||
padding: 0.26rem;
|
||||
border-radius: 0.3em;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
99
src/components/SingleLibraryItem.tsx
Normal file
99
src/components/SingleLibraryItem.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { exportToSvg } from "../packages/utils";
|
||||
import { AppState, LibraryItem } from "../types";
|
||||
import { close } from "./icons";
|
||||
|
||||
import "./SingleLibraryItem.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
const SingleLibraryItem = ({
|
||||
libItem,
|
||||
appState,
|
||||
index,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
libItem: LibraryItem;
|
||||
appState: AppState;
|
||||
index: number;
|
||||
onChange: (val: string, index: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) => {
|
||||
const svgRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = svgRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const svg = await exportToSvg({
|
||||
elements: libItem.elements,
|
||||
appState: {
|
||||
...appState,
|
||||
viewBackgroundColor: oc.white,
|
||||
exportBackground: true,
|
||||
},
|
||||
files: null,
|
||||
});
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
}, [libItem.elements, appState]);
|
||||
|
||||
return (
|
||||
<div className="single-library-item">
|
||||
<div ref={svgRef} className="single-library-item__svg" />
|
||||
<ToolButton
|
||||
aria-label={t("buttons.remove")}
|
||||
type="button"
|
||||
icon={close}
|
||||
className="single-library-item--remove"
|
||||
onClick={onRemove.bind(null, libItem.id)}
|
||||
title={t("buttons.remove")}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: "0.8rem 0.3rem",
|
||||
width: "100%",
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "0.5em 0" }}>
|
||||
<span style={{ fontWeight: 500, color: oc.gray[6] }}>
|
||||
{t("publishDialog.itemName")}
|
||||
</span>
|
||||
<span aria-hidden="true" className="required">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
style={{ width: "80%", padding: "0.2rem" }}
|
||||
defaultValue={libItem.name}
|
||||
placeholder="Item name"
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value, index);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span className="error">{libItem.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleLibraryItem;
|
@ -2,24 +2,6 @@
|
||||
|
||||
.excalidraw {
|
||||
.TextInput {
|
||||
color: var(--text-primary-color);
|
||||
display: inline-block;
|
||||
border: 1.5px solid var(--button-gray-1);
|
||||
line-height: 1;
|
||||
padding: 0.75rem;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--space-factor);
|
||||
background-color: var(--input-bg-color);
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: var(--input-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
||||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
type ToolButtonProps =
|
||||
@ -33,6 +34,11 @@ type ToolButtonProps =
|
||||
children?: React.ReactNode;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "submit";
|
||||
children?: React.ReactNode;
|
||||
onClick?(event: React.MouseEvent): void;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "icon";
|
||||
children?: React.ReactNode;
|
||||
@ -82,7 +88,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
|
||||
const lastPointerTypeRef = useRef<PointerType | null>(null);
|
||||
|
||||
if (props.type === "button" || props.type === "icon") {
|
||||
if (
|
||||
props.type === "button" ||
|
||||
props.type === "icon" ||
|
||||
props.type === "submit"
|
||||
) {
|
||||
const type = (props.type === "icon" ? "button" : props.type) as
|
||||
| "button"
|
||||
| "submit";
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@ -102,10 +115,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
type="button"
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
ref={innerRef}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || props.isLoading}
|
||||
>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
@ -115,6 +128,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
{props.keyBindingLabel}
|
||||
</span>
|
||||
)}
|
||||
{props.isLoading && <Spinner />}
|
||||
</div>
|
||||
)}
|
||||
{props.showAriaLabel && (
|
||||
|
@ -85,6 +85,7 @@ export const clipboard = createIcon(
|
||||
|
||||
export const trash = createIcon(
|
||||
"M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
|
||||
|
||||
{ width: 448, height: 512 },
|
||||
);
|
||||
|
||||
@ -882,3 +883,11 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
{ width: 448, height: 512 },
|
||||
),
|
||||
);
|
||||
|
||||
export const publishIcon = createIcon(
|
||||
<path
|
||||
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
||||
fill="currentColor"
|
||||
/>,
|
||||
{ width: 640, height: 512 },
|
||||
);
|
||||
|
@ -517,6 +517,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea:not(.excalidraw-wysiwyg) {
|
||||
color: var(--text-primary-color);
|
||||
border: 1.5px solid var(--input-border-color);
|
||||
padding: 0.75rem;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--space-factor);
|
||||
background-color: var(--input-bg-color);
|
||||
|
||||
&:not(:focus) {
|
||||
&:hover {
|
||||
background-color: var(--input-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.App-bottom-bar,
|
||||
.FixedSideContainer,
|
||||
|
@ -16,7 +16,7 @@
|
||||
--icon-green-fill-color: #{$oc-green-9};
|
||||
--default-bg-color: #{$oc-white};
|
||||
--input-bg-color: #{$oc-white};
|
||||
--input-border-color: #{$oc-gray-3};
|
||||
--input-border-color: #{$oc-gray-4};
|
||||
--input-hover-bg-color: #{$oc-gray-1};
|
||||
--input-label-color: #{$oc-gray-7};
|
||||
--island-bg-color: rgba(255, 255, 255, 0.96);
|
||||
@ -64,6 +64,7 @@
|
||||
--input-label-color: #{$oc-gray-2};
|
||||
--island-bg-color: rgba(30, 30, 30, 0.98);
|
||||
--keybinding-color: #{$oc-gray-6};
|
||||
--link-color: #{$oc-blue-4};
|
||||
--overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
|
||||
--popup-bg-color: #2c2c2c;
|
||||
--popup-secondary-bg-color: #222;
|
||||
|
@ -3,7 +3,7 @@ import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForDatabase, clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||
|
||||
import {
|
||||
@ -114,17 +114,16 @@ export const isValidLibrary = (json: any) => {
|
||||
typeof json === "object" &&
|
||||
json &&
|
||||
json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
|
||||
json.version === 1
|
||||
(json.version === 1 || json.version === 2)
|
||||
);
|
||||
};
|
||||
|
||||
export const saveLibraryAsJSON = async (library: Library) => {
|
||||
const libraryItems = await library.loadLibrary();
|
||||
export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
|
||||
const data: ExportedLibraryData = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||
version: 1,
|
||||
version: 2,
|
||||
source: EXPORT_SOURCE,
|
||||
library: libraryItems,
|
||||
libraryItems,
|
||||
};
|
||||
const serialized = JSON.stringify(data, null, 2);
|
||||
await fileSave(
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { loadLibraryFromBlob } from "./blob";
|
||||
import { LibraryItems, LibraryItem } from "../types";
|
||||
import { restoreElements } from "./restore";
|
||||
import { restoreElements, restoreLibraryItems } from "./restore";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import type App from "../components/App";
|
||||
|
||||
@ -18,14 +18,16 @@ class Library {
|
||||
};
|
||||
|
||||
restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
|
||||
const elements = getNonDeletedElements(restoreElements(libraryItem, null));
|
||||
return elements.length ? elements : null;
|
||||
const elements = getNonDeletedElements(
|
||||
restoreElements(libraryItem.elements, null),
|
||||
);
|
||||
return elements.length ? { ...libraryItem, elements } : null;
|
||||
};
|
||||
|
||||
/** imports library (currently merges, removing duplicates) */
|
||||
async importLibrary(blob: Blob) {
|
||||
async importLibrary(blob: Blob, defaultStatus = "unpublished") {
|
||||
const libraryFile = await loadLibraryFromBlob(blob);
|
||||
if (!libraryFile || !libraryFile.library) {
|
||||
if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -37,17 +39,17 @@ class Library {
|
||||
targetLibraryItem: LibraryItem,
|
||||
) => {
|
||||
return !existingLibraryItems.find((libraryItem) => {
|
||||
if (libraryItem.length !== targetLibraryItem.length) {
|
||||
if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// detect z-index difference by checking the excalidraw elements
|
||||
// are in order
|
||||
return libraryItem.every((libItemExcalidrawItem, idx) => {
|
||||
return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
|
||||
return (
|
||||
libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
|
||||
libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
|
||||
libItemExcalidrawItem.versionNonce ===
|
||||
targetLibraryItem[idx].versionNonce
|
||||
targetLibraryItem.elements[idx].versionNonce
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -55,15 +57,20 @@ class Library {
|
||||
|
||||
const existingLibraryItems = await this.loadLibrary();
|
||||
|
||||
const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
|
||||
const restoredItem = this.restoreLibraryItem(libraryItem);
|
||||
const library = libraryFile.libraryItems || libraryFile.library || [];
|
||||
const restoredLibItems = restoreLibraryItems(
|
||||
library,
|
||||
defaultStatus as "published" | "unpublished",
|
||||
);
|
||||
const filteredItems = [];
|
||||
for (const item of restoredLibItems) {
|
||||
const restoredItem = this.restoreLibraryItem(item as LibraryItem);
|
||||
if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
|
||||
acc.push(restoredItem);
|
||||
filteredItems.push(restoredItem);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, [] as Mutable<LibraryItems>);
|
||||
|
||||
await this.saveLibrary([...existingLibraryItems, ...filtered]);
|
||||
await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
|
||||
}
|
||||
|
||||
loadLibrary = (): Promise<LibraryItems> => {
|
||||
|
@ -3,7 +3,12 @@ import {
|
||||
ExcalidrawSelectionElement,
|
||||
FontFamilyValues,
|
||||
} from "../element/types";
|
||||
import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
|
||||
import {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
LibraryItem,
|
||||
NormalizedZoomValue,
|
||||
} from "../types";
|
||||
import { ImportedDataState } from "./types";
|
||||
import {
|
||||
getElementMap,
|
||||
@ -273,3 +278,30 @@ export const restore = (
|
||||
files: data?.files || {},
|
||||
};
|
||||
};
|
||||
|
||||
export const restoreLibraryItems = (
|
||||
libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
|
||||
defaultStatus: LibraryItem["status"],
|
||||
) => {
|
||||
const restoredItems: LibraryItem[] = [];
|
||||
for (const item of libraryItems) {
|
||||
// migrate older libraries
|
||||
if (Array.isArray(item)) {
|
||||
restoredItems.push({
|
||||
status: defaultStatus,
|
||||
elements: item,
|
||||
id: randomId(),
|
||||
created: Date.now(),
|
||||
});
|
||||
} else {
|
||||
const _item = item as MarkOptional<LibraryItem, "id" | "status">;
|
||||
restoredItems.push({
|
||||
..._item,
|
||||
id: _item.id || randomId(),
|
||||
status: _item.status || defaultStatus,
|
||||
created: _item.created || Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return restoredItems;
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
|
||||
import type { cleanAppStateForExport } from "../appState";
|
||||
|
||||
export interface ExportedDataState {
|
||||
@ -18,15 +18,18 @@ export interface ImportedDataState {
|
||||
elements?: readonly ExcalidrawElement[] | null;
|
||||
appState?: Readonly<Partial<AppState>> | null;
|
||||
scrollToContent?: boolean;
|
||||
libraryItems?: LibraryItems;
|
||||
libraryItems?: LibraryItems | LibraryItems_v1;
|
||||
files?: BinaryFiles;
|
||||
}
|
||||
|
||||
export interface ExportedLibraryData {
|
||||
type: string;
|
||||
version: number;
|
||||
version: 2;
|
||||
source: string;
|
||||
library: LibraryItems;
|
||||
libraryItems: LibraryItems;
|
||||
}
|
||||
|
||||
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
|
||||
export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
|
||||
/** @deprecated v1 */
|
||||
library?: LibraryItems;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
ExcalidrawLinearElement,
|
||||
Arrowhead,
|
||||
ExcalidrawFreeDrawElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
@ -513,3 +514,17 @@ export const getClosestElementBounds = (
|
||||
|
||||
return getElementBounds(closestElement);
|
||||
};
|
||||
|
||||
export interface Box {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): Box => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return { minX, minY, maxX, maxY };
|
||||
};
|
||||
|
@ -108,6 +108,7 @@ export const textWysiwyg = ({
|
||||
editable.dataset.type = "wysiwyg";
|
||||
// prevent line wrapping on Safari
|
||||
editable.wrap = "off";
|
||||
editable.classList.add("excalidraw-wysiwyg");
|
||||
|
||||
Object.assign(editable.style, {
|
||||
position: "absolute",
|
||||
|
@ -6,7 +6,7 @@
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.RoomDialog-link {
|
||||
input.RoomDialog-link {
|
||||
color: var(--text-primary-color);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
@ -14,8 +14,6 @@
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
padding: 0 0.5rem;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--space-factor);
|
||||
@ -55,10 +53,7 @@
|
||||
margin-top: 0.5em;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
height: 2.5rem;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.RoomDialog-sessionStartButtonContainer {
|
||||
|
@ -124,6 +124,7 @@ const RoomDialog = ({
|
||||
/>
|
||||
</Stack.Row>
|
||||
<input
|
||||
type="text"
|
||||
value={activeRoomLink}
|
||||
readOnly={true}
|
||||
className="RoomDialog-link"
|
||||
@ -136,6 +137,7 @@ const RoomDialog = ({
|
||||
{t("labels.yourName")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username || ""}
|
||||
className="RoomDialog-username TextInput"
|
||||
|
2
src/global.d.ts
vendored
2
src/global.d.ts
vendored
@ -50,6 +50,8 @@ type MarkNonNullable<T, K extends keyof T> = {
|
||||
[P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
|
||||
} & { [P in keyof T]: T[P] };
|
||||
|
||||
type NonOptional<T> = Exclude<T, undefined>;
|
||||
|
||||
// PNG encoding/decoding
|
||||
// -----------------------------------------------------------------------------
|
||||
type TEXtChunk = { name: "tEXt"; data: Uint8Array };
|
||||
|
@ -105,7 +105,10 @@ const findPartsForData = (data: any, parts: string[]) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||
export const t = (
|
||||
path: string,
|
||||
replacement?: { [key: string]: string | number },
|
||||
) => {
|
||||
if (currentLang.code.startsWith(TEST_LANG_CODE)) {
|
||||
const name = replacement
|
||||
? `${path}(${JSON.stringify(replacement).slice(1, -1)})`
|
||||
@ -123,7 +126,7 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||
|
||||
if (replacement) {
|
||||
for (const key in replacement) {
|
||||
translation = translation.replace(`{{${key}}}`, replacement[key]);
|
||||
translation = translation.replace(`{{${key}}}`, String(replacement[key]));
|
||||
}
|
||||
}
|
||||
return translation;
|
||||
|
@ -100,7 +100,9 @@
|
||||
"share": "Share",
|
||||
"showStroke": "Show stroke color picker",
|
||||
"showBackground": "Show background color picker",
|
||||
"toggleTheme": "Toggle theme"
|
||||
"toggleTheme": "Toggle theme",
|
||||
"personalLib": "Personal Library",
|
||||
"excalidrawLib": "Excalidraw Library"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Reset the canvas",
|
||||
@ -136,6 +138,9 @@
|
||||
"exitZenMode": "Exit zen mode",
|
||||
"cancel": "Cancel",
|
||||
"clear": "Clear",
|
||||
"remove": "Remove",
|
||||
"publishLibrary": "Publish",
|
||||
"submit": "Submit",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"alerts": {
|
||||
@ -158,6 +163,7 @@
|
||||
"cannotRestoreFromImage": "Scene couldn't be restored from this image file",
|
||||
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
|
||||
"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."
|
||||
},
|
||||
"errors": {
|
||||
@ -200,7 +206,8 @@
|
||||
"lineEditor_info": "Double-click or press Enter to edit points",
|
||||
"lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
|
||||
"lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
|
||||
"placeImage": "Click to place the image, or click and drag to set its size manually"
|
||||
"placeImage": "Click to place the image, or click and drag to set its size manually",
|
||||
"publishLibrary": "Publish your own library"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Cannot show preview",
|
||||
@ -270,6 +277,55 @@
|
||||
"clearCanvasDialog": {
|
||||
"title": "Clear canvas"
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "Publish library",
|
||||
"itemName": "Item name",
|
||||
"authorName": "Author name",
|
||||
"githubUsername": "GitHub username",
|
||||
"twitterUsername": "Twitter username",
|
||||
"libraryName": "Library name",
|
||||
"libraryDesc": "Library description",
|
||||
"website": "Website",
|
||||
"placeholder": {
|
||||
"authorName": "Your name or username",
|
||||
"libraryName": "Name of your library",
|
||||
"libraryDesc": "Description of your library to help people understand its usage",
|
||||
"githubHandle": "Github handle (optional), so you can edit the library once submitted for review",
|
||||
"twitterHandle": "Twitter username (optional), so we know who to credit when promoting over Twitter",
|
||||
"website": "Link to your personal website or elsewhere (optional)"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Required",
|
||||
"letter&Spaces": "Only letters and spaces allowed"
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "Submit your library to be included in the ",
|
||||
"link": "public library repository",
|
||||
"post": "for other people to use in their drawings."
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "The library needs to be manually approved first. Please read the ",
|
||||
"link": "guidelines",
|
||||
"post": " before submitting. You will need a GitHub account to communicate and make changes if requested, but it is not strictly required."
|
||||
},
|
||||
"noteLicense": {
|
||||
"pre": "By submitting, you agree the library will be published under the ",
|
||||
"link": "MIT License, ",
|
||||
"post": "which in short means anyone can use them without restrictions."
|
||||
},
|
||||
"noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:",
|
||||
"atleastOneLibItem": "Please select at least one library item to get started"
|
||||
},
|
||||
|
||||
"publishSuccessDialog": {
|
||||
"title": "Library submitted",
|
||||
"content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status",
|
||||
"link": "here"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "Reset library",
|
||||
"removeItemsFromLib": "Remove selected items from library"
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.",
|
||||
"link": "Blog post on end-to-end encryption in Excalidraw"
|
||||
@ -290,6 +346,7 @@
|
||||
"width": "Width"
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "Added to library",
|
||||
"copyStyles": "Copied styles.",
|
||||
"copyToClipboard": "Copied to clipboard.",
|
||||
"copyToClipboardAsPng": "Copied {{exportSelection}} to clipboard as PNG\n({{exportColorScheme}})",
|
||||
|
@ -4,13 +4,13 @@ import {
|
||||
} from "../scene/export";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { AppState, BinaryFiles } from "../types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { restore } from "../data/restore";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
|
||||
type ExportOpts = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||
files: BinaryFiles | null;
|
||||
getDimensions?: (
|
||||
|
@ -66,7 +66,7 @@ Object {
|
||||
"startBoundElement": null,
|
||||
"suggestedBindings": Array [],
|
||||
"theme": "light",
|
||||
"toastMessage": null,
|
||||
"toastMessage": "Added to library",
|
||||
"viewBackgroundColor": "#ffffff",
|
||||
"viewModeEnabled": false,
|
||||
"width": 200,
|
||||
@ -166,7 +166,7 @@ Object {
|
||||
|
||||
exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of elements 1`] = `1`;
|
||||
|
||||
exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `9`;
|
||||
exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `10`;
|
||||
|
||||
exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] appState 1`] = `
|
||||
Object {
|
||||
|
@ -20,6 +20,7 @@ import { copiedStyles } from "../actions/actionStyles";
|
||||
import { API } from "./helpers/api";
|
||||
import { setDateTimeForTests } from "../utils";
|
||||
import { t } from "../i18n";
|
||||
import { LibraryItem } from "../types";
|
||||
|
||||
const checkpoint = (name: string) => {
|
||||
expect(renderScene.mock.calls.length).toMatchSnapshot(
|
||||
@ -392,8 +393,8 @@ describe("contextMenu element", () => {
|
||||
await waitFor(() => {
|
||||
const library = localStorage.getItem("excalidraw-library");
|
||||
expect(library).not.toBeNull();
|
||||
const addedElement = JSON.parse(library!)[0][0];
|
||||
expect(addedElement).toEqual(h.elements[0]);
|
||||
const addedElement = JSON.parse(library!)[0] as LibraryItem;
|
||||
expect(addedElement.elements[0]).toEqual(h.elements[0]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -20,7 +20,12 @@ describe("library", () => {
|
||||
);
|
||||
await waitFor(async () => {
|
||||
expect(await h.app.library.loadLibrary()).toEqual([
|
||||
[expect.objectContaining({ id: "A" })],
|
||||
{
|
||||
status: "unpublished",
|
||||
elements: [expect.objectContaining({ id: "A" })],
|
||||
id: "id0",
|
||||
created: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
19
src/types.ts
19
src/types.ts
@ -178,8 +178,25 @@ export declare class GestureEvent extends UIEvent {
|
||||
readonly scale: number;
|
||||
}
|
||||
|
||||
export type LibraryItem = readonly NonDeleted<ExcalidrawElement>[];
|
||||
// libraries
|
||||
// -----------------------------------------------------------------------------
|
||||
/** @deprecated legacy: do not use outside of migration paths */
|
||||
export type LibraryItem_v1 = readonly NonDeleted<ExcalidrawElement>[];
|
||||
/** @deprecated legacy: do not use outside of migration paths */
|
||||
export type LibraryItems_v1 = readonly LibraryItem_v1[];
|
||||
|
||||
/** v2 library item */
|
||||
export type LibraryItem = {
|
||||
id: string;
|
||||
status: "published" | "unpublished";
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||
/** timestamp in epoch (ms) */
|
||||
created: number;
|
||||
name?: string;
|
||||
error?: string;
|
||||
};
|
||||
export type LibraryItems = readonly LibraryItem[];
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// NOTE ready/readyPromise props are optional for host apps' sake (our own
|
||||
// implem guarantees existence)
|
||||
|
14
src/utils.ts
14
src/utils.ts
@ -150,6 +150,20 @@ export const debounce = <T extends any[]>(
|
||||
return ret;
|
||||
};
|
||||
|
||||
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||
export const chunk = <T extends any>(array: T[], size: number): T[][] => {
|
||||
if (!array.length || size < 1) {
|
||||
return [];
|
||||
}
|
||||
let index = 0;
|
||||
let resIndex = 0;
|
||||
const result = Array(Math.ceil(array.length / size));
|
||||
while (index < array.length) {
|
||||
result[resIndex++] = array.slice(index, (index += size));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const selectNode = (node: Element) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user