aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/hooks')
-rw-r--r--packages/excalidraw/hooks/useCallbackRefState.ts7
-rw-r--r--packages/excalidraw/hooks/useCopiedIndicator.ts27
-rw-r--r--packages/excalidraw/hooks/useCreatePortalContainer.ts47
-rw-r--r--packages/excalidraw/hooks/useEmitter.ts21
-rw-r--r--packages/excalidraw/hooks/useLibraryItemSvg.ts79
-rw-r--r--packages/excalidraw/hooks/useOutsideClick.ts86
-rw-r--r--packages/excalidraw/hooks/useScrollPosition.ts32
-rw-r--r--packages/excalidraw/hooks/useStable.ts7
-rw-r--r--packages/excalidraw/hooks/useStableCallback.ts18
-rw-r--r--packages/excalidraw/hooks/useTransition.ts9
10 files changed, 333 insertions, 0 deletions
diff --git a/packages/excalidraw/hooks/useCallbackRefState.ts b/packages/excalidraw/hooks/useCallbackRefState.ts
new file mode 100644
index 0000000..4a8552b
--- /dev/null
+++ b/packages/excalidraw/hooks/useCallbackRefState.ts
@@ -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;
+};
diff --git a/packages/excalidraw/hooks/useCopiedIndicator.ts b/packages/excalidraw/hooks/useCopiedIndicator.ts
new file mode 100644
index 0000000..18ad793
--- /dev/null
+++ b/packages/excalidraw/hooks/useCopiedIndicator.ts
@@ -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,
+ };
+};
diff --git a/packages/excalidraw/hooks/useCreatePortalContainer.ts b/packages/excalidraw/hooks/useCreatePortalContainer.ts
new file mode 100644
index 0000000..e8f5e3d
--- /dev/null
+++ b/packages/excalidraw/hooks/useCreatePortalContainer.ts
@@ -0,0 +1,47 @@
+import { useState, useLayoutEffect } from "react";
+import { useDevice, useExcalidrawContainer } from "../components/App";
+import { THEME } from "../constants";
+import { useUIAppState } from "../context/ui-appState";
+
+export const useCreatePortalContainer = (opts?: {
+ className?: string;
+ parentSelector?: string;
+}) => {
+ const [div, setDiv] = useState<HTMLDivElement | null>(null);
+
+ const device = useDevice();
+ 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", device.editor.isMobile);
+ div.classList.toggle("theme--dark", theme === THEME.DARK);
+ }
+ }, [div, theme, device.editor.isMobile, opts?.className]);
+
+ useLayoutEffect(() => {
+ const container = opts?.parentSelector
+ ? excalidrawContainer?.querySelector(opts.parentSelector)
+ : document.body;
+
+ if (!container) {
+ return;
+ }
+
+ const div = document.createElement("div");
+
+ container.appendChild(div);
+
+ setDiv(div);
+
+ return () => {
+ container.removeChild(div);
+ };
+ }, [excalidrawContainer, opts?.parentSelector]);
+
+ return div;
+};
diff --git a/packages/excalidraw/hooks/useEmitter.ts b/packages/excalidraw/hooks/useEmitter.ts
new file mode 100644
index 0000000..27b94bc
--- /dev/null
+++ b/packages/excalidraw/hooks/useEmitter.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+import type { Emitter } from "../emitter";
+
+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;
+};
diff --git a/packages/excalidraw/hooks/useLibraryItemSvg.ts b/packages/excalidraw/hooks/useLibraryItemSvg.ts
new file mode 100644
index 0000000..72b648d
--- /dev/null
+++ b/packages/excalidraw/hooks/useLibraryItemSvg.ts
@@ -0,0 +1,79 @@
+import { useEffect, useState } from "react";
+import { COLOR_PALETTE } from "../colors";
+import { atom, useAtom } from "../editor-jotai";
+import { exportToSvg } from "@excalidraw/utils/export";
+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"]) => {
+ 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,
+): 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]);
+
+ 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,
+ };
+};
diff --git a/packages/excalidraw/hooks/useOutsideClick.ts b/packages/excalidraw/hooks/useOutsideClick.ts
new file mode 100644
index 0000000..da9a54d
--- /dev/null
+++ b/packages/excalidraw/hooks/useOutsideClick.ts
@@ -0,0 +1,86 @@
+import { useEffect } from "react";
+import { EVENT } from "../constants";
+
+export function useOutsideClick<T extends HTMLElement>(
+ ref: React.RefObject<T | null>,
+ /** if performance is of concern, memoize the callback */
+ callback: (event: Event) => 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: HTMLElement },
+ /** the element of the passed ref */
+ container: T,
+ ) => boolean | undefined,
+) {
+ useEffect(() => {
+ 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)
+ !document.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 === document.documentElement &&
+ document.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`
+ document.addEventListener(EVENT.POINTER_DOWN, onOutsideClick);
+ document.addEventListener(EVENT.TOUCH_START, onOutsideClick);
+
+ return () => {
+ document.removeEventListener(EVENT.POINTER_DOWN, onOutsideClick);
+ document.removeEventListener(EVENT.TOUCH_START, onOutsideClick);
+ };
+ }, [ref, callback, isInside]);
+}
diff --git a/packages/excalidraw/hooks/useScrollPosition.ts b/packages/excalidraw/hooks/useScrollPosition.ts
new file mode 100644
index 0000000..0be2eab
--- /dev/null
+++ b/packages/excalidraw/hooks/useScrollPosition.ts
@@ -0,0 +1,32 @@
+import { useEffect } from "react";
+import { atom, useAtom } from "../editor-jotai";
+import throttle from "lodash.throttle";
+
+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;
+};
diff --git a/packages/excalidraw/hooks/useStable.ts b/packages/excalidraw/hooks/useStable.ts
new file mode 100644
index 0000000..5660848
--- /dev/null
+++ b/packages/excalidraw/hooks/useStable.ts
@@ -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;
+};
diff --git a/packages/excalidraw/hooks/useStableCallback.ts b/packages/excalidraw/hooks/useStableCallback.ts
new file mode 100644
index 0000000..9920a73
--- /dev/null
+++ b/packages/excalidraw/hooks/useStableCallback.ts
@@ -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;
+};
diff --git a/packages/excalidraw/hooks/useTransition.ts b/packages/excalidraw/hooks/useTransition.ts
new file mode 100644
index 0000000..bb107ed
--- /dev/null
+++ b/packages/excalidraw/hooks/useTransition.ts
@@ -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;