feat(packages/excalidraw): state tracking, api hook, and others (#10870)

This commit is contained in:
David Luzar
2026-03-08 23:15:18 +01:00
committed by GitHub
parent fa1f7d9f22
commit 21dd1cfacc
46 changed files with 1900 additions and 582 deletions
+360 -319
View File
@@ -88,6 +88,7 @@ import {
isShallowEqual,
arrayToMap,
applyDarkModeFilter,
AppEventBus,
type EXPORT_IMAGE_TYPES,
randomInteger,
CLASSES,
@@ -448,7 +449,7 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas";
import { isPointHittingLink } from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast";
import { AppStateObserver, type OnStateChange } from "./AppStateObserver";
import { findShapeByKey } from "./shapes";
@@ -464,7 +465,6 @@ import type {
import type { ClipboardData, PastedMixedContent } from "../clipboard";
import type { ExportedElements } from "../data";
import type { ContextMenuItems } from "./ContextMenu";
import type { FileSystemHandle } from "../data/filesystem";
import type {
AppClassProperties,
@@ -488,6 +488,7 @@ import type {
UnsubscribeCallback,
EmbedsValidationStatus,
ElementsPendingErasure,
ExcalidrawImperativeAPIEventMap,
GenerateDiagramToCode,
NullableGridSize,
Offsets,
@@ -513,6 +514,11 @@ const EditorInterfaceContext = React.createContext<EditorInterface>(
);
EditorInterfaceContext.displayName = "EditorInterfaceContext";
const editorLifecycleEventBehavior = {
"editor:mount": { cardinality: "once", replay: "last" },
"editor:initialize": { cardinality: "once", replay: "last" },
} as const;
export const ExcalidrawContainerContext = React.createContext<{
container: HTMLDivElement | null;
id: string | null;
@@ -545,6 +551,15 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
);
ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
export const ExcalidrawAPIContext =
React.createContext<ExcalidrawImperativeAPI | null>(null);
ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext";
export const ExcalidrawAPISetContext = React.createContext<
((api: ExcalidrawImperativeAPI) => void) | null
>(null);
ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext";
export const useApp = () => useContext(AppContext);
export const useAppProps = () => useContext(AppPropsContext);
export const useEditorInterface = () =>
@@ -561,6 +576,10 @@ export const useExcalidrawSetAppState = () =>
useContext(ExcalidrawSetAppStateContext);
export const useExcalidrawActionManager = () =>
useContext(ExcalidrawActionManagerContext);
/**
* Requires wrapping your component in <ExcalidrawAPIContext.Provider>
*/
export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext);
let didTapTwice: boolean = false;
let tappedTwiceTimer = 0;
@@ -635,12 +654,26 @@ class App extends React.Component<AppProps, AppState> {
* insert to DOM before user initially scrolls to them) */
private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
private handleToastClose = () => {
this.setToast(null);
};
private elementsPendingErasure: ElementsPendingErasure = new Set();
private _initialized = false;
private readonly editorLifecycleEvents = new AppEventBus<
ExcalidrawImperativeAPIEventMap,
typeof editorLifecycleEventBehavior
>(editorLifecycleEventBehavior);
public onEvent = this.editorLifecycleEvents.on.bind(
this.editorLifecycleEvents,
) as AppEventBus<
ExcalidrawImperativeAPIEventMap,
typeof editorLifecycleEventBehavior
>["on"];
private appStateObserver = new AppStateObserver(() => this.state);
public onStateChange: OnStateChange = this.appStateObserver.onStateChange;
public flowChartCreator: FlowChartCreator = new FlowChartCreator();
private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
@@ -696,11 +729,12 @@ class App extends React.Component<AppProps, AppState> {
>();
onRemoveEventListenersEmitter = new Emitter<[]>();
api: ExcalidrawImperativeAPI;
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
const {
excalidrawAPI,
viewModeEnabled = false,
zenModeEnabled = false,
gridModeEnabled = false,
@@ -708,6 +742,7 @@ class App extends React.Component<AppProps, AppState> {
theme = defaultAppState.theme,
name = `${t("labels.untitled")}-${getDateTime()}`,
} = props;
this.state = {
...defaultAppState,
theme,
@@ -744,51 +779,6 @@ class App extends React.Component<AppProps, AppState> {
this.store = new Store(this);
this.history = new History(this.store);
if (excalidrawAPI) {
const api: ExcalidrawImperativeAPI = {
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
} as const;
if (typeof excalidrawAPI === "function") {
excalidrawAPI(api);
} else {
console.error("excalidrawAPI should be a function!");
}
}
this.excalidrawContainerValue = {
container: this.excalidrawContainerRef.current,
id: this.id,
@@ -800,6 +790,48 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAll(actions);
this.actionManager.registerAction(createUndoAction(this.history));
this.actionManager.registerAction(createRedoAction(this.history));
this.api = {
updateScene: this.updateScene,
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
getSceneElementsMapIncludingDeleted:
this.getSceneElementsMapIncludingDeleted,
history: {
clear: this.resetHistory,
},
scrollToContent: this.scrollToContent,
getSceneElements: this.getSceneElements,
getAppState: () => this.state,
getFiles: () => this.files,
getName: this.getName,
registerAction: (action: Action) => {
this.actionManager.registerAction(action);
},
refresh: this.refresh,
setToast: this.setToast,
id: this.id,
setActiveTool: this.setActiveTool,
setCursor: this.setCursor,
resetCursor: this.resetCursor,
getEditorInterface: () => this.editorInterface,
updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar,
onChange: (cb) => this.onChangeEmitter.on(cb),
onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb),
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
onStateChange: this.onStateChange,
onEvent: this.onEvent,
} as const;
props.onExcalidrawAPI?.(this.api);
}
updateEditorAtom = <Value, Args extends unknown[], Result>(
@@ -2042,282 +2074,279 @@ class App extends React.Component<AppProps, AppState> {
onPointerEnter={this.toggleOverscrollBehavior}
onPointerLeave={this.toggleOverscrollBehavior}
>
<AppContext.Provider value={this}>
<AppPropsContext.Provider value={this.props}>
<ExcalidrawContainerContext.Provider
value={this.excalidrawContainerValue}
>
<EditorInterfaceContext.Provider value={this.editorInterface}>
<ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
<ExcalidrawAppStateContext.Provider value={this.state}>
<ExcalidrawElementsContext.Provider
value={this.scene.getNonDeletedElements()}
>
<ExcalidrawActionManagerContext.Provider
value={this.actionManager}
<ExcalidrawAPIContext.Provider value={this.api}>
<AppContext.Provider value={this}>
<AppPropsContext.Provider value={this.props}>
<ExcalidrawContainerContext.Provider
value={this.excalidrawContainerValue}
>
<EditorInterfaceContext.Provider value={this.editorInterface}>
<ExcalidrawSetAppStateContext.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()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" &&
this.state.zenModeEnabled
}
UIOptions={this.props.UIOptions}
onExportImage={this.onExportImage}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type ===
this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
generateLinkForSelection={
this.props.generateLinkForSelection
}
<ExcalidrawActionManagerContext.Provider
value={this.actionManager}
>
{this.props.children}
</LayerUI>
<LayerUI
canvas={this.canvas}
appState={this.state}
files={this.files}
setAppState={this.setAppState}
actionManager={this.actionManager}
elements={this.scene.getNonDeletedElements()}
onLockToggle={this.toggleLock}
onPenModeToggle={this.togglePenMode}
onHandToolToggle={this.onHandToolToggle}
langCode={getLanguage().code}
renderTopLeftUI={renderTopLeftUI}
renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats}
showExitZenModeBtn={
typeof this.props?.zenModeEnabled ===
"undefined" && this.state.zenModeEnabled
}
UIOptions={this.props.UIOptions}
onExportImage={this.onExportImage}
renderWelcomeScreen={
!this.state.isLoading &&
this.state.showWelcomeScreen &&
this.state.activeTool.type ===
this.state.preferredSelectionTool.type &&
!this.state.zenModeEnabled &&
!this.scene.getElementsIncludingDeleted().length
}
app={this}
isCollaborating={this.props.isCollaborating}
generateLinkForSelection={
this.props.generateLinkForSelection
}
>
{this.props.children}
</LayerUI>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[
this.laserTrails,
this.lassoTrail,
this.eraserTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={firstSelectedElement.id}
element={firstSelectedElement}
scene={this.scene}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
updateEmbedValidationStatus={
this.updateEmbedValidationStatus
}
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
<div className="excalidraw-eye-dropper-container" />
<SVGLayer
trails={[
this.laserTrails,
this.lassoTrail,
this.eraserTrail,
]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={firstSelectedElement.id}
element={firstSelectedElement}
scene={this.scene}
setAppState={this.setAppState}
onLinkOpen={this.props.onLinkOpen}
setToast={this.setToast}
updateEmbedValidationStatus={
this.updateEmbedValidationStatus
}
/>
)}
{this.props.aiEnabled !== false &&
selectedElements.length === 1 &&
isMagicFrameElement(firstSelectedElement) && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.convertToCode")}
icon={MagicIcon}
checked={false}
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
</ElementCanvasButtons>
)}
{selectedElements.length === 1 &&
isIframeElement(firstSelectedElement) &&
firstSelectedElement.customData?.generationData
?.status === "done" && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.copySource")}
icon={copyIcon}
checked={false}
onChange={() =>
this.onIframeSrcCopy(firstSelectedElement)
}
/>
<ElementCanvasButton
title="Enter fullscreen"
icon={fullscreenIcon}
checked={false}
onChange={() => {
const iframe =
this.getHTMLIFrameElement(
firstSelectedElement,
);
if (iframe) {
try {
iframe.requestFullscreen();
this.setState({
activeEmbeddable: {
element: firstSelectedElement,
state: "active",
},
selectedElementIds: {
[firstSelectedElement.id]: true,
},
newElement: null,
selectionElement: null,
});
} catch (err: any) {
console.warn(err);
this.setState({
errorMessage:
"Couldn't enter fullscreen",
});
}
}
}}
/>
</ElementCanvasButtons>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/>
)}
{this.props.aiEnabled !== false &&
selectedElements.length === 1 &&
isMagicFrameElement(firstSelectedElement) && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.convertToCode")}
icon={MagicIcon}
checked={false}
onChange={() =>
this.onMagicFrameGenerate(
firstSelectedElement,
"button",
)
}
/>
</ElementCanvasButtons>
)}
{selectedElements.length === 1 &&
isIframeElement(firstSelectedElement) &&
firstSelectedElement.customData?.generationData
?.status === "done" && (
<ElementCanvasButtons
element={firstSelectedElement}
elementsMap={elementsMap}
>
<ElementCanvasButton
title={t("labels.copySource")}
icon={copyIcon}
checked={false}
onChange={() =>
this.onIframeSrcCopy(firstSelectedElement)
}
/>
<ElementCanvasButton
title="Enter fullscreen"
icon={fullscreenIcon}
checked={false}
onChange={() => {
const iframe =
this.getHTMLIFrameElement(
firstSelectedElement,
);
if (iframe) {
try {
iframe.requestFullscreen();
this.setState({
activeEmbeddable: {
element: firstSelectedElement,
state: "active",
},
selectedElementIds: {
[firstSelectedElement.id]: true,
},
newElement: null,
selectionElement: null,
});
} catch (err: any) {
console.warn(err);
this.setState({
errorMessage:
"Couldn't enter fullscreen",
});
}
}
}}
/>
</ElementCanvasButtons>
)}
{this.state.toast !== null && (
<Toast
message={this.state.toast.message}
onClose={this.handleToastClose}
duration={this.state.toast.duration}
closable={this.state.toast.closable}
/>
)}
{this.state.contextMenu && (
<ContextMenu
items={this.state.contextMenu.items}
top={this.state.contextMenu.top}
left={this.state.contextMenu.left}
actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/>
)}
<StaticCanvas
canvas={this.canvas}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: isGridModeEnabled(this),
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus: this.embedsValidationStatus,
elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}}
/>
{this.state.newElement && (
<NewElementCanvas
appState={this.state}
scale={window.devicePixelRatio}
<StaticCanvas
canvas={this.canvas}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
visibleElements={visibleElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: false,
renderGrid: isGridModeEnabled(this),
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus:
this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
pendingFlowchartNodes:
this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}}
/>
)}
<InteractiveCanvas
app={this}
containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderScrollbars={
this.props.renderScrollbars === true
}
editorInterface={this.editorInterface}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/>
{this.state.userToFollow && (
<FollowMode
width={this.state.width}
height={this.state.height}
userToFollow={this.state.userToFollow}
onDisconnect={this.maybeUnfollowRemoteUser}
/>
)}
{this.renderFrameNames()}
{this.state.activeLockedId && (
<UnlockPopup
{this.state.newElement && (
<NewElementCanvas
appState={this.state}
scale={window.devicePixelRatio}
rc={this.rc}
elementsMap={elementsMap}
allElementsMap={allElementsMap}
renderConfig={{
imageCache: this.imageCache,
isExporting: false,
renderGrid: false,
canvasBackgroundColor:
this.state.viewBackgroundColor,
embedsValidationStatus:
this.embedsValidationStatus,
elementsPendingErasure:
this.elementsPendingErasure,
pendingFlowchartNodes: null,
theme: this.state.theme,
}}
/>
)}
<InteractiveCanvas
app={this}
activeLockedId={this.state.activeLockedId}
containerRef={this.excalidrawContainerRef}
canvas={this.interactiveCanvas}
elementsMap={elementsMap}
visibleElements={visibleElements}
allElementsMap={allElementsMap}
selectedElements={selectedElements}
sceneNonce={sceneNonce}
selectionNonce={
this.state.selectionElement?.versionNonce
}
scale={window.devicePixelRatio}
appState={this.state}
renderScrollbars={
this.props.renderScrollbars === true
}
editorInterface={this.editorInterface}
renderInteractiveSceneCallback={
this.renderInteractiveSceneCallback
}
handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu}
onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer}
onTouchMove={this.handleTouchMove}
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/>
)}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
</EditorInterfaceContext.Provider>
</ExcalidrawContainerContext.Provider>
</AppPropsContext.Provider>
</AppContext.Provider>
{this.state.userToFollow && (
<FollowMode
width={this.state.width}
height={this.state.height}
userToFollow={this.state.userToFollow}
onDisconnect={this.maybeUnfollowRemoteUser}
/>
)}
{this.renderFrameNames()}
{this.state.activeLockedId && (
<UnlockPopup
app={this}
activeLockedId={this.state.activeLockedId}
/>
)}
{showShapeSwitchPanel && (
<ConvertElementTypePopup app={this} />
)}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
</ExcalidrawElementsContext.Provider>
</ExcalidrawAppStateContext.Provider>
</ExcalidrawSetAppStateContext.Provider>
</EditorInterfaceContext.Provider>
</ExcalidrawContainerContext.Provider>
</AppPropsContext.Provider>
</AppContext.Provider>
</ExcalidrawAPIContext.Provider>
</div>
);
}
@@ -3015,12 +3044,10 @@ class App extends React.Component<AppProps, AppState> {
this.history.record(increment.delta);
});
const { onIncrement } = this.props;
// per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation
if (onIncrement) {
if (this.props.onIncrement) {
this.store.onStoreIncrementEmitter.on((increment) => {
onIncrement(increment);
this.props.onIncrement?.(increment);
});
}
@@ -3054,6 +3081,14 @@ class App extends React.Component<AppProps, AppState> {
errorMessage: <BraveMeasureTextError />,
});
}
const mountPayload = {
excalidrawAPI: this.api,
container: this.excalidrawContainerRef.current,
};
this.editorLifecycleEvents.emit("editor:mount", mountPayload);
this.props.onMount?.(mountPayload);
}
public componentWillUnmount() {
@@ -3074,6 +3109,8 @@ class App extends React.Component<AppProps, AppState> {
this.onChangeEmitter.clear();
this.store.onStoreIncrementEmitter.clear();
this.store.onDurableIncrementEmitter.clear();
this.appStateObserver.clear();
this.editorLifecycleEvents.clear();
ShapeCache.destroy();
SnapCache.destroy();
clearTimeout(touchTimeout);
@@ -3239,6 +3276,15 @@ class App extends React.Component<AppProps, AppState> {
}
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
// must be updated *before* state change listeners are triggered below
if (!this._initialized && !this.state.isLoading) {
this._initialized = true;
this.editorLifecycleEvents.emit("editor:initialize", this.api);
this.props.onInitialize?.(this.api);
}
this.appStateObserver.flush(prevState);
this.updateEmbeddables();
const elements = this.scene.getElementsIncludingDeleted();
const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -4324,13 +4370,7 @@ class App extends React.Component<AppProps, AppState> {
this.setState(state);
};
setToast = (
toast: {
message: string;
closable?: boolean;
duration?: number;
} | null,
) => {
setToast = (toast: AppState["toast"]) => {
this.setState({ toast });
};
@@ -5155,7 +5195,8 @@ class App extends React.Component<AppProps, AppState> {
// eye dropper
// -----------------------------------------------------------------------
const lowerCased = event.key.toLocaleLowerCase();
const isPickingStroke = lowerCased === KEYS.S && event.shiftKey;
const isPickingStroke =
lowerCased === KEYS.S && event.shiftKey && !event[KEYS.CTRL_OR_CMD];
const isPickingBackground =
event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
@@ -11602,7 +11643,7 @@ class App extends React.Component<AppProps, AppState> {
loadFileToCanvas = async (
file: File,
fileHandle: FileSystemHandle | null,
fileHandle: FileSystemFileHandle | null,
) => {
file = await normalizeFile(file);
try {
@@ -0,0 +1,208 @@
import type { AppState, UnsubscribeCallback } from "../types";
type StateChangeSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
type StateChangePredicateOptions = {
predicate: (appState: AppState) => boolean;
callback?: (appState: AppState) => void;
once?: boolean;
};
type StateChangeArg = StateChangeSelector | StateChangePredicateOptions;
type StateChangeListener = {
predicate: (appState: AppState, prevState: AppState) => boolean;
getValue: (appState: AppState) => unknown;
callback: (value: any, appState: AppState) => void;
once: boolean;
};
type NormalizedStateChange = {
predicate: StateChangeListener["predicate"];
getValue: StateChangeListener["getValue"];
callback?: StateChangeListener["callback"];
once: boolean;
matchesImmediately: boolean;
};
export type OnStateChange = {
<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<K extends keyof AppState>(prop: K): Promise<AppState[K]>;
(
prop: (keyof AppState)[],
callback: (appState: AppState, appState2: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
(prop: (keyof AppState)[]): Promise<AppState>;
<T>(
prop: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
opts?: { once: boolean },
): UnsubscribeCallback;
<T>(prop: (appState: AppState) => T): Promise<T>;
(opts: {
predicate: (appState: AppState) => boolean;
callback: (appState: AppState) => void;
once?: boolean;
}): UnsubscribeCallback;
(opts: { predicate: (appState: AppState) => boolean }): Promise<AppState>;
(
selector: StateChangeSelector,
callback: (value: any, appState: AppState) => void,
): any;
};
export class AppStateObserver {
private listeners: StateChangeListener[] = [];
constructor(private readonly getState: () => AppState) {}
private isStateChangePredicateOptions(
propOrOpts: StateChangeArg,
): propOrOpts is StateChangePredicateOptions {
return (
typeof propOrOpts === "object" &&
!Array.isArray(propOrOpts) &&
"predicate" in propOrOpts
);
}
private subscribe(listener: StateChangeListener): UnsubscribeCallback {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(
(existingListener) => existingListener !== listener,
);
};
}
private normalize(
propOrOpts: StateChangeArg,
callback?: (value: any, appState: AppState) => void,
opts?: { once: boolean },
): NormalizedStateChange {
let predicate: StateChangeListener["predicate"];
let getValue: StateChangeListener["getValue"];
let normalizedCallback = callback;
let once = opts?.once ?? false;
let matchesImmediately = false;
if (this.isStateChangePredicateOptions(propOrOpts)) {
const {
predicate: predicateFn,
callback: callbackFromOpts,
once: onceFromOpts,
} = propOrOpts;
predicate = predicateFn;
getValue = (appState: AppState) => appState;
normalizedCallback = callbackFromOpts
? (_value: AppState, appState: AppState) => callbackFromOpts(appState)
: undefined;
once = onceFromOpts ?? false;
matchesImmediately = predicateFn(this.getState());
} else if (typeof propOrOpts === "function") {
const selector = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
selector(appState) !== selector(prevState);
getValue = (appState: AppState) => selector(appState);
} else if (Array.isArray(propOrOpts)) {
const keys = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
keys.some((key) => appState[key] !== prevState[key]);
getValue = (appState: AppState) => appState;
} else {
const key = propOrOpts;
predicate = (appState: AppState, prevState: AppState) =>
appState[key] !== prevState[key];
getValue = (appState: AppState) => appState[key];
}
return {
predicate,
getValue,
callback: normalizedCallback,
once,
matchesImmediately,
};
}
public onStateChange: OnStateChange = ((
propOrOpts: StateChangeArg,
callback?: any,
opts?: { once: boolean },
) => {
const {
predicate,
getValue,
callback: stateChangeCallback,
once,
matchesImmediately,
} = this.normalize(propOrOpts, callback, opts);
if (stateChangeCallback) {
if (matchesImmediately) {
queueMicrotask(() => {
const state = this.getState();
stateChangeCallback(getValue(state), state);
});
if (once) {
return () => {};
}
}
return this.subscribe({
predicate,
getValue,
callback: stateChangeCallback,
once,
});
}
if (matchesImmediately) {
return Promise.resolve(getValue(this.getState()));
}
return new Promise<any>((resolve) => {
this.subscribe({
predicate,
getValue,
callback: (value) => resolve(value),
once: true,
});
});
}) as OnStateChange;
public flush(prevState: AppState) {
if (!this.listeners.length) {
return;
}
const state = this.getState();
const listenersToKeep: StateChangeListener[] = [];
for (const listener of this.listeners) {
if (listener.predicate(state, prevState)) {
listener.callback(listener.getValue(state), state);
if (!listener.once) {
listenersToKeep.push(listener);
}
} else {
listenersToKeep.push(listener);
}
}
this.listeners = listenersToKeep;
}
public clear() {
this.listeners = [];
}
}
@@ -10,12 +10,11 @@ import {
isWritableElement,
} from "@excalidraw/common";
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import type { MarkRequired } from "@excalidraw/common/utility-types";
import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch";
import { getShortcutKey } from "../../shortcut";
import {
actionClearCanvas,
actionLink,
@@ -1,7 +1,9 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import {
bumpVersion,
getLinearElementSubType,
mutateElement,
updateElbowArrowPoints,
} from "@excalidraw/element";
@@ -37,6 +39,8 @@ import {
isProdEnv,
mapFind,
reduceToCommonValue,
ROUNDNESS,
sceneCoordsToViewportCoords,
updateActiveTool,
} from "@excalidraw/common";
@@ -71,12 +75,6 @@ import type {
import type { Scene } from "@excalidraw/element";
import {
bumpVersion,
mutateElement,
ROUNDNESS,
sceneCoordsToViewportCoords,
} from "..";
import { trackEvent } from "../analytics";
import { atom } from "../editor-jotai";
+25 -12
View File
@@ -60,6 +60,7 @@ import { ImageExportDialog } from "./ImageExportDialog";
import { Island } from "./Island";
import { JSONExportDialog } from "./JSONExportDialog";
import { LaserPointerButton } from "./LaserPointerButton";
import { Toast } from "./Toast";
import "./LayerUI.scss";
import "./Toolbar.scss";
@@ -605,18 +606,30 @@ const LayerUI = ({
showExitZenModeBtn={showExitZenModeBtn}
renderWelcomeScreen={renderWelcomeScreen}
/>
{appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
{(appState.toast || appState.scrolledOutside) && (
<div className="floating-status-stack">
{appState.toast && (
<Toast
message={appState.toast.message}
onClose={() => setAppState({ toast: null })}
duration={appState.toast.duration}
closable={appState.toast.closable}
/>
)}
{!appState.toast && appState.scrolledOutside && (
<button
type="button"
className="scroll-back-to-content"
onClick={() => {
setAppState((appState) => ({
...calculateScrollCenter(elements, appState),
}));
}}
>
{t("buttons.scrollBackToContent")}
</button>
)}
</div>
)}
</div>
{renderSidebars()}
@@ -11,7 +11,7 @@ import { rateLimitsAtom } from "../TTDContext";
import { ChatHistoryMenu } from "./ChatHistoryMenu";
import { ChatInterface } from ".";
import { ChatInterface } from "./ChatInterface";
import type { TTDPanelAction } from "../TTDDialogPanel";
@@ -1,4 +1,4 @@
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
import { getShortcutKey } from "../../shortcut";
export const TTDDialogSubmitShortcut = () => {
return (
@@ -1,4 +1,12 @@
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "@excalidraw/common";
import {
DEFAULT_EXPORT_PADDING,
EDITOR_LS_KEYS,
THEME,
} from "@excalidraw/common";
import { convertToExcalidrawElements } from "@excalidraw/element";
import { exportToCanvas } from "@excalidraw/utils";
import type {
NonDeletedExcalidrawElement,
@@ -6,11 +14,6 @@ import type {
} from "@excalidraw/element/types";
import { EditorLocalStorage } from "../../data/EditorLocalStorage";
import {
convertToExcalidrawElements,
exportToCanvas,
THEME,
} from "../../index";
import type { MermaidToExcalidrawLibProps } from "./types";
@@ -1,9 +1,6 @@
import { RequestError } from "@excalidraw/excalidraw/errors";
import { RequestError } from "../../../errors";
import type {
LLMMessage,
TTTDDialog,
} from "@excalidraw/excalidraw/components/TTDDialog/types";
import type { LLMMessage, TTTDDialog } from "../types";
interface RateLimitInfo {
rateLimit?: number;
+31 -15
View File
@@ -1,35 +1,51 @@
@use "../css/variables.module" as *;
.excalidraw {
.Toast {
$closeButtonSize: 1.2rem;
$closeButtonPadding: 0.4rem;
animation: fade-in 0.5s;
background-color: var(--button-gray-1);
border-radius: 4px;
bottom: 10px;
animation: Toast-fade-in 0.5s;
min-width: 220px;
max-width: min(360px, calc(100vw - 32px));
border-radius: var(--border-radius-lg);
border: 1px solid var(--default-border-color);
background-color: var(--island-bg-color);
color: var(--text-primary-color);
padding: 0.5rem 0.75rem;
box-shadow: 0 0 0 1px var(--color-surface-lowest);
box-sizing: border-box;
cursor: default;
left: 50%;
margin-left: -150px;
padding: 4px 0;
position: absolute;
text-align: center;
width: 300px;
z-index: 999999;
pointer-events: none;
.Toast__message {
font-family: var(--ui-font);
font-size: 0.75rem;
line-height: 1.25rem;
text-align: center;
padding: 0 $closeButtonSize + ($closeButtonPadding);
color: var(--popup-text-color);
white-space: pre-wrap;
}
.Toast__progress-bar {
margin-top: 0.35rem;
width: 100%;
height: 4px;
border-radius: 999px;
background-color: var(--button-gray-2);
overflow: hidden;
}
.Toast__progress-bar-fill {
height: 100%;
border-radius: inherit;
background-color: var(--color-primary);
}
.close {
position: absolute;
top: 0;
right: 0;
padding: $closeButtonPadding;
pointer-events: auto;
.ToolIcon__icon {
width: $closeButtonSize;
@@ -38,7 +54,7 @@
}
}
@keyframes fade-in {
@keyframes Toast-fade-in {
from {
opacity: 0;
}
+18 -4
View File
@@ -5,11 +5,22 @@ import { ToolButton } from "./ToolButton";
import "./Toast.scss";
import type { CSSProperties } from "react";
import type { CSSProperties, ReactNode } from "react";
const DEFAULT_TOAST_TIMEOUT = 5000;
export const Toast = ({
const ProgressBar = ({ progress }: { progress: number }) => (
<div className="Toast__progress-bar">
<div
className="Toast__progress-bar-fill"
style={{
width: `${Math.min(5, Math.round(progress * 100))}%`,
}}
/>
</div>
);
const ToastComponent = ({
message,
onClose,
closable = false,
@@ -17,7 +28,7 @@ export const Toast = ({
duration = DEFAULT_TOAST_TIMEOUT,
style,
}: {
message: string;
message: ReactNode;
onClose: () => void;
closable?: boolean;
duration?: number;
@@ -47,11 +58,12 @@ export const Toast = ({
return (
<div
className="Toast"
role="status"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={style}
>
<p className="Toast__message">{message}</p>
<div className="Toast__message">{message}</div>
{closable && (
<ToolButton
icon={CloseIcon}
@@ -64,3 +76,5 @@ export const Toast = ({
</div>
);
};
export const Toast = Object.assign(ToastComponent, { ProgressBar });
@@ -6,7 +6,6 @@ import {
sceneCoordsToViewportCoords,
type EditorInterface,
} from "@excalidraw/common";
import { AnimationController } from "@excalidraw/excalidraw/renderer/animation";
import type {
InteractiveCanvasRenderConfig,
@@ -24,6 +23,8 @@ import type {
import { t } from "../../i18n";
import { renderInteractiveScene } from "../../renderer/interactiveScene";
import { AnimationController } from "../../renderer/animation";
import type {
AppClassProperties,
AppState,