snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from "react";
import { useExcalidrawAPI } from "../components/App";
import { getDefaultAppState } from "../appState";
import type { AppState } from "../types";
type AppStateSelector =
| keyof AppState
| (keyof AppState)[]
| ((appState: AppState) => unknown);
const getSelectedValue = (appState: AppState, selector: AppStateSelector) => {
if (typeof selector === "function") {
return selector(appState);
}
if (Array.isArray(selector)) {
return appState;
}
return appState[selector];
};
const getLatestValue = (
api: ReturnType<typeof useExcalidrawAPI>,
selector: AppStateSelector,
_internal: boolean,
) => {
if (api?.isDestroyed) {
return;
}
let appState = api?.getAppState();
if (!appState) {
if (!_internal) {
return undefined;
}
console.warn(
"useAppStateValue: excalidrawAPI not defined yet for internal component while it should always be defined. Are you sure you're rendering inside of <Excalidraw/> component tree?",
);
// fall back in case there's a bug so we don't break the app
// (internal components using this internal useAppStateValue expect
// non-undefined values on init)
appState = Object.assign(
{ width: 0, height: 0, offsetLeft: 0, offsetTop: 0 },
getDefaultAppState(),
);
}
return getSelectedValue(appState, selector);
};
/**
* Subscribes to specific appState changes. The component re-renders
* only when the specified prop(s) change — not on every appState update.
*
* Works both inside and outside the <Excalidraw> tree, as long as
* ExcalidrawAPIContext.Provider is an ancestor (automatically provided
* inside <Excalidraw>, or manually by the host app).
*
* Returns the narrowed value depending on prop form:
* - `keyof AppState` → `AppState[K]`
* - `(keyof AppState)[]` → whole `AppState`
* - selector function → selector's return type `T`
*
* If excalidrawAPI is not ready yet (host apps), hook is rerendered with latest
* value once available.
*/
export function useAppStateValue<K extends keyof AppState>(
prop: K,
_internal?: boolean,
): AppState[K];
export function useAppStateValue(
props: (keyof AppState)[],
_internal?: boolean,
): AppState;
export function useAppStateValue<T>(
selector: (appState: AppState) => T,
_internal?: boolean,
): T;
export function useAppStateValue(
selector: AppStateSelector,
_internal: boolean = true,
): unknown {
const api = useExcalidrawAPI();
const [, rerender] = useState(0);
const stateRef = useRef<{
selector: AppStateSelector;
isInitialized: boolean;
latestValue: unknown;
} | null>(null);
if (!stateRef.current) {
stateRef.current = {
selector,
isInitialized: !!api,
latestValue: getLatestValue(api, selector, _internal),
};
}
stateRef.current.selector = selector;
if (!stateRef.current.isInitialized && api && !api.isDestroyed) {
stateRef.current.isInitialized = true;
stateRef.current.latestValue = getLatestValue(api, selector, _internal);
}
useEffect(() => {
const currentStateRef = stateRef.current;
if (!api || api.isDestroyed || !currentStateRef) {
return;
}
return api.onStateChange(currentStateRef.selector, (newValue: any) => {
currentStateRef.latestValue = newValue;
rerender((value) => value + 1);
});
}, [api]);
return stateRef.current.latestValue;
}
/**
* Subscribes to specific appState changes without causing component rerenders.
*
* The callback is called on every matching change, but also on initial render
* so you can initialize your state.
*/
export function useOnAppStateChange<K extends keyof AppState>(
prop: K,
callback: (value: AppState[K], appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
props: (keyof AppState)[],
callback: (props: AppState, appState: AppState) => void,
): undefined;
export function useOnAppStateChange<T>(
selector: (appState: AppState) => T,
callback: (value: T, appState: AppState) => void,
): undefined;
export function useOnAppStateChange(
selector: AppStateSelector,
callback: (value: any, appState: AppState) => void,
): undefined {
const api = useExcalidrawAPI();
const stateRef = useRef({
selector,
callback,
});
stateRef.current.selector = selector;
stateRef.current.callback = callback;
useEffect(() => {
if (!api || api.isDestroyed) {
return;
}
stateRef.current.callback(
getLatestValue(api, stateRef.current.selector, true),
api.getAppState(),
);
return api.onStateChange(
stateRef.current.selector,
(newValue: any, state: AppState) => {
stateRef.current.callback(newValue, state);
},
);
}, [api]);
return undefined;
}

View File

@@ -0,0 +1,7 @@
import { useCallback, useState } from "react";
export const useCallbackRefState = <T>() => {
const [refValue, setRefValue] = useState<T | null>(null);
const refCallback = useCallback((value: T | null) => setRefValue(value), []);
return [refValue, refCallback] as const;
};

View File

@@ -0,0 +1,27 @@
import { useCallback, useRef, useState } from "react";
const TIMEOUT = 2000;
export const useCopyStatus = () => {
const [copyStatus, setCopyStatus] = useState<"success" | null>(null);
const timeoutRef = useRef<number>(0);
const onCopy = () => {
clearTimeout(timeoutRef.current);
setCopyStatus("success");
timeoutRef.current = window.setTimeout(() => {
setCopyStatus(null);
}, TIMEOUT);
};
const resetCopyStatus = useCallback(() => {
setCopyStatus(null);
}, []);
return {
copyStatus,
resetCopyStatus,
onCopy,
};
};

View File

@@ -0,0 +1,53 @@
import { useState, useLayoutEffect } from "react";
import { THEME } from "@excalidraw/common";
import { useEditorInterface, useExcalidrawContainer } from "../components/App";
import { useUIAppState } from "../context/ui-appState";
export const useCreatePortalContainer = (opts?: {
className?: string;
parentSelector?: string;
}) => {
const [div, setDiv] = useState<HTMLDivElement | null>(null);
const editorInterface = useEditorInterface();
const { theme } = useUIAppState();
const { container: excalidrawContainer } = useExcalidrawContainer();
useLayoutEffect(() => {
if (div) {
div.className = "";
div.classList.add("excalidraw", ...(opts?.className?.split(/\s+/) || []));
div.classList.toggle(
"excalidraw--mobile",
editorInterface.formFactor === "phone",
);
div.classList.toggle("theme--dark", theme === THEME.DARK);
}
}, [div, theme, editorInterface.formFactor, opts?.className]);
useLayoutEffect(() => {
const ownerDocument = excalidrawContainer?.ownerDocument;
const container = opts?.parentSelector
? excalidrawContainer?.querySelector(opts.parentSelector)
: ownerDocument?.body;
if (!container || !ownerDocument) {
return;
}
const div = ownerDocument.createElement("div");
container.appendChild(div);
setDiv(div);
return () => {
container.removeChild(div);
};
}, [excalidrawContainer, opts?.parentSelector]);
return div;
};

View File

@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
import type { Emitter } from "@excalidraw/common";
export const useEmitter = <TEvent extends unknown>(
emitter: Emitter<[TEvent]>,
initialState: TEvent,
) => {
const [event, setEvent] = useState<TEvent>(initialState);
useEffect(() => {
const unsubscribe = emitter.on((event) => {
setEvent(event);
});
return () => {
unsubscribe();
};
}, [emitter]);
return event;
};

View File

@@ -0,0 +1,101 @@
import { exportToSvg } from "@excalidraw/utils/export";
import { useEffect, useState } from "react";
import { COLOR_PALETTE } from "@excalidraw/common";
import { atom, useAtom } from "../editor-jotai";
import type { LibraryItem } from "../types";
export type SvgCache = Map<LibraryItem["id"], SVGSVGElement>;
export const libraryItemSvgsCache = atom<SvgCache>(new Map());
const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
// TODO should pass theme (appState.exportWithDark) - we're still using
// CSS filter here
return await exportToSvg({
elements,
appState: {
exportBackground: false,
viewBackgroundColor: COLOR_PALETTE.white,
},
files: null,
renderEmbeddables: false,
skipInliningFonts: true,
});
};
export const useLibraryItemSvg = (
id: LibraryItem["id"] | null,
elements: LibraryItem["elements"] | undefined,
svgCache: SvgCache,
ref: React.RefObject<HTMLDivElement | null>,
): SVGSVGElement | undefined => {
const [svg, setSvg] = useState<SVGSVGElement>();
useEffect(() => {
if (elements) {
if (id) {
// Try to load cached svg
const cachedSvg = svgCache.get(id);
if (cachedSvg) {
setSvg(cachedSvg);
} else {
// When there is no svg in cache export it and save to cache
(async () => {
const exportedSvg = await exportLibraryItemToSvg(elements);
// TODO: should likely be removed for custom fonts
exportedSvg.querySelector(".style-fonts")?.remove();
if (exportedSvg) {
svgCache.set(id, exportedSvg);
setSvg(exportedSvg);
}
})();
}
} else {
// When we have no id (usualy selected items from canvas) just export the svg
(async () => {
const exportedSvg = await exportLibraryItemToSvg(elements);
setSvg(exportedSvg);
})();
}
}
}, [id, elements, svgCache, setSvg]);
useEffect(() => {
const node = ref.current;
if (!node) {
return;
}
if (svg) {
node.innerHTML = svg.outerHTML;
}
return () => {
node.innerHTML = "";
};
}, [svg, ref]);
return svg;
};
export const useLibraryCache = () => {
const [svgCache] = useAtom(libraryItemSvgsCache);
const clearLibraryCache = () => svgCache.clear();
const deleteItemsFromLibraryCache = (items: LibraryItem["id"][]) => {
items.forEach((item) => svgCache.delete(item));
};
return {
clearLibraryCache,
deleteItemsFromLibraryCache,
svgCache,
};
};

View File

@@ -0,0 +1,93 @@
import { useEffect } from "react";
import { EVENT } from "@excalidraw/common";
export function useOutsideClick<T extends HTMLElement>(
ref: React.RefObject<T | null>,
/** if performance is of concern, memoize the callback */
callback: (event: Event & { target: T }) => void,
/**
* Optional callback which is called on every click.
*
* Should return `true` if click should be considered as inside the container,
* and `false` if it falls outside and should call the `callback`.
*
* Returning `true` overrides the default behavior and `callback` won't be
* called.
*
* Returning `undefined` will fallback to the default behavior.
*/
isInside?: (
event: Event & { target: T },
/** the element of the passed ref */
container: T,
) => boolean | undefined,
) {
useEffect(() => {
const refOwnerDocument = ref.current?.ownerDocument;
if (!refOwnerDocument) {
return;
}
const ownerDocument: Document = refOwnerDocument;
function onOutsideClick(event: Event) {
const _event = event as Event & { target: T };
if (!ref.current) {
return;
}
const isInsideOverride = isInside?.(_event, ref.current);
if (isInsideOverride === true) {
return;
} else if (isInsideOverride === false) {
return callback(_event);
}
// clicked element is in the descenendant of the target container
if (
ref.current.contains(_event.target) ||
// target is detached from DOM (happens when the element is removed
// on a pointerup event fired *before* this handler's pointerup is
// dispatched)
!ownerDocument.documentElement.contains(_event.target)
) {
return;
}
const isClickOnRadixPortal =
_event.target.closest("[data-radix-portal]") ||
// when radix popup is in "modal" mode, it disables pointer events on
// the `body` element, so the target element is going to be the `html`
// (note: this won't work if we selectively re-enable pointer events on
// specific elements as we do with navbar or excalidraw UI elements)
(_event.target === ownerDocument.documentElement &&
ownerDocument.body.style.pointerEvents === "none");
// if clicking on radix portal, assume it's a popup that
// should be considered as part of the UI. Obviously this is a terrible
// hack you can end up click on radix popups that outside the tree,
// but it works for most cases and the downside is minimal for now
if (isClickOnRadixPortal) {
return;
}
// clicking on a container that ignores outside clicks
if (_event.target.closest("[data-prevent-outside-click]")) {
return;
}
callback(_event);
}
// note: don't use `click` because it often reports incorrect `event.target`
ownerDocument.addEventListener(EVENT.POINTER_DOWN, onOutsideClick);
ownerDocument.addEventListener(EVENT.TOUCH_START, onOutsideClick);
return () => {
ownerDocument.removeEventListener(EVENT.POINTER_DOWN, onOutsideClick);
ownerDocument.removeEventListener(EVENT.TOUCH_START, onOutsideClick);
};
}, [ref, callback, isInside]);
}

View File

@@ -0,0 +1,33 @@
import throttle from "lodash.throttle";
import { useEffect } from "react";
import { atom, useAtom } from "../editor-jotai";
const scrollPositionAtom = atom<number>(0);
export const useScrollPosition = <T extends HTMLElement>(
elementRef: React.RefObject<T | null>,
) => {
const [scrollPosition, setScrollPosition] = useAtom(scrollPositionAtom);
useEffect(() => {
const { current: element } = elementRef;
if (!element) {
return;
}
const handleScroll = throttle(() => {
const { scrollTop } = element;
setScrollPosition(scrollTop);
}, 200);
element.addEventListener("scroll", handleScroll);
return () => {
handleScroll.cancel();
element.removeEventListener("scroll", handleScroll);
};
}, [elementRef, setScrollPosition]);
return scrollPosition;
};

View File

@@ -0,0 +1,7 @@
import { useRef } from "react";
export const useStable = <T extends Record<string, any>>(value: T) => {
const ref = useRef<T>(value);
Object.assign(ref.current, value);
return ref.current;
};

View File

@@ -0,0 +1,18 @@
import { useRef } from "react";
/**
* Returns a stable function of the same type.
*/
export const useStableCallback = <T extends (...args: any[]) => any>(
userFn: T,
) => {
const stableRef = useRef<{ userFn: T; stableFn?: T }>({ userFn });
stableRef.current.userFn = userFn;
if (!stableRef.current.stableFn) {
stableRef.current.stableFn = ((...args: any[]) =>
stableRef.current.userFn(...args)) as T;
}
return stableRef.current.stableFn as T;
};

View File

@@ -0,0 +1,124 @@
import { useState, useCallback } from "react";
// Utility type for caret position
export type CaretPosition = {
start: number;
end: number;
};
// Utility function to get text editor element
const getTextEditor = (ownerDocument: Document): HTMLTextAreaElement | null => {
return ownerDocument.querySelector(
".excalidraw-wysiwyg",
) as HTMLTextAreaElement;
};
// Utility functions for caret position management
export const saveCaretPosition = (
ownerDocument: Document = document,
): CaretPosition | null => {
const textEditor = getTextEditor(ownerDocument);
if (textEditor) {
return {
start: textEditor.selectionStart,
end: textEditor.selectionEnd,
};
}
return null;
};
export const restoreCaretPosition = (
position: CaretPosition | null,
ownerDocument: Document = document,
): void => {
const ownerWindow = ownerDocument.defaultView ?? window;
ownerWindow.setTimeout(() => {
const textEditor = getTextEditor(ownerDocument);
if (textEditor) {
textEditor.focus();
if (position) {
textEditor.selectionStart = position.start;
textEditor.selectionEnd = position.end;
}
}
}, 0);
};
export const withCaretPositionPreservation = (
callback: () => void,
isCompactMode: boolean,
isEditingText: boolean,
onPreventClose?: () => void,
ownerDocument: Document = document,
): void => {
// Prevent popover from closing in compact mode
if (isCompactMode && onPreventClose) {
onPreventClose();
}
// Save caret position if editing text
const savedPosition =
isCompactMode && isEditingText ? saveCaretPosition(ownerDocument) : null;
// Execute the callback
callback();
// Restore caret position if needed
if (isCompactMode && isEditingText) {
restoreCaretPosition(savedPosition, ownerDocument);
}
};
// Hook for managing text editor caret position with state
export const useTextEditorFocus = (ownerDocument: Document = document) => {
const [savedCaretPosition, setSavedCaretPosition] =
useState<CaretPosition | null>(null);
const saveCaretPositionToState = useCallback(() => {
const position = saveCaretPosition(ownerDocument);
setSavedCaretPosition(position);
}, [ownerDocument]);
const restoreCaretPositionFromState = useCallback(() => {
const ownerWindow = ownerDocument.defaultView ?? window;
ownerWindow.setTimeout(() => {
const textEditor = getTextEditor(ownerDocument);
if (textEditor) {
textEditor.focus();
if (savedCaretPosition) {
textEditor.selectionStart = savedCaretPosition.start;
textEditor.selectionEnd = savedCaretPosition.end;
setSavedCaretPosition(null);
}
}
}, 0);
}, [ownerDocument, savedCaretPosition]);
const clearSavedPosition = useCallback(() => {
setSavedCaretPosition(null);
}, []);
return {
saveCaretPosition: saveCaretPositionToState,
restoreCaretPosition: restoreCaretPositionFromState,
clearSavedPosition,
hasSavedPosition: !!savedCaretPosition,
};
};
// Utility function to temporarily disable text editor blur
export const temporarilyDisableTextEditorBlur = (
ownerDocument: Document = document,
duration: number = 100,
): void => {
const textEditor = getTextEditor(ownerDocument);
if (textEditor) {
const originalOnBlur = textEditor.onblur;
textEditor.onblur = null;
const ownerWindow = ownerDocument.defaultView ?? window;
ownerWindow.setTimeout(() => {
textEditor.onblur = originalOnBlur;
}, duration);
}
};

View File

@@ -0,0 +1,9 @@
import React, { useCallback } from "react";
/** noop polyfill for v17. Subset of API available */
function useTransitionPolyfill() {
const startTransition = useCallback((callback: () => void) => callback(), []);
return [false, startTransition] as const;
}
export const useTransition = React.useTransition || useTransitionPolyfill;