Library MVP (#1787)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Pete Hunt 2020-07-10 02:20:23 -07:00 committed by GitHub
parent 7ab0c1aba8
commit 6428b59ccb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 599 additions and 20 deletions

View File

@ -0,0 +1,23 @@
import { register } from "./register";
import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element";
import { deepCopyElement } from "../element/newElement";
import { loadLibrary, saveLibrary } from "../data/localStorage";
export const actionAddToLibrary = register({
name: "addToLibrary",
perform: (elements, appState) => {
const selectedElements = getSelectedElements(
getNonDeletedElements(elements),
appState,
);
loadLibrary().then((items) => {
saveLibrary([...items, selectedElements.map(deepCopyElement)]);
});
return false;
},
contextMenuOrder: 6,
contextItemLabel: "labels.addToLibrary",
});

View File

@ -49,3 +49,5 @@ export {
export { actionGroup, actionUngroup } from "./actionGroup"; export { actionGroup, actionUngroup } from "./actionGroup";
export { actionGoToCollaborator } from "./actionNavigate"; export { actionGoToCollaborator } from "./actionNavigate";
export { actionAddToLibrary } from "./actionAddToLibrary";

View File

@ -62,7 +62,8 @@ export type ActionName =
| "toggleShortcuts" | "toggleShortcuts"
| "group" | "group"
| "ungroup" | "ungroup"
| "goToCollaborator"; | "goToCollaborator"
| "addToLibrary";
export interface Action { export interface Action {
name: ActionName; name: ActionName;

View File

@ -58,6 +58,7 @@ export const getDefaultAppState = (): AppState => {
selectedGroupIds: {}, selectedGroupIds: {},
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
isLibraryOpen: false,
}; };
}; };
@ -76,6 +77,7 @@ export const clearAppStateForLocalStorage = (appState: AppState) => {
errorMessage, errorMessage,
showShortcutsDialog, showShortcutsDialog,
editingLinearElement, editingLinearElement,
isLibraryOpen,
...exportedState ...exportedState
} = appState; } = appState;
return exportedState; return exportedState;

View File

@ -85,12 +85,21 @@ export const SelectedShapeActions = ({
); );
}; };
const LIBRARY_ICON = (
// fa-th-large
<svg viewBox="0 0 512 512">
<path d="M296 32h192c13.255 0 24 10.745 24 24v160c0 13.255-10.745 24-24 24H296c-13.255 0-24-10.745-24-24V56c0-13.255 10.745-24 24-24zm-80 0H24C10.745 32 0 42.745 0 56v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V56c0-13.255-10.745-24-24-24zM0 296v160c0 13.255 10.745 24 24 24h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H24c-13.255 0-24 10.745-24 24zm296 184h192c13.255 0 24-10.745 24-24V296c0-13.255-10.745-24-24-24H296c-13.255 0-24 10.745-24 24v160c0 13.255 10.745 24 24 24z" />
</svg>
);
export const ShapesSwitcher = ({ export const ShapesSwitcher = ({
elementType, elementType,
setAppState, setAppState,
isLibraryOpen,
}: { }: {
elementType: ExcalidrawElement["type"]; elementType: ExcalidrawElement["type"];
setAppState: any; setAppState: (appState: Partial<AppState>) => void;
isLibraryOpen: boolean;
}) => ( }) => (
<> <>
{SHAPES.map(({ value, icon, key }, index) => { {SHAPES.map(({ value, icon, key }, index) => {
@ -119,9 +128,21 @@ export const ShapesSwitcher = ({
setCursorForShape(value); setCursorForShape(value);
setAppState({}); setAppState({});
}} }}
></ToolButton> />
); );
})} })}
<ToolButton
type="button"
icon={LIBRARY_ICON}
name="editor-library"
keyBindingLabel="9"
aria-keyshortcuts="9"
title={`${capitalizeString(t("toolBar.library"))} — 9`}
aria-label={capitalizeString(t("toolBar.library"))}
onClick={() => {
setAppState({ isLibraryOpen: !isLibraryOpen });
}}
/>
</> </>
); );

View File

@ -299,6 +299,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}); });
}} }}
onLockToggle={this.toggleLock} onLockToggle={this.toggleLock}
onInsertShape={(elements) =>
this.addElementsFromPasteOrLibrary(elements)
}
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
toggleZenMode={this.toggleZenMode} toggleZenMode={this.toggleZenMode}
lng={getLanguage().lng} lng={getLanguage().lng}
@ -870,7 +873,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (data.error) { if (data.error) {
alert(data.error); alert(data.error);
} else if (data.elements) { } else if (data.elements) {
this.addElementsFromPaste(data.elements); this.addElementsFromPasteOrLibrary(data.elements);
} else if (data.text) { } else if (data.text) {
this.addTextFromPaste(data.text); this.addTextFromPaste(data.text);
} }
@ -879,8 +882,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}, },
); );
private addElementsFromPaste = ( private addElementsFromPasteOrLibrary = (
clipboardElements: readonly ExcalidrawElement[], clipboardElements: readonly ExcalidrawElement[],
clientX = cursorX,
clientY = cursorY,
) => { ) => {
const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements); const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
@ -888,7 +893,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const elementsCenterY = distance(minY, maxY) / 2; const elementsCenterY = distance(minY, maxY) / 2;
const { x, y } = viewportCoordsToSceneCoords( const { x, y } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY }, { clientX, clientY },
this.state, this.state,
this.canvas, this.canvas,
window.devicePixelRatio, window.devicePixelRatio,
@ -911,6 +916,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
]); ]);
history.resumeRecording(); history.resumeRecording();
this.setState({ this.setState({
isLibraryOpen: false,
selectedElementIds: newElements.reduce((map, element) => { selectedElementIds: newElements.reduce((map, element) => {
map[element.id] = true; map[element.id] = true;
return map; return map;
@ -1355,6 +1361,10 @@ class App extends React.Component<ExcalidrawProps, AppState> {
return; return;
} }
if (event.code === "Digit9") {
this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
}
const shape = findShapeByKey(event.key); const shape = findShapeByKey(event.key);
if (isArrowKey(event.key)) { if (isArrowKey(event.key)) {
@ -3135,6 +3145,18 @@ class App extends React.Component<ExcalidrawProps, AppState> {
}; };
private handleCanvasOnDrop = (event: React.DragEvent<HTMLCanvasElement>) => { private handleCanvasOnDrop = (event: React.DragEvent<HTMLCanvasElement>) => {
const libraryShapes = event.dataTransfer.getData(
"application/vnd.excalidraw.json",
);
if (libraryShapes !== "") {
this.addElementsFromPasteOrLibrary(
JSON.parse(libraryShapes),
event.clientX,
event.clientY,
);
return;
}
const file = event.dataTransfer?.files[0]; const file = event.dataTransfer?.files[0];
if ( if (
file?.type === "application/json" || file?.type === "application/json" ||

View File

@ -1,5 +1,22 @@
@import "open-color/open-color"; @import "open-color/open-color";
.layer-ui__library {
margin: auto;
display: flex;
align-items: center;
justify-content: center;
}
.layer-ui__library-message {
padding: 10px 20px;
max-width: 200px;
}
.layer-ui__library-items {
max-height: 50vh;
overflow: auto;
}
.layer-ui__wrapper { .layer-ui__wrapper {
.encrypted-icon { .encrypted-icon {
position: relative; position: relative;

View File

@ -1,10 +1,20 @@
import React from "react"; import React, {
useRef,
useState,
RefObject,
useEffect,
useCallback,
} from "react";
import { showSelectedShapeActions } from "../element"; import { showSelectedShapeActions } from "../element";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter, getSelectedElements } from "../scene";
import { exportCanvas } from "../data"; import { exportCanvas } from "../data";
import { AppState } from "../types"; import { AppState, LibraryItems } from "../types";
import { NonDeletedExcalidrawElement } from "../element/types"; import {
NonDeletedExcalidrawElement,
ExcalidrawElement,
NonDeleted,
} from "../element/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { Island } from "./Island"; import { Island } from "./Island";
@ -32,6 +42,8 @@ import { GitHubCorner } from "./GitHubCorner";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import "./LayerUI.scss"; import "./LayerUI.scss";
import { LibraryUnit } from "./LibraryUnit";
import { loadLibrary, saveLibrary } from "../data/localStorage";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@ -43,11 +55,182 @@ interface LayerUIProps {
onUsernameChange: (username: string) => void; onUsernameChange: (username: string) => void;
onRoomDestroy: () => void; onRoomDestroy: () => void;
onLockToggle: () => void; onLockToggle: () => void;
onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
zenModeEnabled: boolean; zenModeEnabled: boolean;
toggleZenMode: () => void; toggleZenMode: () => void;
lng: string; lng: string;
} }
function 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 = ({
library,
onRemoveFromLibrary,
onAddToLibrary,
onInsertShape,
pendingElements,
}: {
library: LibraryItems;
pendingElements: NonDeleted<ExcalidrawElement>[];
onClickOutside: (event: MouseEvent) => void;
onRemoveFromLibrary: (index: number) => void;
onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
onAddToLibrary: (elements: NonDeleted<ExcalidrawElement>[]) => void;
}) => {
const numCells = library.length + (pendingElements.length > 0 ? 1 : 0);
const CELLS_PER_ROW = 3;
const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
const rows = [];
let addedPendingElements = false;
for (let row = 0; row < numRows; row++) {
const i = CELLS_PER_ROW * row;
const children = [];
for (let j = 0; j < 3; j++) {
const shouldAddPendingElements: boolean =
pendingElements.length > 0 &&
!addedPendingElements &&
i + j >= library.length;
addedPendingElements = addedPendingElements || shouldAddPendingElements;
children.push(
<Stack.Col key={j}>
<LibraryUnit
elements={library[i + j]}
pendingElements={
shouldAddPendingElements ? pendingElements : undefined
}
onRemoveFromLibrary={onRemoveFromLibrary.bind(null, i + j)}
onClick={
shouldAddPendingElements
? onAddToLibrary.bind(null, pendingElements)
: onInsertShape.bind(null, library[i + j])
}
/>
</Stack.Col>,
);
}
rows.push(
<Stack.Row align="center" gap={1} key={row}>
{children}
</Stack.Row>,
);
}
return (
<Stack.Col align="center" gap={1} className="layer-ui__library-items">
{rows}
</Stack.Col>
);
};
const LibraryMenu = ({
onClickOutside,
onInsertShape,
pendingElements,
onAddToLibrary,
}: {
pendingElements: NonDeleted<ExcalidrawElement>[];
onClickOutside: (event: MouseEvent) => void;
onInsertShape: (elements: readonly NonDeleted<ExcalidrawElement>[]) => void;
onAddToLibrary: () => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useOnClickOutside(ref, onClickOutside);
const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
const [loadingState, setIsLoading] = useState<
"preloading" | "loading" | "ready"
>("preloading");
const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
Promise.race([
new Promise((resolve) => {
loadingTimerRef.current = setTimeout(() => {
resolve("loading");
}, 100);
}),
loadLibrary().then((items) => {
setLibraryItems(items);
setIsLoading("ready");
}),
]).then((data) => {
if (data === "loading") {
setIsLoading("loading");
}
});
return () => {
clearTimeout(loadingTimerRef.current!);
};
}, []);
const removeFromLibrary = useCallback(async (indexToRemove) => {
const items = await loadLibrary();
const nextItems = items.filter((_, index) => index !== indexToRemove);
saveLibrary(nextItems);
setLibraryItems(nextItems);
}, []);
const addToLibrary = useCallback(
async (elements: NonDeleted<ExcalidrawElement>[]) => {
const items = await loadLibrary();
const nextItems = [...items, elements];
onAddToLibrary();
saveLibrary(nextItems);
setLibraryItems(nextItems);
},
[onAddToLibrary],
);
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
library={libraryItems}
onClickOutside={onClickOutside}
onRemoveFromLibrary={removeFromLibrary}
onAddToLibrary={addToLibrary}
onInsertShape={onInsertShape}
pendingElements={pendingElements}
/>
)}
</Island>
);
};
const LayerUI = ({ const LayerUI = ({
actionManager, actionManager,
appState, appState,
@ -58,6 +241,7 @@ const LayerUI = ({
onUsernameChange, onUsernameChange,
onRoomDestroy, onRoomDestroy,
onLockToggle, onLockToggle,
onInsertShape,
zenModeEnabled, zenModeEnabled,
toggleZenMode, toggleZenMode,
}: LayerUIProps) => { }: LayerUIProps) => {
@ -167,11 +351,33 @@ const LayerUI = ({
</Section> </Section>
); );
const closeLibrary = useCallback(
(event) => {
setAppState({ isLibraryOpen: false });
},
[setAppState],
);
const deselectItems = useCallback(() => {
setAppState({
selectedElementIds: {},
selectedGroupIds: {},
});
}, [setAppState]);
const renderFixedSideContainer = () => { const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions( const shouldRenderSelectedShapeActions = showSelectedShapeActions(
appState, appState,
elements, elements,
); );
const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState)}
onClickOutside={closeLibrary}
onInsertShape={onInsertShape}
onAddToLibrary={deselectItems}
/>
) : null;
return ( return (
<FixedSideContainer side="top"> <FixedSideContainer side="top">
<HintViewer appState={appState} elements={elements} /> <HintViewer appState={appState} elements={elements} />
@ -193,6 +399,7 @@ const LayerUI = ({
<ShapesSwitcher <ShapesSwitcher
elementType={appState.elementType} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>
@ -203,6 +410,7 @@ const LayerUI = ({
title={t("toolBar.lock")} title={t("toolBar.lock")}
/> />
</Stack.Row> </Stack.Row>
{libraryMenu}
</Stack.Col> </Stack.Col>
)} )}
</Section> </Section>

View File

@ -0,0 +1,75 @@
.library-unit {
align-items: center;
border: 1px solid #ccc;
display: flex;
height: 126px; // match width
justify-content: center;
position: relative;
width: 126px; // exactly match the toolbar width when 3 are lined up + padding
}
.library-unit__dragger {
display: flex;
height: 100%;
width: 100%;
}
.library-unit__dragger > svg {
flex-grow: 1;
max-height: 100%;
max-width: 100%;
}
.library-unit__removeFromLibrary,
.library-unit__removeFromLibrary:hover,
.library-unit__removeFromLibrary:active {
align-items: center;
background: none;
border: none;
display: flex;
justify-content: center;
margin: 0;
padding: 0;
position: absolute;
right: 5px;
top: 5px;
}
.library-unit__removeFromLibrary > svg {
height: 16px;
width: 16px;
}
.library-unit__pulse {
transform: scale(1);
animation: library-unit__pulse-animation 1s ease-in infinite;
}
.library-unit__adder {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
margin-left: -10px;
margin-top: -10px;
pointer-events: none;
}
.library-unit__active {
cursor: pointer;
}
@keyframes library-unit__pulse-animation {
0% {
transform: scale(0.95);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.95);
}
}

View File

@ -0,0 +1,93 @@
import React, { useRef, useEffect, useState } from "react";
import { exportToSvg } from "../scene/export";
import { ExcalidrawElement, NonDeleted } from "../element/types";
import { close } from "../components/icons";
import "./LibraryUnit.scss";
import { t } from "../i18n";
// fa-plus
const PLUS_ICON = (
<svg viewBox="0 0 1792 1792">
<path d="M1600 736v192q0 40-28 68t-68 28h-416v416q0 40-28 68t-68 28h-192q-40 0-68-28t-28-68v-416h-416q-40 0-68-28t-28-68v-192q0-40 28-68t68-28h416v-416q0-40 28-68t68-28h192q40 0 68 28t28 68v416h416q40 0 68 28t28 68z" />
</svg>
);
export const LibraryUnit = ({
elements,
pendingElements,
onRemoveFromLibrary,
onClick,
}: {
elements?: NonDeleted<ExcalidrawElement>[];
pendingElements?: NonDeleted<ExcalidrawElement>[];
onRemoveFromLibrary: () => void;
onClick: () => void;
}) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const elementsToRender = elements || pendingElements;
if (!elementsToRender) {
return;
}
const svg = exportToSvg(elementsToRender, {
exportBackground: false,
viewBackgroundColor: "#fff",
shouldAddWatermark: false,
});
for (const child of ref.current!.children) {
if (child.tagName !== "svg") {
continue;
}
ref.current!.removeChild(child);
}
ref.current!.appendChild(svg);
const current = ref.current!;
return () => {
current.removeChild(svg);
};
}, [elements, pendingElements]);
const [isHovered, setIsHovered] = useState(false);
const adder = isHovered && pendingElements && (
<div className="library-unit__adder">{PLUS_ICON}</div>
);
return (
<div
className={`library-unit ${
elements || pendingElements ? "library-unit__active" : ""
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={`library-unit__dragger ${
!!pendingElements ? "library-unit__pulse" : ""
}`}
ref={ref}
draggable={!!elements}
onClick={!!elements || !!pendingElements ? onClick : undefined}
onDragStart={(event) => {
setIsHovered(false);
event.dataTransfer.setData(
"application/vnd.excalidraw.json",
JSON.stringify(elements),
);
}}
/>
{adder}
{elements && isHovered && (
<button
className="library-unit__removeFromLibrary"
aria-label={t("labels.removeFromLibrary")}
onClick={onRemoveFromLibrary}
>
{close}
</button>
)}
</div>
);
};

View File

@ -56,6 +56,7 @@ export const MobileMenu = ({
<ShapesSwitcher <ShapesSwitcher
elementType={appState.elementType} elementType={appState.elementType}
setAppState={setAppState} setAppState={setAppState}
isLibraryOpen={appState.isLibraryOpen}
/> />
</Stack.Row> </Stack.Row>
</Island> </Island>

View File

@ -63,6 +63,11 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
> >
<div className="ToolIcon__icon" aria-hidden="true"> <div className="ToolIcon__icon" aria-hidden="true">
{props.icon || props.label} {props.icon || props.label}
{props.keyBindingLabel && (
<span className="ToolIcon__keybinding">
{props.keyBindingLabel}
</span>
)}
</div> </div>
{props.showAriaLabel && ( {props.showAriaLabel && (
<div className="ToolIcon__label">{props["aria-label"]}</div> <div className="ToolIcon__label">{props["aria-label"]}</div>

View File

@ -348,11 +348,12 @@ export const exportCanvas = async (
window.alert(t("alerts.couldNotCopyToClipboard")); window.alert(t("alerts.couldNotCopyToClipboard"));
} }
} else if (type === "backend") { } else if (type === "backend") {
const appState = getDefaultAppState(); exportToBackend(elements, {
if (exportBackground) { ...appState,
appState.viewBackgroundColor = viewBackgroundColor; viewBackgroundColor: exportBackground
} ? appState.viewBackgroundColor
exportToBackend(elements, appState); : getDefaultAppState().viewBackgroundColor,
});
} }
// clean up the DOM // clean up the DOM

View File

@ -1,11 +1,54 @@
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types"; import { AppState, LibraryItems } from "../types";
import { clearAppStateForLocalStorage } from "../appState"; import { clearAppStateForLocalStorage } from "../appState";
import { restore } from "./restore"; import { restore } from "./restore";
const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY = "excalidraw";
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state"; const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
const LOCAL_STORAGE_KEY_COLLAB = "excalidraw-collab"; const LOCAL_STORAGE_KEY_COLLAB = "excalidraw-collab";
const LOCAL_STORAGE_KEY_LIBRARY = "excalidraw-library";
let _LATEST_LIBRARY_ITEMS: LibraryItems | null = null;
export const loadLibrary = (): Promise<LibraryItems> => {
return new Promise(async (resolve) => {
if (_LATEST_LIBRARY_ITEMS) {
return resolve(JSON.parse(JSON.stringify(_LATEST_LIBRARY_ITEMS)));
}
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY_LIBRARY);
if (!data) {
return resolve([]);
}
const items = (JSON.parse(data) as ExcalidrawElement[][]).map(
(elements) => restore(elements, null).elements,
) as Mutable<LibraryItems>;
// clone to ensure we don't mutate the cached library elements in the app
_LATEST_LIBRARY_ITEMS = JSON.parse(JSON.stringify(items));
resolve(items);
} catch (e) {
console.error(e);
resolve([]);
}
});
};
export const saveLibrary = (items: LibraryItems) => {
const prevLibraryItems = _LATEST_LIBRARY_ITEMS;
try {
const serializedItems = JSON.stringify(items);
// cache optimistically so that consumers have access to the latest
// immediately
_LATEST_LIBRARY_ITEMS = JSON.parse(serializedItems);
localStorage.setItem(LOCAL_STORAGE_KEY_LIBRARY, serializedItems);
} catch (e) {
_LATEST_LIBRARY_ITEMS = prevLibraryItems;
console.error(e);
}
};
export const saveUsernameToLocalStorage = (username: string) => { export const saveUsernameToLocalStorage = (username: string) => {
try { try {

View File

@ -65,7 +65,10 @@
"group": "Group selection", "group": "Group selection",
"ungroup": "Ungroup selection", "ungroup": "Ungroup selection",
"collaborators": "Collaborators", "collaborators": "Collaborators",
"toggleGridMode": "Toggle grid mode" "toggleGridMode": "Toggle grid mode",
"addToLibrary": "Add to library",
"removeFromLibrary": "Remove from library",
"libraryLoadingMessage": "Loading library..."
}, },
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",
@ -115,6 +118,7 @@
"arrow": "Arrow", "arrow": "Arrow",
"line": "Line", "line": "Line",
"text": "Text", "text": "Text",
"library": "Library",
"lock": "Keep selected tool active after drawing" "lock": "Keep selected tool active after drawing"
}, },
"headings": { "headings": {

View File

@ -27,6 +27,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -427,6 +428,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -636,6 +638,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -762,6 +765,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -1024,6 +1028,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -1188,6 +1193,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -1390,6 +1396,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -1598,6 +1605,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -1907,6 +1915,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -2302,6 +2311,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4090,6 +4100,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4216,6 +4227,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4342,6 +4354,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4468,6 +4481,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4616,6 +4630,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4764,6 +4779,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -4912,6 +4928,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -5060,6 +5077,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -5186,6 +5204,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -5312,6 +5331,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -5460,6 +5480,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -5586,6 +5607,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -5734,6 +5756,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -6374,6 +6397,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -6583,6 +6607,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -6650,6 +6675,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -6715,6 +6741,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -7537,6 +7564,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -7936,6 +7964,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -8252,6 +8281,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -8489,6 +8519,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -8651,6 +8682,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -9422,6 +9454,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -10094,6 +10127,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -10671,6 +10705,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -11157,6 +11192,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -11599,6 +11635,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -11956,6 +11993,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -12232,6 +12270,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -12431,6 +12470,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -13253,6 +13293,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -13974,6 +14015,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -14598,6 +14640,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -15129,6 +15172,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -15402,6 +15446,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -15613,6 +15658,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -15892,6 +15938,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -15957,6 +16004,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -16083,6 +16131,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -16148,6 +16197,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -16802,6 +16852,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -16869,6 +16920,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -17297,6 +17349,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,
@ -17373,6 +17426,7 @@ Object {
"gridSize": null, "gridSize": null,
"height": 768, "height": 768,
"isCollaborating": false, "isCollaborating": false,
"isLibraryOpen": false,
"isLoading": false, "isLoading": false,
"isResizing": false, "isResizing": false,
"isRotating": false, "isRotating": false,

View File

@ -884,6 +884,7 @@ describe("regression tests", () => {
"Copy styles", "Copy styles",
"Paste styles", "Paste styles",
"Delete", "Delete",
"Add to library",
"Send backward", "Send backward",
"Bring forward", "Bring forward",
"Send to back", "Send to back",
@ -892,7 +893,7 @@ describe("regression tests", () => {
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
expect(contextMenu?.children.length).toBe(8); expect(contextMenu?.children.length).toBe(9);
options?.forEach((opt, i) => { options?.forEach((opt, i) => {
expect(opt.textContent).toBe(expectedOptions[i]); expect(opt.textContent).toBe(expectedOptions[i]);
}); });
@ -926,6 +927,7 @@ describe("regression tests", () => {
"Paste styles", "Paste styles",
"Delete", "Delete",
"Group selection", "Group selection",
"Add to library",
"Send backward", "Send backward",
"Bring forward", "Bring forward",
"Send to back", "Send to back",
@ -934,7 +936,7 @@ describe("regression tests", () => {
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
expect(contextMenu?.children.length).toBe(9); expect(contextMenu?.children.length).toBe(10);
options?.forEach((opt, i) => { options?.forEach((opt, i) => {
expect(opt.textContent).toBe(expectedOptions[i]); expect(opt.textContent).toBe(expectedOptions[i]);
}); });
@ -973,6 +975,7 @@ describe("regression tests", () => {
"Delete", "Delete",
"Group selection", "Group selection",
"Ungroup selection", "Ungroup selection",
"Add to library",
"Send backward", "Send backward",
"Bring forward", "Bring forward",
"Send to back", "Send to back",
@ -981,7 +984,7 @@ describe("regression tests", () => {
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
expect(contextMenu?.children.length).toBe(10); expect(contextMenu?.children.length).toBe(11);
options?.forEach((opt, i) => { options?.forEach((opt, i) => {
expect(opt.textContent).toBe(expectedOptions[i]); expect(opt.textContent).toBe(expectedOptions[i]);
}); });

View File

@ -81,6 +81,8 @@ export type AppState = {
editingGroupId: GroupId | null; editingGroupId: GroupId | null;
width: number; width: number;
height: number; height: number;
isLibraryOpen: boolean;
}; };
export type PointerCoords = Readonly<{ export type PointerCoords = Readonly<{
@ -103,3 +105,5 @@ export declare class GestureEvent extends UIEvent {
export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & { export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
_brand: "socketUpdateData"; _brand: "socketUpdateData";
}; };
export type LibraryItems = readonly NonDeleted<ExcalidrawElement>[][];