import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { type ComponentType, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { getRouteActionPolicy } from "@/components/AppSidebar/routes"; import { loadMemoEditor } from "@/components/MemoEditor/loader"; import type { MemoEditorProps } from "@/components/MemoEditor/types"; import { VisuallyHidden } from "@/components/ui/visually-hidden"; import { useAppSidebar } from "@/contexts/AppSidebarContext"; import { useAuth } from "@/contexts/AuthContext"; import { useSpaceContext } from "@/contexts/SpaceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { spaceScopedCacheKey } from "@/lib/resource-names"; import { useTranslate } from "@/utils/i18n"; interface GlobalMemoEditorContextValue { /** Whether the signed-in user is ready to compose; gates every visible entry point. */ canOpen: boolean; openEditor: () => void; } const GlobalMemoEditorContext = createContext(null); const isVisibleFocusTarget = (element: HTMLElement | null): element is HTMLElement => { if (!element?.isConnected || element.tabIndex < 0 || element.matches(":disabled, [aria-disabled='true']")) return false; // Walk ancestors: the mobile trigger is hidden by a `md:hidden` parent, which // never shows up in the element's own computed style. for (let current: HTMLElement | null = element; current; current = current.parentElement) { const style = window.getComputedStyle(current); if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") return false; } return true; }; const findVisibleFocusTarget = (selector: string): HTMLElement | null => Array.from(document.querySelectorAll(selector)).find(isVisibleFocusTarget) ?? null; export function GlobalMemoEditorProvider({ children }: { children: ReactNode }) { const t = useTranslate(); const location = useLocation(); const currentUserName = useCurrentUser()?.name; const { selectedSpaceName } = useSpaceContext(); const { isUserSettingsInitialized } = useAuth(); const { setMobileOpen, setQuickFindOpen } = useAppSidebar(); const routePolicy = getRouteActionPolicy(location.pathname); const composeSpace = routePolicy.composePlacement === "remembered-space" ? selectedSpaceName : undefined; // One snapshot taken when the composer opens: keyed by the user who opened it, so // signing out closes the composer in the same render and a different user signing // in cannot resurrect it, and pinned to the Space that was selected at that moment. const [opened, setOpened] = useState<{ user: string; space?: string }>(); const [EditorComponent, setEditorComponent] = useState>(); // Only ever read from event handlers, so a ref keeps each save from // re-rendering the provider and the whole editor tree it hosts. const isSavingRef = useRef(false); const returnFocusRef = useRef(null); const openRequestVersionRef = useRef(0); const closeEditor = useCallback(() => { openRequestVersionRef.current += 1; isSavingRef.current = false; setOpened(undefined); }, []); const requestCloseEditor = useCallback(() => { if (!isSavingRef.current) closeEditor(); }, [closeEditor]); const reportSaving = useCallback((saving: boolean) => { isSavingRef.current = saving; }, []); // The editor reads defaults out of user settings, so composing has to wait for // them. Keep the rule here so every entry point uses the same gate. const canOpen = Boolean(currentUserName) && isUserSettingsInitialized; const openEditor = useCallback(() => { if (!canOpen || !currentUserName) return; // Owned here so no caller can leave a sidebar surface open underneath. setMobileOpen(false); setQuickFindOpen(false); const requestVersion = ++openRequestVersionRef.current; isSavingRef.current = false; const activeElement = document.activeElement; returnFocusRef.current = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : null; void loadMemoEditor() .then(({ default: MemoEditor }) => { if (openRequestVersionRef.current !== requestVersion) return; setEditorComponent(() => MemoEditor); setOpened({ user: currentUserName, space: composeSpace }); }) .catch(() => undefined); }, [canOpen, composeSpace, currentUserName, setMobileOpen, setQuickFindOpen]); useEffect(() => { // RootLayout remains mounted when a public instance moves from Home to // Explore on sign-out. Treat every auth/readiness transition as a session // boundary so an open composer—or a pending lazy import—cannot survive it. closeEditor(); returnFocusRef.current = null; }, [closeEditor, currentUserName, isUserSettingsInitialized]); const resolveFinalFocus = useCallback(() => { // Release the remembered node here rather than in closeEditor: base-ui asks // for it after close, and a trigger that unmounted with the drawer would // otherwise keep its whole detached subtree alive until the next open. const remembered = isVisibleFocusTarget(returnFocusRef.current) ? returnFocusRef.current : null; returnFocusRef.current = null; return remembered ?? findVisibleFocusTarget("[data-new-memo-trigger]") ?? findVisibleFocusTarget("[data-mobile-navigation-trigger]"); }, []); const editorIsOpen = opened !== undefined && opened.user === currentUserName; const value = useMemo(() => ({ canOpen, openEditor }), [canOpen, openEditor]); return ( {children} { if (!open && isSavingRef.current) { eventDetails.cancel(); return; } if (open) { setOpened((previous) => (currentUserName ? { user: currentUserName, space: previous?.space } : undefined)); } else { closeEditor(); } }} > {EditorComponent && ( {t("editor.new-memo")} {t("common.close")} )} ); } export function useGlobalMemoEditor() { const context = useContext(GlobalMemoEditorContext); if (!context) { throw new Error("useGlobalMemoEditor must be used within GlobalMemoEditorProvider"); } return context; }