diff options
Diffstat (limited to 'packages/excalidraw/hooks')
| -rw-r--r-- | packages/excalidraw/hooks/useCallbackRefState.ts | 7 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useCopiedIndicator.ts | 27 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useCreatePortalContainer.ts | 47 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useEmitter.ts | 21 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useLibraryItemSvg.ts | 79 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useOutsideClick.ts | 86 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useScrollPosition.ts | 32 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useStable.ts | 7 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useStableCallback.ts | 18 | ||||
| -rw-r--r-- | packages/excalidraw/hooks/useTransition.ts | 9 |
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; |
