feat: refactor Sidebar into standalone reusable component (#5663)

🚀!
This commit is contained in:
David Luzar 2022-10-17 12:25:24 +02:00 committed by GitHub
parent fdc462ec01
commit e9067de173
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1369 additions and 464 deletions

View File

@ -57,8 +57,7 @@ export const getDefaultAppState = (): Omit<
fileHandle: null,
gridSize: null,
isBindingEnabled: true,
isLibraryOpen: false,
isLibraryMenuDocked: false,
isSidebarDocked: false,
isLoading: false,
isResizing: false,
isRotating: false,
@ -67,6 +66,7 @@ export const getDefaultAppState = (): Omit<
name: `${t("labels.untitled")}-${getDateTime()}`,
openMenu: null,
openPopup: null,
openSidebar: null,
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
@ -148,8 +148,7 @@ const APP_STATE_STORAGE_CONF = (<
gridSize: { browser: true, export: true, server: true },
height: { browser: false, export: false, server: false },
isBindingEnabled: { browser: false, export: false, server: false },
isLibraryOpen: { browser: true, export: false, server: false },
isLibraryMenuDocked: { browser: true, export: false, server: false },
isSidebarDocked: { browser: true, export: false, server: false },
isLoading: { browser: false, export: false, server: false },
isResizing: { browser: false, export: false, server: false },
isRotating: { browser: false, export: false, server: false },
@ -160,6 +159,7 @@ const APP_STATE_STORAGE_CONF = (<
offsetTop: { browser: false, export: false, server: false },
openMenu: { browser: true, export: false, server: false },
openPopup: { browser: false, export: false, server: false },
openSidebar: { browser: true, export: false, server: false },
pasteDialog: { browser: false, export: false, server: false },
previousSelectedElementIds: { browser: true, export: false, server: false },
resizingElement: { browser: false, export: false, server: false },

View File

@ -293,10 +293,17 @@ const ExcalidrawAppStateContext = React.createContext<AppState>({
offsetLeft: 0,
offsetTop: 0,
});
const ExcalidrawSetAppStateContent = React.createContext<
React.Component<any, AppState>["setState"]
>(() => {});
export const useExcalidrawElements = () =>
useContext(ExcalidrawElementsContext);
export const useExcalidrawAppState = () =>
useContext(ExcalidrawAppStateContext);
export const useExcalidrawSetAppState = () =>
useContext(ExcalidrawSetAppStateContent);
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
@ -380,7 +387,7 @@ class App extends React.Component<AppProps, AppState> {
width: window.innerWidth,
height: window.innerHeight,
showHyperlinkPopup: false,
isLibraryMenuDocked: false,
isSidebarDocked: false,
};
this.id = nanoid();
@ -412,6 +419,7 @@ class App extends React.Component<AppProps, AppState> {
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
toggleMenu: this.toggleMenu,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@ -524,65 +532,68 @@ class App extends React.Component<AppProps, AppState> {
value={this.excalidrawContainerValue}
>
<DeviceContext.Provider value={this.device}>
<ExcalidrawAppStateContext.Provider value={this.state}>
<ExcalidrawElementsContext.Provider
value={this.scene.getNonDeletedElements()}
>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating}
renderTopRightUI={renderTopRightUI}
renderCustomFooter={renderFooter}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{selectedElement.length === 1 &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
<ExcalidrawSetAppStateContent.Provider value={this.setAppState}>
<ExcalidrawAppStateContext.Provider value={this.state}>
<ExcalidrawElementsContext.Provider
value={this.scene.getNonDeletedElements()}
>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onCollabButtonClick={onCollabButtonClick}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onInsertElements={(elements) =>
this.addElementsFromPasteOrLibrary({
elements,
position: "center",
files: null,
})
}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating}
renderTopRightUI={renderTopRightUI}
renderCustomFooter={renderFooter}
renderCustomStats={renderCustomStats}
renderCustomSidebar={this.props.renderSidebar}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
focusContainer={this.focusContainer}
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
{selectedElement.length === 1 &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={selectedElement[0].id}
element={selectedElement[0]}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={() => this.setToast(null)}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
<main>{this.renderCanvas()}</main>
</ExcalidrawElementsContext.Provider>{" "}
</ExcalidrawAppStateContext.Provider>
<main>{this.renderCanvas()}</main>
</ExcalidrawElementsContext.Provider>{" "}
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContent.Provider>
</DeviceContext.Provider>
</ExcalidrawContainerContext.Provider>
</div>
@ -787,8 +798,7 @@ class App extends React.Component<AppProps, AppState> {
// whether to open the library, to handle a case where we
// update the state outside of initialData (e.g. when loading the app
// with a library install link, which should auto-open the library)
isLibraryOpen:
initialData?.appState?.isLibraryOpen || this.state.isLibraryOpen,
openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
activeTool:
scene.appState.activeTool.type === "image"
? { ...scene.appState.activeTool, type: "selection" }
@ -1562,10 +1572,17 @@ class App extends React.Component<AppProps, AppState> {
selectGroupsForSelectedElements(
{
...this.state,
isLibraryOpen:
this.state.isLibraryOpen && this.device.canDeviceFitSidebar
? this.state.isLibraryMenuDocked
: false,
// keep sidebar (presumably the library) open if it's docked and
// can fit.
//
// Note, we should close the sidebar only if we're dropping items
// from library, not when pasting from clipboard. Alas.
openSidebar:
this.state.openSidebar &&
this.device.canDeviceFitSidebar &&
this.state.isSidebarDocked
? this.state.openSidebar
: null,
selectedElementIds: newElements.reduce(
(acc: Record<ExcalidrawElement["id"], true>, element) => {
if (!isBoundToContainer(element)) {
@ -1623,8 +1640,8 @@ class App extends React.Component<AppProps, AppState> {
// Collaboration
setAppState = (obj: any) => {
this.setState(obj);
setAppState: React.Component<any, AppState>["setState"] = (state) => {
this.setState(state);
};
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
@ -1762,6 +1779,35 @@ class App extends React.Component<AppProps, AppState> {
this.setState({});
};
/**
* @returns whether the menu was toggled on or off
*/
public toggleMenu = (
type: "library" | "customSidebar",
force?: boolean,
): boolean => {
if (type === "customSidebar" && !this.props.renderSidebar) {
console.warn(
`attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
);
return false;
}
if (type === "library" || type === "customSidebar") {
let nextValue;
if (force === undefined) {
nextValue = this.state.openSidebar === type ? null : type;
} else {
nextValue = force ? type : null;
}
this.setState({ openSidebar: nextValue });
return !!nextValue;
}
return false;
};
private updateCurrentCursorPosition = withBatchedUpdates(
(event: MouseEvent) => {
cursorX = event.clientX;
@ -1837,8 +1883,7 @@ class App extends React.Component<AppProps, AppState> {
}
if (event.code === CODES.ZERO) {
const nextState = !this.state.isLibraryOpen;
this.setState({ isLibraryOpen: nextState });
const nextState = this.toggleMenu("library");
// track only openings
if (nextState) {
trackEvent(

View File

@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types";
import { getSelectedElements } from "../scene";
import "./HintViewer.scss";
import { AppState } from "../types";
import { AppState, Device } from "../types";
import {
isImageElement,
isLinearElement,
@ -17,13 +17,19 @@ interface HintViewerProps {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
isMobile: boolean;
device: Device;
}
const getHints = ({ appState, elements, isMobile }: HintViewerProps) => {
const getHints = ({
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
const multiMode = appState.multiElement !== null;
if (appState.isLibraryOpen) {
if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
return null;
}
@ -111,11 +117,13 @@ export const HintViewer = ({
appState,
elements,
isMobile,
device,
}: HintViewerProps) => {
let hint = getHints({
appState,
elements,
isMobile,
device,
});
if (!hint) {
return null;

View File

@ -1,48 +1,6 @@
@import "open-color/open-color";
@import "../css/variables.module";
.layer-ui__sidebar {
position: absolute;
top: var(--sat);
bottom: var(--sab);
right: var(--sar);
z-index: 5;
box-shadow: var(--shadow-island);
overflow: hidden;
border-radius: var(--border-radius-lg);
margin: var(--space-factor);
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
}
.excalidraw {
.layer-ui__wrapper.animate {
transition: width 0.1s ease-in-out;

View File

@ -40,6 +40,9 @@ import { useDevice } from "../components/App";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions/actionToggleStats";
import Footer from "./Footer";
import { hostSidebarCountersAtom, Sidebar } from "./Sidebar/Sidebar";
import { jotaiScope } from "../jotai";
import { useAtom } from "jotai";
interface LayerUIProps {
actionManager: ActionManager;
@ -58,6 +61,7 @@ interface LayerUIProps {
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
renderCustomFooter?: ExcalidrawProps["renderFooter"];
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
focusContainer: () => void;
@ -81,6 +85,7 @@ const LayerUI = ({
renderTopRightUI,
renderCustomFooter,
renderCustomStats,
renderCustomSidebar,
libraryReturnUrl,
UIOptions,
focusContainer,
@ -249,7 +254,7 @@ const LayerUI = ({
if (isDialogOpen) {
return;
}
setAppState({ isLibraryOpen: false });
setAppState({ openSidebar: null });
}, [setAppState]);
const deselectItems = useCallback(() => {
@ -259,23 +264,24 @@ const LayerUI = ({
});
}, [setAppState]);
const libraryMenu = appState.isLibraryOpen ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
onClose={closeLibrary}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
files={files}
id={id}
appState={appState}
/>
) : null;
const libraryMenu =
appState.openSidebar === "library" ? (
<LibraryMenu
pendingElements={getSelectedElements(elements, appState, true)}
onClose={closeLibrary}
onInsertLibraryItems={(libraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
}}
onAddToLibrary={deselectItems}
setAppState={setAppState}
libraryReturnUrl={libraryReturnUrl}
focusContainer={focusContainer}
library={library}
files={files}
id={id}
appState={appState}
/>
) : null;
const renderFixedSideContainer = () => {
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
@ -330,6 +336,7 @@ const LayerUI = ({
appState={appState}
elements={elements}
isMobile={device.isMobile}
device={device}
/>
{heading}
<Stack.Row gap={1}>
@ -374,6 +381,8 @@ const LayerUI = ({
);
};
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
return (
<>
{appState.isLoading && <LoadingMessage delay={250} />}
@ -420,6 +429,8 @@ const LayerUI = ({
onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
renderCustomSidebar={renderCustomSidebar}
device={device}
/>
)}
@ -434,8 +445,9 @@ const LayerUI = ({
!isTextElement(appState.editingElement)),
})}
style={
appState.isLibraryOpen &&
appState.isLibraryMenuDocked &&
((appState.openSidebar === "library" &&
appState.isSidebarDocked) ||
hostSidebarCounters.docked) &&
device.canDeviceFitSidebar
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
: {}
@ -472,9 +484,26 @@ const LayerUI = ({
</button>
)}
</div>
{appState.isLibraryOpen && (
<div className="layer-ui__sidebar">{libraryMenu}</div>
)}
{appState.openSidebar === "customSidebar" ? (
renderCustomSidebar?.()
) : appState.openSidebar === "library" ? (
<Sidebar
__isInternal
// necessary to remount when switching between internal
// and custom (host app) sidebar, so that the `props.onClose`
// is colled correctly
key="library"
onDock={(docked) => {
trackEvent(
"library",
`toggleLibraryDock (${docked ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}}
>
{libraryMenu}
</Sidebar>
) : null}
</>
)}
</>
@ -494,8 +523,12 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
const nextAppState = getNecessaryObj(next.appState);
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
return (
prev.renderCustomFooter === next.renderCustomFooter &&
prev.renderTopRightUI === next.renderTopRightUI &&
prev.renderCustomStats === next.renderCustomStats &&
prev.renderCustomSidebar === next.renderCustomSidebar &&
prev.langCode === next.langCode &&
prev.elements === next.elements &&
prev.files === next.files &&

View File

@ -40,10 +40,10 @@ export const LibraryButton: React.FC<{
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const nextState = event.target.checked;
setAppState({ isLibraryOpen: nextState });
const isOpen = event.target.checked;
setAppState({ openSidebar: isOpen ? "library" : null });
// track only openings
if (nextState) {
if (isOpen) {
trackEvent(
"library",
"toggleLibrary (open)",
@ -51,7 +51,7 @@ export const LibraryButton: React.FC<{
);
}
}}
checked={appState.isLibraryOpen}
checked={appState.openSidebar === "library"}
aria-label={capitalizeString(t("toolBar.library"))}
aria-keyshortcuts="0"
/>

View File

@ -17,7 +17,6 @@ import {
ExcalidrawProps,
} from "../types";
import { Dialog } from "./Dialog";
import { Island } from "./Island";
import PublishLibrary from "./PublishLibrary";
import { ToolButton } from "./ToolButton";
@ -69,9 +68,9 @@ const LibraryMenuWrapper = forwardRef<
{ children: React.ReactNode }
>(({ children }, ref) => {
return (
<Island padding={1} ref={ref} className="layer-ui__library">
<div ref={ref} className="layer-ui__library">
{children}
</Island>
</div>
);
});
@ -112,11 +111,11 @@ export const LibraryMenu = ({
if ((event.target as Element).closest(".ToolIcon__library")) {
return;
}
if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) {
if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
onClose();
}
},
[onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar],
[onClose, appState.isSidebarDocked, device.canDeviceFitSidebar],
),
);
@ -124,7 +123,7 @@ export const LibraryMenu = ({
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar)
(!appState.isSidebarDocked || !device.canDeviceFitSidebar)
) {
onClose();
}
@ -133,7 +132,7 @@ export const LibraryMenu = ({
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]);
}, [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar]);
const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
const [showPublishLibraryDialog, setShowPublishLibraryDialog] =

View File

@ -5,8 +5,6 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 0.5rem;
box-sizing: border-box;
.library-actions {
width: 100%;

View File

@ -13,7 +13,7 @@ import {
import { arrayToMap, chunk, muteFSAbortError } from "../utils";
import { useDevice } from "./App";
import ConfirmDialog from "./ConfirmDialog";
import { close, exportToFileIcon, load, publishIcon, trash } from "./icons";
import { exportToFileIcon, load, publishIcon, trash } from "./icons";
import { LibraryUnit } from "./LibraryUnit";
import Stack from "./Stack";
import { ToolButton } from "./ToolButton";
@ -23,9 +23,7 @@ import "./LibraryMenuItems.scss";
import { MIME_TYPES, VERSIONS } from "../constants";
import Spinner from "./Spinner";
import { fileOpen } from "../data/filesystem";
import { SidebarLockButton } from "./SidebarLockButton";
import { trackEvent } from "../analytics";
import { Sidebar } from "./Sidebar/Sidebar";
const LibraryMenuItems = ({
isLoading,
@ -372,54 +370,6 @@ const LibraryMenuItems = ({
(item) => item.status === "published",
);
const renderLibraryHeader = () => {
return (
<>
<div className="layer-ui__library-header" key="library-header">
{renderLibraryActions()}
{device.canDeviceFitSidebar && (
<>
<div className="layer-ui__sidebar-lock-button">
<SidebarLockButton
checked={appState.isLibraryMenuDocked}
onChange={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.add("animate");
const nextState = !appState.isLibraryMenuDocked;
setAppState({
isLibraryMenuDocked: nextState,
});
trackEvent(
"library",
`toggleLibraryDock (${nextState ? "dock" : "undock"})`,
`sidebar (${device.isMobile ? "mobile" : "desktop"})`,
);
}}
/>
</div>
</>
)}
{!device.isMobile && (
<div className="ToolIcon__icon__close">
<button
className="Modal__close"
onClick={() =>
setAppState({
isLibraryOpen: false,
})
}
aria-label={t("buttons.close")}
>
{close}
</button>
</div>
)}
</div>
</>
);
};
const renderLibraryMenuItems = () => {
return (
<Stack.Col
@ -548,7 +498,11 @@ const LibraryMenuItems = ({
}
>
{showRemoveLibAlert && renderRemoveLibAlert()}
{renderLibraryHeader()}
{/* NOTE using SidebarHeader here isn't semantic since this may render
outside of a sidebar, but for now it doesn't matter */}
<Sidebar.Header className="layer-ui__library-header">
{renderLibraryActions()}
</Sidebar.Header>
{renderLibraryMenuItems()}
{!device.isMobile && renderLibraryFooter()}
</div>

View File

@ -1,5 +1,5 @@
import React from "react";
import { AppState, ExcalidrawProps } from "../types";
import { AppState, Device, ExcalidrawProps } from "../types";
import { ActionManager } from "../actions/manager";
import { t } from "../i18n";
import Stack from "./Stack";
@ -44,6 +44,8 @@ type MobileMenuProps = {
appState: AppState,
) => JSX.Element | null;
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
device: Device;
};
export const MobileMenu = ({
@ -63,6 +65,8 @@ export const MobileMenu = ({
onImageAction,
renderTopRightUI,
renderCustomStats,
renderCustomSidebar,
device,
}: MobileMenuProps) => {
const renderToolbar = () => {
return (
@ -107,11 +111,16 @@ export const MobileMenu = ({
penDetected={appState.penDetected}
/>
</Stack.Row>
{libraryMenu}
{libraryMenu && <Island padding={2}>{libraryMenu}</Island>}
</Stack.Col>
)}
</Section>
<HintViewer appState={appState} elements={elements} isMobile={true} />
<HintViewer
appState={appState}
elements={elements}
isMobile={true}
device={device}
/>
</FixedSideContainer>
);
};
@ -175,6 +184,7 @@ export const MobileMenu = ({
};
return (
<>
{appState.openSidebar === "customSidebar" && renderCustomSidebar?.()}
{!appState.viewModeEnabled && renderToolbar()}
{!appState.openMenu && appState.showStats && (
<Stats
@ -230,7 +240,7 @@ export const MobileMenu = ({
{renderAppToolbar()}
{appState.scrolledOutside &&
!appState.openMenu &&
!appState.isLibraryOpen && (
appState.openSidebar !== "library" && (
<button
className="scroll-back-to-content"
onClick={() => {

View File

@ -0,0 +1,89 @@
@import "open-color/open-color";
@import "../../css/variables.module";
.excalidraw {
.layer-ui__sidebar {
position: absolute;
top: var(--sat);
bottom: var(--sab);
right: var(--sar);
z-index: 5;
box-shadow: var(--shadow-island);
overflow: hidden;
border-radius: var(--border-radius-lg);
margin: var(--space-factor);
width: calc(#{$right-sidebar-width} - var(--space-factor) * 2);
padding: 0.5rem;
box-sizing: border-box;
.Island {
box-shadow: none;
}
.ToolIcon__icon {
border-radius: var(--border-radius-md);
}
.ToolIcon__icon__close {
.Modal__close {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text);
}
}
.Island {
--padding: 0;
background-color: var(--island-bg-color);
border-radius: var(--border-radius-lg);
padding: calc(var(--padding) * var(--space-factor));
position: relative;
transition: box-shadow 0.5s ease-in-out;
}
}
.layer-ui__sidebar__header {
display: flex;
align-items: center;
width: 100%;
margin: 2px 0 15px 0;
&:empty {
margin: 0;
}
button {
// 2px from the left to account for focus border of left-most button
margin: 0 2px;
}
}
.layer-ui__sidebar__header__buttons {
display: flex;
align-items: center;
margin-left: auto;
}
.layer-ui__sidebar-dock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
.ToolIcon_type_floating .ToolIcon__icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon {
background-color: var(--color-primary);
}
}
}
}

View File

@ -0,0 +1,355 @@
import React from "react";
import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
import {
act,
fireEvent,
queryAllByTestId,
queryByTestId,
render,
waitFor,
withExcalidrawDimensions,
} from "../../tests/test-utils";
describe("Sidebar", () => {
it("should render custom sidebar", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
it("should render custom sidebar header", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<Sidebar.Header>
<div id="test-sidebar-header-content">42</div>
</Sidebar.Header>
</Sidebar>
)}
/>,
);
const node = container.querySelector("#test-sidebar-header-content");
expect(node).not.toBe(null);
// make sure we don't render the default fallback header,
// just the custom one
expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
});
it("should render only one sidebar and prefer the custom one", async () => {
const { container } = await render(
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
await waitFor(() => {
// make sure the custom sidebar is rendered
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
expect(sidebars.length).toBe(1);
});
});
it("should always render custom sidebar with close button & close on click", async () => {
const onClose = jest.fn();
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar" onClose={onClose}>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-close");
expect(closeButton).not.toBe(null);
fireEvent.click(closeButton!.querySelector("button")!);
await waitFor(() => {
expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
expect(onClose).toHaveBeenCalled();
});
});
it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
const CustomExcalidraw = () => {
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar">hello</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
// should show dock button when the sidebar fits to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null);
});
// should not show dock button when the sidebar does not fit to be docked
// -------------------------------------------------------------------------
await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).toBe(null);
});
});
it("should support controlled docking", async () => {
let _setDockable: (dockable: boolean) => void = null!;
const CustomExcalidraw = () => {
const [dockable, setDockable] = React.useState(false);
_setDockable = setDockable;
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar
className="test-sidebar"
docked={false}
dockable={dockable}
>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
act(() => {
_setDockable(false);
});
await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).toBe(null);
});
// should show dock button when `dockable` is `true`, even if `docked`
// prop is set
// -------------------------------------------------------------------------
act(() => {
_setDockable(true);
});
await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const closeButton = queryByTestId(sidebar!, "sidebar-dock");
expect(closeButton).not.toBe(null);
});
});
});
it("should support controlled docking", async () => {
let _setDocked: (docked?: boolean) => void = null!;
const CustomExcalidraw = () => {
const [docked, setDocked] = React.useState<boolean | undefined>();
_setDocked = setDocked;
return (
<Excalidraw
initialData={{ appState: { openSidebar: "customSidebar" } }}
renderSidebar={() => (
<Sidebar className="test-sidebar" docked={docked}>
hello
</Sidebar>
)}
/>
);
};
const { container } = await render(<CustomExcalidraw />);
const { h } = window;
await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
const dockButton = await waitFor(() => {
const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
expect(sidebar).not.toBe(null);
const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
expect(dockBotton).not.toBe(null);
return dockBotton!;
});
const dockButtonInput = dockButton.querySelector("input")!;
// should not show dock button when `dockable` is `false`
// -------------------------------------------------------------------------
expect(h.state.isSidebarDocked).toBe(false);
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).not.toBeChecked();
});
// shouldn't update `appState.isSidebarDocked` when the sidebar
// is controlled (`docked` prop is set), as host apps should handle
// the state themselves
// -------------------------------------------------------------------------
act(() => {
_setDocked(true);
});
await waitFor(() => {
expect(dockButtonInput).toBeChecked();
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(false);
expect(dockButtonInput).toBeChecked();
});
// the `appState.isSidebarDocked` should remain untouched when
// `props.docked` is set to `false`, and user toggles
// -------------------------------------------------------------------------
act(() => {
_setDocked(false);
h.setState({ isSidebarDocked: true });
});
await waitFor(() => {
expect(h.state.isSidebarDocked).toBe(true);
expect(dockButtonInput).not.toBeChecked();
});
fireEvent.click(dockButtonInput);
await waitFor(() => {
expect(dockButtonInput).not.toBeChecked();
expect(h.state.isSidebarDocked).toBe(true);
});
});
});
it("should toggle sidebar using props.toggleMenu()", async () => {
const { container } = await render(
<Excalidraw
renderSidebar={() => (
<Sidebar>
<div id="test-sidebar-content">42</div>
</Sidebar>
)}
/>,
);
// sidebar isn't rendered initially
// -------------------------------------------------------------------------
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle sidebar off
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar off (=> still hidden)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
});
// force-toggle sidebar on
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).not.toBe(null);
});
// toggle library (= hide custom sidebar)
// -------------------------------------------------------------------------
expect(window.h.app.toggleMenu("library")).toBe(true);
await waitFor(() => {
const node = container.querySelector("#test-sidebar-content");
expect(node).toBe(null);
// make sure only one sidebar is rendered
const sidebars = container.querySelectorAll(".layer-ui__sidebar");
expect(sidebars.length).toBe(1);
});
});
});

View File

@ -0,0 +1,121 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Island } from ".././Island";
import { atom, useAtom } from "jotai";
import { jotaiScope } from "../../jotai";
import {
SidebarPropsContext,
SidebarProps,
SidebarPropsContextValue,
} from "./common";
import { SidebarHeaderComponents } from "./SidebarHeader";
import "./Sidebar.scss";
import clsx from "clsx";
import { useExcalidrawSetAppState } from "../App";
import { updateObject } from "../../utils";
/** using a counter instead of boolean to handle race conditions where
* the host app may render (mount/unmount) multiple different sidebar */
export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
export const Sidebar = ({
children,
onClose,
onDock,
docked,
dockable = true,
className,
__isInternal,
}: SidebarProps<{
// NOTE sidebars we use internally inside the editor must have this flag set.
// It indicates that this sidebar should have lower precedence over host
// sidebars, if both are open.
/** @private internal */
__isInternal?: boolean;
}>) => {
const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
hostSidebarCountersAtom,
jotaiScope,
);
const setAppState = useExcalidrawSetAppState();
const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false);
useLayoutEffect(() => {
if (docked === undefined) {
// ugly hack to get initial state out of AppState without susbcribing
// to it as a whole (once we have granular subscriptions, we'll move
// to that)
//
// NOTE this means that is updated `state.isSidebarDocked` changes outside
// of this compoent, it won't be reflected here. Currently doesn't happen.
setAppState((state) => {
setIsDockedFallback(state.isSidebarDocked);
// bail from update
return null;
});
}
}, [setAppState, docked]);
useLayoutEffect(() => {
if (!__isInternal) {
setHostSidebarCounters((s) => ({
rendered: s.rendered + 1,
docked: isDockedFallback ? s.docked + 1 : s.docked,
}));
return () => {
setHostSidebarCounters((s) => ({
rendered: s.rendered - 1,
docked: isDockedFallback ? s.docked - 1 : s.docked,
}));
};
}
}, [__isInternal, setHostSidebarCounters, isDockedFallback]);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
return () => {
onCloseRef.current?.();
};
}, []);
const headerPropsRef = useRef<SidebarPropsContextValue>({});
headerPropsRef.current.onClose = () => {
setAppState({ openSidebar: null });
};
headerPropsRef.current.onDock = (isDocked) => {
if (docked === undefined) {
setAppState({ isSidebarDocked: isDocked });
setIsDockedFallback(isDocked);
}
onDock?.(isDocked);
};
// renew the ref object if the following props change since we want to
// rerender. We can't pass down as component props manually because
// the <Sidebar.Header/> can be rendered upsream.
headerPropsRef.current = updateObject(headerPropsRef.current, {
docked: docked ?? isDockedFallback,
dockable,
});
if (hostSidebarCounters.rendered > 0 && __isInternal) {
return null;
}
return (
<Island padding={2} className={clsx("layer-ui__sidebar", className)}>
<SidebarPropsContext.Provider value={headerPropsRef.current}>
<SidebarHeaderComponents.Context>
<SidebarHeaderComponents.Component __isFallback />
{children}
</SidebarHeaderComponents.Context>
</SidebarPropsContext.Provider>
</Island>
);
};
Sidebar.Header = SidebarHeaderComponents.Component;

View File

@ -0,0 +1,95 @@
import clsx from "clsx";
import { useContext } from "react";
import { t } from "../../i18n";
import { useDevice } from "../App";
import { SidebarPropsContext } from "./common";
import { close } from "../icons";
import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
import { Tooltip } from "../Tooltip";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarDockButton = (props: {
checked: boolean;
onChange?(): void;
}) => {
return (
<div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_medium`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
</div>{" "}
</label>{" "}
</Tooltip>
</div>
);
};
const _SidebarHeader: React.FC<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
const device = useDevice();
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
const renderCloseButton = !!props.onClose;
return (
<div
className={clsx("layer-ui__sidebar__header", className)}
data-testid="sidebar-header"
>
{children}
{(renderDockButton || renderCloseButton) && (
<div className="layer-ui__sidebar__header__buttons">
{renderDockButton && (
<SidebarDockButton
checked={!!props.docked}
onChange={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.add("animate");
props.onDock?.(!props.docked);
}}
/>
)}
{renderCloseButton && (
<div className="ToolIcon__icon__close" data-testid="sidebar-close">
<button
className="Modal__close"
onClick={props.onClose}
aria-label={t("buttons.close")}
>
{close}
</button>
</div>
)}
</div>
)}
</div>
);
};
const [Context, Component] = withUpstreamOverride(_SidebarHeader);
/** @private */
export const SidebarHeaderComponents = { Context, Component };

View File

@ -0,0 +1,22 @@
import React from "react";
export type SidebarProps<P = {}> = {
children: React.ReactNode;
/**
* Called on sidebar close (either by user action or by the editor).
*/
onClose?: () => void | boolean;
/** if not supplied, sidebar won't be dockable */
onDock?: (docked: boolean) => void;
docked?: boolean;
dockable?: boolean;
className?: string;
} & P;
export type SidebarPropsContextValue = Pick<
SidebarProps,
"onClose" | "onDock" | "docked" | "dockable"
>;
export const SidebarPropsContext =
React.createContext<SidebarPropsContextValue>({});

View File

@ -1,22 +0,0 @@
@import "../css/variables.module";
.excalidraw {
.layer-ui__sidebar-lock-button {
@include toolbarButtonColorStates;
margin-right: 0.2rem;
}
.ToolIcon_type_floating .side_lock_icon {
width: calc(var(--space-factor) * 7);
height: calc(var(--space-factor) * 7);
svg {
// mirror
transform: scale(-1, 1);
}
}
.ToolIcon_type_checkbox {
&:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon {
background-color: var(--color-primary);
}
}
}

View File

@ -1,46 +0,0 @@
import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
import { ToolButtonSize } from "./ToolButton";
import { t } from "../i18n";
import { Tooltip } from "./Tooltip";
import "./SidebarLockButton.scss";
type SidebarLockIconProps = {
checked: boolean;
onChange?(): void;
};
const DEFAULT_SIZE: ToolButtonSize = "medium";
const SIDE_LIBRARY_TOGGLE_ICON = (
<svg viewBox="0 0 24 24" fill="#ffffff">
<path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path>
</svg>
);
export const SidebarLockButton = (props: SidebarLockIconProps) => {
return (
<Tooltip label={t("labels.sidebarLock")}>
<label
className={clsx(
"ToolIcon ToolIcon__lock ToolIcon_type_floating",
`ToolIcon_size_${DEFAULT_SIZE}`,
)}
>
<input
className="ToolIcon_type_checkbox"
type="checkbox"
onChange={props.onChange}
checked={props.checked}
aria-label={t("labels.sidebarLock")}
/>{" "}
<div className="ToolIcon__icon side_lock_icon" tabIndex={0}>
{SIDE_LIBRARY_TOGGLE_ICON}
</div>{" "}
</label>{" "}
</Tooltip>
);
};

View File

@ -0,0 +1,63 @@
import React, {
useMemo,
useContext,
useLayoutEffect,
useState,
createContext,
} from "react";
export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
const DefaultComponentContext = createContext<ContextValue>([
false,
() => {},
]);
const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
const contextValue: ContextValue = useMemo(
() => [isRenderedUpstream, setIsRenderedUpstream],
[isRenderedUpstream],
);
return (
<DefaultComponentContext.Provider value={contextValue}>
{children}
</DefaultComponentContext.Provider>
);
};
const DefaultComponent = (
props: P & {
// indicates whether component should render when not rendered upstream
/** @private internal */
__isFallback?: boolean;
},
) => {
const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
DefaultComponentContext,
);
useLayoutEffect(() => {
if (!props.__isFallback) {
setIsRenderedUpstream(true);
return () => setIsRenderedUpstream(false);
}
}, [props.__isFallback, setIsRenderedUpstream]);
if (props.__isFallback && isRenderedUpstream) {
return null;
}
return <Component {...props} />;
};
if (Component.name) {
DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
}
return [ComponentContext, DefaultComponent] as const;
};

View File

@ -148,7 +148,7 @@ class Library {
defaultStatus?: "unpublished" | "published";
}): Promise<LibraryItems> => {
if (openLibraryMenu) {
this.app.setState({ isLibraryOpen: true });
this.app.setState({ openSidebar: "library" });
}
return this.setLibrary(() => {

View File

@ -9,7 +9,7 @@ import {
LibraryItem,
NormalizedZoomValue,
} from "../types";
import { ImportedDataState } from "./types";
import { ImportedDataState, LegacyAppState } from "./types";
import {
getNonDeletedElements,
getNormalizedDimensions,
@ -251,6 +251,43 @@ export const restoreElements = (
}, [] as ExcalidrawElement[]);
};
const coalesceAppStateValue = <
T extends keyof ReturnType<typeof getDefaultAppState>,
>(
key: T,
appState: Exclude<ImportedDataState["appState"], null | undefined>,
defaultAppState: ReturnType<typeof getDefaultAppState>,
) => {
const value = appState[key];
// NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions)
return value !== undefined ? value! : defaultAppState[key];
};
const LegacyAppStateMigrations: {
[K in keyof LegacyAppState]: (
ImportedDataState: Exclude<ImportedDataState["appState"], null | undefined>,
defaultAppState: ReturnType<typeof getDefaultAppState>,
) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
} = {
isLibraryOpen: (appState, defaultAppState) => {
return [
"openSidebar",
"isLibraryOpen" in appState
? appState.isLibraryOpen
? "library"
: null
: coalesceAppStateValue("openSidebar", appState, defaultAppState),
];
},
isLibraryMenuDocked: (appState, defaultAppState) => {
return [
"isSidebarDocked",
appState.isLibraryMenuDocked ??
coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
];
},
};
export const restoreAppState = (
appState: ImportedDataState["appState"],
localAppState: Partial<AppState> | null | undefined,
@ -258,11 +295,30 @@ export const restoreAppState = (
appState = appState || {};
const defaultAppState = getDefaultAppState();
const nextAppState = {} as typeof defaultAppState;
// first, migrate all legacy AppState properties to new ones. We do it
// in one go before migrate the rest of the properties in case the new ones
// depend on checking any other key (i.e. they are coupled)
for (const legacyKey of Object.keys(
LegacyAppStateMigrations,
) as (keyof typeof LegacyAppStateMigrations)[]) {
if (legacyKey in appState) {
const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey](
appState,
defaultAppState,
);
(nextAppState as any)[nextKey] = nextValue;
}
}
for (const [key, defaultValue] of Object.entries(defaultAppState) as [
keyof typeof defaultAppState,
any,
][]) {
// if AppState contains a legacy key, prefer that one and migrate its
// value to the new one
const suppliedValue = appState[key];
const localValue = localAppState ? localAppState[key] : undefined;
(nextAppState as any)[key] =
suppliedValue !== undefined
@ -299,9 +355,12 @@ export const restoreAppState = (
: appState.zoom || defaultAppState.zoom,
// when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state.
isLibraryOpen: nextAppState.isLibraryMenuDocked
? nextAppState.isLibraryOpen
: false,
openSidebar:
nextAppState.openSidebar === "library"
? nextAppState.isSidebarDocked
? "library"
: null
: nextAppState.openSidebar,
};
};

View File

@ -17,12 +17,32 @@ export interface ExportedDataState {
files: BinaryFiles | undefined;
}
/**
* Map of legacy AppState keys, with values of:
* [<legacy type>, <new AppState proeprty>]
*
* This is a helper type used in downstream abstractions.
* Don't consume on its own.
*/
export type LegacyAppState = {
/** @deprecated #5663 TODO remove 22-12-15 */
isLibraryOpen: [boolean, "openSidebar"];
/** @deprecated #5663 TODO remove 22-12-15 */
isLibraryMenuDocked: [boolean, "isSidebarDocked"];
};
export interface ImportedDataState {
type?: string;
version?: number;
source?: string;
elements?: readonly ExcalidrawElement[] | null;
appState?: Readonly<Partial<AppState>> | null;
appState?: Readonly<
Partial<
AppState & {
[T in keyof LegacyAppState]: LegacyAppState[T][0];
}
>
> | null;
scrollToContent?: boolean;
libraryItems?: LibraryItems_anyVersion;
files?: BinaryFiles;

View File

@ -17,6 +17,8 @@ Please add the latest change on the top under the correct section.
#### Features
- Support rendering custom sidebar using [`renderSidebar`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#renderSidebar) prop ([#5663](https://github.com/excalidraw/excalidraw/pull/5663)).
- Add [`toggleMenu`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onMenuToggle) prop to toggle specific menu open/close state ([#5663](https://github.com/excalidraw/excalidraw/pull/5663)).
- Support [theme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#theme) to be semi-controlled [#5660](https://github.com/excalidraw/excalidraw/pull/5660).
- Added support for storing [`customData`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#storing-custom-data-to-excalidraw-elements) on Excalidraw elements [#5592].
- Added `exportPadding?: number;` to [exportToCanvas](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exporttocanvas) and [exportToBlob](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exporttoblob). The default value of the padding is 10.

View File

@ -376,13 +376,17 @@ Most notably, you can customize the primary colors, by overriding these variable
For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/src/css/theme.scss), though most of them will not make sense to override.
### Does this package support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
### Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| [`onChange`](#onChange) | Function | | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw elements and the current app state. |
| [`initialData`](#initialData) | <pre>{elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState<a> } </pre> | null | The initial data with which app loads. |
| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) &#124; [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) &#124; [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) &#124; <pre>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317">resolvablePromise</a> } }</pre> | | Ref to be passed to Excalidraw |
| [`initialData`](#initialData) | <code>{elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState<a> } </code> | null | The initial data with which app loads. |
| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) &#124; [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) &#124; [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) &#124; <code>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317">resolvablePromise</a> } }</code> | | Ref to be passed to Excalidraw |
| [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked |
| [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode |
| [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. |
@ -390,22 +394,23 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr
| [`renderTopRightUI`](#renderTopRightUI) | Function | | Function that renders custom UI in top right corner |
| [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer |
| [`renderCustomStats`](#renderCustomStats) | Function | | Function that can be used to render custom stats on the stats dialog. |
| [`renderSIdebar`](#renderSIdebar) | Function | | Render function that renders custom sidebar. |
| [`viewModeEnabled`](#viewModeEnabled) | boolean | | This implies if the app is in view mode. |
| [`zenModeEnabled`](#zenModeEnabled) | boolean | | This implies if the zen mode is enabled |
| [`gridModeEnabled`](#gridModeEnabled) | boolean | | This implies if the grid mode is enabled |
| [`libraryReturnUrl`](#libraryReturnUrl) | string | | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | [THEME.LIGHT](#THEME-1) &#124; [THEME.DARK](#THEME-1) | [THEME.LIGHT](#THEME-1) | The theme of the Excalidraw component |
| [`name`](#name) | string | | Name of the drawing |
| [`UIOptions`](#UIOptions) | <pre>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</pre> | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) |
| [`onPaste`](#onPaste) | <pre>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L21">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</pre> | | Callback to be triggered if passed when the something is pasted in to the scene |
| [`UIOptions`](#UIOptions) | <code>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</code> | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) |
| [`onPaste`](#onPaste) | <code>(data: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/clipboard.ts#L21">ClipboardData</a>, event: ClipboardEvent &#124; null) => boolean</code> | | Callback to be triggered if passed when the something is pasted in to the scene |
| [`detectScroll`](#detectScroll) | boolean | true | Indicates whether to update the offsets when nearest ancestor is scrolled. |
| [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. |
| [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> | | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`onLibraryChange`](#onLibraryChange) | <code>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </code> | | The callback if supplied is triggered when the library is updated and receives the library items. |
| [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load |
| [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise<string>` | Allows you to override `id` generation for files added on canvas |
| [`onLinkOpen`](#onLinkOpen) | <pre>(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">NonDeletedExcalidrawElement</a>, event: CustomEvent) </pre> | | This prop if passed will be triggered when link of an element is clicked. |
| [`onPointerDown`](#onPointerDown) | <pre>(activeTool: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L93"> AppState["activeTool"]</a>, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L365">PointerDownState</a>) => void</pre> | | This prop if passed gets triggered on pointer down evenets |
| [`onScrollChange`](#onScrollChange) | (scrollX: number, scrollY: number) | | This prop if passed gets triggered when scrolling the canvas. |
| [`onLinkOpen`](#onLinkOpen) | <code>(element: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">NonDeletedExcalidrawElement</a>, event: CustomEvent) </code> | | This prop if passed will be triggered when link of an element is clicked. |
| [`onPointerDown`](#onPointerDown) | <code>(activeTool: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L93"> AppState["activeTool"]</a>, pointerDownState: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L365">PointerDownState</a>) => void</code> | | This prop if passed gets triggered on pointer down evenets |
| [`onScrollChange`](#onScrollChange) | <code>(scrollX: number, scrollY: number)</code> | | This prop if passed gets triggered when scrolling the canvas. |
### Dimensions of Excalidraw
@ -503,6 +508,7 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
| [setActiveTool](#setActiveTool) | <code>(tool: { type: typeof <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L4">SHAPES</a> [number]["value"]&#124; "eraser" } &#124; { type: "custom"; customType: string }) => void</code> | This API can be used to set the active tool |
| [setCursor](#setCursor) | <code>(cursor: string) => void </code> | This API can be used to set customise the mouse cursor on the canvas |
| [resetCursor](#resetCursor) | <code>() => void </code> | This API can be used to reset to default mouse cursor on the canvas |
| [toggleMenu](#toggleMenu) | <code>(type: string, force?: boolean) => boolean</code> | Toggles specific menus on/off |
#### `readyPromise`
@ -619,6 +625,38 @@ A function returning JSX to render custom UI footer. For example, you can use th
A function that can be used to render custom stats (returns JSX) in the nerd stats dialog. For example you can use this prop to render the size of the elements in the storage.
#### `renderSidebar`
<pre>
() => JSX | null
</pre>
Optional function that can render custom sidebar. This sidebar is the same that the library menu sidebar is using, and can be used for any purposes your app needs. The render function should return a `<Sidebar>` instance — a component that is exported from the Excalidraw package. It accepts `children` which can be any content you like to render inside.
The `<Sidebar>` component takes these props (all are optional except `children`):
| name | type | description |
| --- | --- | --- |
| className | string |
| children | <pre>React.ReactNode</pre> | Content you want to render inside the sidebar. |
| onClose | <pre>() => void</pre> | Invoked when the component is closed (by user, or the editor). No need to act on this event, as the editor manages the sidebar open state on its own. |
| onDock | <pre>(isDocked: boolean) => void</pre> | Invoked when the user toggles the dock button. |
| docked | boolean | Indicates whether the sidebar is docked. By default, the sidebar is undocked. If passed, the docking becomes controlled, and you are responsible for updating the `docked` state by listening on `onDock` callback. See [here](#dockedSidebarBreakpoint) for more info docking. |
| dockable | boolean | Indicates whether the sidebar can be docked by user (=the dock button is shown). If `false`, you can still dock programmatically by passing `docked=true` |
The sidebar will always include a header with close/dock buttons (when applicable).
You can also add custom content to the header, by rendering `<Sidebar.Header>` as a child of the `<Sidebar>` component. Note that the custom header will still include the default buttons.
The `<Sidebar.Header>` component takes these props children (all are optional):
| name | type | description |
| --- | --- | --- |
| className | string |
| children | <pre>React.ReactNode</pre> | Content you want to render inside the sidebar header, sibling of the header buttons. |
For example code, see the example [`App.tsx`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/example/App.tsx#L524) file.
#### `viewModeEnabled`
This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over `intialData.appState.viewModeEnabled`, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app.
@ -753,6 +791,16 @@ This API can be used to customise the mouse cursor on the canvas and has the bel
(cursor: string) => void
</pre>
#### `toggleMenu`
<pre>
(type: "library" | "customSidebar", force?: boolean) => boolean
</pre>
This API can be used to toggle a specific menu (currently only the sidebars), and returns whether the menu was toggled on or off. If the `force` flag passed, it will force the menu to be toggled either on/off based on the boolean passed.
This API is especially useful when you render a custom sidebar using [`renderSidebar`](#renderSidebar) prop, and you want to toggle it from your app based on a user action.
#### `resetCursor`
This API can be used to reset to default mouse cursor.
@ -842,10 +890,6 @@ This prop if passed will be triggered when canvas is scrolled and has the below
(scrollX: number, scrollY: number) => void
```
### Does it support collaboration ?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
### Restore utilities
#### `restoreAppState`

View File

@ -1,6 +1,8 @@
import { useEffect, useState, useRef, useCallback } from "react";
import Sidebar from "./sidebar/Sidebar";
import ExampleSidebar from "./sidebar/ExampleSidebar";
import type * as TExcalidraw from "../index";
import "./App.scss";
import initialData from "./initialData";
@ -24,15 +26,12 @@ import {
LibraryItems,
PointerDownState as ExcalidrawPointerDownState,
} from "../../../types";
import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "../../../element/types";
import { NonDeletedExcalidrawElement } from "../../../element/types";
import { ImportedLibraryData } from "../../../data/types";
declare global {
interface Window {
ExcalidrawLib: any;
ExcalidrawLib: typeof TExcalidraw;
}
}
@ -68,6 +67,7 @@ const {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
restoreElements,
Sidebar,
} = window.ExcalidrawLib;
const COMMENT_SVG = (
@ -275,11 +275,14 @@ export default function App() {
[],
);
const onCopy = async (type: string) => {
const onCopy = async (type: "png" | "svg" | "json") => {
if (!excalidrawAPI) {
return false;
}
await exportToClipboard({
elements: excalidrawAPI?.getSceneElements(),
appState: excalidrawAPI?.getAppState(),
files: excalidrawAPI?.getFiles(),
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
type,
});
window.alert(`Copied to clipboard as ${type} successfully`);
@ -302,12 +305,15 @@ export default function App() {
};
const rerenderCommentIcons = () => {
if (!excalidrawAPI) {
return false;
}
const commentIconsElements = appRef.current.querySelectorAll(
".comment-icon",
) as HTMLElement[];
commentIconsElements.forEach((ele) => {
const id = ele.id;
const appstate = excalidrawAPI?.getAppState();
const appstate = excalidrawAPI.getAppState();
const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: commentIcons[id].x, sceneY: commentIcons[id].y },
appstate,
@ -325,12 +331,15 @@ export default function App() {
pointerDownState: PointerDownState,
) => {
return withBatchedUpdatesThrottled((event) => {
if (!excalidrawAPI) {
return false;
}
const { x, y } = viewportCoordsToSceneCoords(
{
clientX: event.clientX - pointerDownState.hitElementOffsets.x,
clientY: event.clientY - pointerDownState.hitElementOffsets.y,
},
excalidrawAPI?.getAppState(),
excalidrawAPI.getAppState(),
);
setCommentIcons({
...commentIcons,
@ -371,10 +380,13 @@ export default function App() {
};
const renderCommentIcons = () => {
return Object.values(commentIcons).map((commentIcon) => {
const appState = excalidrawAPI?.getAppState();
if (!excalidrawAPI) {
return false;
}
const appState = excalidrawAPI.getAppState();
const { x, y } = sceneCoordsToViewportCoords(
{ sceneX: commentIcon.x, sceneY: commentIcon.y },
excalidrawAPI?.getAppState(),
excalidrawAPI.getAppState(),
);
return (
<div
@ -478,6 +490,7 @@ export default function App() {
if (left + COMMENT_INPUT_WIDTH > appState.width) {
left = appState.width - COMMENT_INPUT_WIDTH - COMMENT_ICON_DIMENSION / 2;
}
return (
<textarea
className="comment"
@ -507,10 +520,20 @@ export default function App() {
/>
);
};
const renderSidebar = () => {
return (
<Sidebar>
<Sidebar.Header>Custom header!</Sidebar.Header>
Custom sidebar!
</Sidebar>
);
};
return (
<div className="App" ref={appRef}>
<h1> Excalidraw Example</h1>
<Sidebar>
<ExampleSidebar>
<div className="button-wrapper">
<button onClick={loadSceneOrLibrary}>Load Scene or Library</button>
<button className="update-scene" onClick={updateScene}>
@ -644,10 +667,27 @@ export default function App() {
</div>
</div>
<div className="excalidraw-wrapper">
<div
style={{
position: "absolute",
left: "50%",
bottom: "20px",
display: "flex",
zIndex: 9999999999999999,
padding: "5px 10px",
transform: "translateX(-50%)",
background: "rgba(255, 255, 255, 0.8)",
gap: "1rem",
}}
>
<button onClick={() => excalidrawAPI?.toggleMenu("customSidebar")}>
Toggle Custom Sidebar
</button>
</div>
<Excalidraw
ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
initialData={initialStatePromiseRef.current.promise}
onChange={(elements: ExcalidrawElement[], state: AppState) => {
onChange={(elements, state) => {
console.info("Elements :", elements, "State : ", state);
}}
onPointerUpdate={(payload: {
@ -669,6 +709,7 @@ export default function App() {
onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown}
onScrollChange={rerenderCommentIcons}
renderSidebar={renderSidebar}
/>
{Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}
{comment && renderComment()}
@ -693,6 +734,9 @@ export default function App() {
</label>
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
@ -702,7 +746,6 @@ export default function App() {
width: 300,
height: 100,
},
embedScene: true,
files: excalidrawAPI?.getFiles(),
});
appRef.current.querySelector(".export-svg").innerHTML =
@ -715,6 +758,9 @@ export default function App() {
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const blob = await exportToBlob({
elements: excalidrawAPI?.getSceneElements(),
mimeType: "image/png",
@ -736,15 +782,18 @@ export default function App() {
<button
onClick={async () => {
if (!excalidrawAPI) {
return;
}
const canvas = await exportToCanvas({
elements: excalidrawAPI?.getSceneElements(),
elements: excalidrawAPI.getSceneElements(),
appState: {
...initialData.appState,
exportWithDarkMode,
},
files: excalidrawAPI?.getFiles(),
files: excalidrawAPI.getFiles(),
});
const ctx = canvas.getContext("2d");
const ctx = canvas.getContext("2d")!;
ctx.font = "30px Virgil";
ctx.strokeText("My custom text", 50, 60);
setCanvasUrl(canvas.toDataURL());
@ -756,7 +805,7 @@ export default function App() {
<img src={canvasUrl} alt="" />
</div>
</div>
</Sidebar>
</ExampleSidebar>
</div>
);
}

View File

@ -1,5 +1,5 @@
import React, { useState } from "react";
import "./Sidebar.scss";
import "./ExampleSidebar.scss";
export default function Sidebar({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);

View File

@ -21,6 +21,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerUpdate,
renderTopRightUI,
renderFooter,
renderSidebar,
langCode = defaultLang.code,
viewModeEnabled,
zenModeEnabled,
@ -111,6 +112,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown}
onScrollChange={onScrollChange}
renderSidebar={renderSidebar}
/>
</Provider>
</InitializeApp>
@ -232,3 +234,5 @@ export {
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
} from "../../utils";
export { Sidebar } from "../../components/Sidebar/Sidebar";

View File

@ -38,11 +38,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -50,6 +49,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -213,11 +213,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -225,6 +224,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -394,11 +394,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -406,6 +405,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -734,11 +734,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -746,6 +745,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -1074,11 +1074,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -1086,6 +1085,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -1255,11 +1255,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -1267,6 +1266,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -1470,11 +1470,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -1482,6 +1481,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -1744,11 +1744,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -1756,6 +1755,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -2102,11 +2102,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -2114,6 +2113,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": "backgroundColorPicker",
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -2906,11 +2906,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -2918,6 +2917,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -3246,11 +3246,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -3258,6 +3257,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -3586,11 +3586,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -3598,6 +3597,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4006,11 +4006,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4018,6 +4017,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4286,11 +4286,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4298,6 +4297,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4647,11 +4647,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4659,6 +4658,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4755,11 +4755,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4767,6 +4766,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4841,11 +4841,10 @@ Object {
"gridSize": null,
"height": 100,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4853,6 +4852,7 @@ Object {
"offsetTop": 10,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,

View File

@ -38,11 +38,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -50,6 +49,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -549,11 +549,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -561,6 +560,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -1066,11 +1066,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": false,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -1078,6 +1077,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -1928,11 +1928,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -1940,6 +1939,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -2151,11 +2151,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -2163,6 +2162,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -2659,11 +2659,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -2671,6 +2670,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -2937,11 +2937,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -2949,6 +2948,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -3116,11 +3116,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -3128,6 +3127,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -3607,11 +3607,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -3619,6 +3618,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": "strokeColorPicker",
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -3866,11 +3866,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -3878,6 +3877,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4089,11 +4089,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4101,6 +4100,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4356,11 +4356,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4368,6 +4367,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -4633,11 +4633,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -4645,6 +4644,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -5057,11 +5057,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -5069,6 +5068,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -5383,11 +5383,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -5395,6 +5394,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -5684,11 +5684,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -5696,6 +5695,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -5913,11 +5913,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -5925,6 +5924,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -6092,11 +6092,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -6104,6 +6103,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -6595,11 +6595,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -6607,6 +6606,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -6945,11 +6945,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -6957,6 +6956,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -9186,11 +9186,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -9198,6 +9197,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -9586,11 +9586,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -9598,6 +9597,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -9864,11 +9864,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -9876,6 +9875,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -10103,11 +10103,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -10115,6 +10114,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -10411,11 +10411,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -10423,6 +10422,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -10590,11 +10590,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -10602,6 +10601,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -10769,11 +10769,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -10781,6 +10780,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -10948,11 +10948,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -10960,6 +10959,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -11174,11 +11174,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -11186,6 +11185,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -11400,11 +11400,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -11412,6 +11411,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -11627,11 +11627,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -11639,6 +11638,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -11853,11 +11853,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -11865,6 +11864,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -12032,11 +12032,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -12044,6 +12043,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -12258,11 +12258,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -12270,6 +12269,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -12437,11 +12437,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -12449,6 +12448,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -12616,11 +12616,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -12628,6 +12627,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -12843,11 +12843,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -12855,6 +12854,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -13639,11 +13639,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -13651,6 +13650,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -13917,11 +13917,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "touch",
"multiElement": null,
"name": "Untitled-201933152653",
@ -13929,6 +13928,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -14027,11 +14027,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -14039,6 +14038,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -14135,11 +14135,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -14147,6 +14146,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -14317,11 +14317,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -14329,6 +14328,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -14670,11 +14670,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -14682,6 +14681,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -14889,11 +14889,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -14901,6 +14900,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -15803,11 +15803,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -15815,6 +15814,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -15911,11 +15911,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -15923,6 +15922,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -16752,11 +16752,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -16764,6 +16763,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -17201,11 +17201,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -17213,6 +17212,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -17502,11 +17502,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "touch",
"multiElement": null,
"name": "Untitled-201933152653",
@ -17514,6 +17513,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -17612,11 +17612,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -17624,6 +17623,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -18158,11 +18158,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -18170,6 +18169,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,
@ -18266,11 +18266,10 @@ Object {
"gridSize": null,
"height": 768,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "Untitled-201933152653",
@ -18278,6 +18277,7 @@ Object {
"offsetTop": 0,
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,

View File

@ -38,16 +38,16 @@ Object {
"fileHandle": null,
"gridSize": null,
"isBindingEnabled": true,
"isLibraryMenuDocked": false,
"isLibraryOpen": false,
"isLoading": false,
"isResizing": false,
"isRotating": false,
"isSidebarDocked": false,
"lastPointerDownWith": "mouse",
"multiElement": null,
"name": "name",
"openMenu": null,
"openPopup": null,
"openSidebar": null,
"pasteDialog": Object {
"data": null,
"shown": false,

View File

@ -110,9 +110,18 @@ export const updateSceneData = (data: SceneData) => {
const originalGetBoundingClientRect =
global.window.HTMLDivElement.prototype.getBoundingClientRect;
export const mockBoundingClientRect = () => {
// override getBoundingClientRect as by default it will always return all values as 0 even if customized in html
global.window.HTMLDivElement.prototype.getBoundingClientRect = () => ({
export const mockBoundingClientRect = (
{
top = 0,
left = 0,
bottom = 0,
right = 0,
width = 1920,
height = 1080,
x = 0,
y = 0,
toJSON = () => {},
} = {
top: 10,
left: 20,
bottom: 10,
@ -121,10 +130,39 @@ export const mockBoundingClientRect = () => {
x: 10,
y: 20,
height: 100,
toJSON: () => {},
},
) => {
// override getBoundingClientRect as by default it will always return all values as 0 even if customized in html
global.window.HTMLDivElement.prototype.getBoundingClientRect = () => ({
top,
left,
bottom,
right,
width,
height,
x,
y,
toJSON,
});
};
export const withExcalidrawDimensions = async (
dimensions: { width: number; height: number },
cb: () => void,
) => {
mockBoundingClientRect(dimensions);
// @ts-ignore
window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
window.h.app.refresh();
await cb();
restoreOriginalGetBoundingClientRect();
// @ts-ignore
window.h.app.refreshDeviceState(h.app.excalidrawContainerRef.current!);
window.h.app.refresh();
};
export const restoreOriginalGetBoundingClientRect = () => {
global.window.HTMLDivElement.prototype.getBoundingClientRect =
originalGetBoundingClientRect;

View File

@ -140,6 +140,9 @@ export type AppState = {
| "backgroundColorPicker"
| "strokeColorPicker"
| null;
openSidebar: "library" | "customSidebar" | null;
isSidebarDocked: boolean;
lastPointerDownWith: PointerType;
selectedElementIds: { [id: string]: boolean };
previousSelectedElementIds: { [id: string]: boolean };
@ -161,8 +164,6 @@ export type AppState = {
offsetTop: number;
offsetLeft: number;
isLibraryOpen: boolean;
isLibraryMenuDocked: boolean;
fileHandle: FileSystemHandle | null;
collaborators: Map<string, Collaborator>;
showStats: boolean;
@ -313,6 +314,10 @@ export interface ExcalidrawProps {
pointerDownState: PointerDownState,
) => void;
onScrollChange?: (scrollX: number, scrollY: number) => void;
/**
* Render function that renders custom <Sidebar /> component.
*/
renderSidebar?: () => JSX.Element | null;
}
export type SceneData = {
@ -368,6 +373,7 @@ export type AppProps = Merge<
detectScroll: boolean;
handleKeyboardGlobally: boolean;
isCollaborating: boolean;
children?: React.ReactNode;
}
>;
@ -479,6 +485,7 @@ export type ExcalidrawImperativeAPI = {
setActiveTool: InstanceType<typeof App>["setActiveTool"];
setCursor: InstanceType<typeof App>["setCursor"];
resetCursor: InstanceType<typeof App>["resetCursor"];
toggleMenu: InstanceType<typeof App>["toggleMenu"];
};
export type Device = Readonly<{