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>pull/4263/head
parent
3ff9744b39
commit
84d1d9993c
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
Loading…
Reference in New Issue