diff --git a/src/apps/beam/AppBeam.tsx b/src/apps/beam/AppBeam.tsx index 9eeec93b1..58e477091 100644 --- a/src/apps/beam/AppBeam.tsx +++ b/src/apps/beam/AppBeam.tsx @@ -8,7 +8,7 @@ import { BeamView } from '~/modules/beam/BeamView'; import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla'; import { useModelsStore } from '~/modules/llms/store-llms'; -import { OptimaPortalIn } from '~/common/layout/optima/portals/OptimaPortalIn'; +import { OptimaToolbarIn } from '~/common/layout/optima/portals/OptimaPortalsIn'; import { createDConversation, DConversation } from '~/common/stores/chat/chat.conversation'; import { createDMessageTextContent, DMessage } from '~/common/stores/chat/chat.message'; import { useIsMobile } from '~/common/components/useMatchMedia'; @@ -76,7 +76,7 @@ export function AppBeam() { return <> - {toolbarItems} + {toolbarItems} diff --git a/src/apps/call/Telephone.tsx b/src/apps/call/Telephone.tsx index 195de35cd..1d1d44687 100644 --- a/src/apps/call/Telephone.tsx +++ b/src/apps/call/Telephone.tsx @@ -20,7 +20,7 @@ import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVo import { AudioPlayer } from '~/common/util/audio/AudioPlayer'; import { Link } from '~/common/components/Link'; -import { OptimaPortalIn } from '~/common/layout/optima/portals/OptimaPortalIn'; +import { OptimaToolbarIn } from '~/common/layout/optima/portals/OptimaPortalsIn'; import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition'; import { conversationTitle } from '~/common/stores/chat/chat.conversation'; import { createDMessageTextContent, DMessage, messageFragmentsReduceText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message'; @@ -307,7 +307,7 @@ export function Telephone(props: { return <> - {chatLLMDropdown} + {chatLLMDropdown} - - {drawerContent} - {focusedBarContent} + {drawerContent} + {focusedBarContent} - {drawSectionDropdown} + {drawSectionDropdown} {drawSection === 'create' ? ( {capitalizeFirstLetter(pageTitle)} ยท {Brand.Title.Base} ๐Ÿš€ - {drawerContent} + {drawerContent} {isListPage ? diff --git a/src/apps/personas/AppPersonas.tsx b/src/apps/personas/AppPersonas.tsx index 3f5119cda..545677564 100644 --- a/src/apps/personas/AppPersonas.tsx +++ b/src/apps/personas/AppPersonas.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Box, Container, ListDivider, Typography } from '@mui/joy'; -import { OptimaPortalIn } from '~/common/layout/optima/portals/OptimaPortalIn'; +import { OptimaDrawerIn } from '~/common/layout/optima/portals/OptimaPortalsIn'; import { Creator } from './creator/Creator'; import { CreatorDrawer } from './creator/CreatorDrawer'; @@ -27,7 +27,7 @@ export function AppPersonas() { }, [selectedSimplePersonaId]); return <> - {drawerContent} + {drawerContent} ({ export function DesktopDrawer(props: { component: React.ElementType, currentApp?: NavItemApp }) { // state - const drawerPortalRef = useOptimaPortalOut('optima-portal-drawer', 'DesktopDrawer'); + const drawerPortalRef = useOptimaPortalOutRef('optima-portal-drawer', 'DesktopDrawer'); // external state const { isDrawerOpen, closeDrawer, openDrawer } = useOptimaDrawers(); diff --git a/src/common/layout/optima/MobileDrawer.tsx b/src/common/layout/optima/MobileDrawer.tsx index a31e88d1c..ba0665986 100644 --- a/src/common/layout/optima/MobileDrawer.tsx +++ b/src/common/layout/optima/MobileDrawer.tsx @@ -5,11 +5,11 @@ import { Box, Drawer } from '@mui/joy'; import type { NavItemApp } from '~/common/app.nav'; import { useOptimaDrawers } from './useOptimaDrawers'; -import { useOptimaPortalOut } from './portals/useOptimaPortalOut'; +import { useOptimaPortalOutRef } from './portals/useOptimaPortalOutRef'; function DrawerContentPortal() { - const drawerPortalRef = useOptimaPortalOut('optima-portal-drawer', 'MobileDrawer'); + const drawerPortalRef = useOptimaPortalOutRef('optima-portal-drawer', 'MobileDrawer'); return ( @@ -51,7 +51,7 @@ const centerItemsContainerSx: SxProps = { }; function CenterItemsPortal() { - const portalToolbarRef = useOptimaPortalOut('optima-portal-toolbar', 'PageBar.CenterItemsContainer'); + const portalToolbarRef = useOptimaPortalOutRef('optima-portal-toolbar', 'PageBar.CenterItemsContainer'); return ( {/* TODO */} diff --git a/src/common/layout/optima/portals/OptimaPortalIn.tsx b/src/common/layout/optima/portals/OptimaPortalIn.tsx deleted file mode 100644 index c18b47995..000000000 --- a/src/common/layout/optima/portals/OptimaPortalIn.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { createPortal } from 'react-dom'; - -import { OptimaPortalId, usePortalElement } from './store-optima-portals'; - - -export function OptimaPortalIn(props: { - targetPortalId: OptimaPortalId; - children: React.ReactNode; -}) { - - // react to the portal being made available - const portalElement = usePortalElement(props.targetPortalId); - if (!portalElement) - return null; - - return createPortal(props.children, portalElement); -} diff --git a/src/common/layout/optima/portals/OptimaPortalsIn.tsx b/src/common/layout/optima/portals/OptimaPortalsIn.tsx new file mode 100644 index 000000000..97f4c4fe8 --- /dev/null +++ b/src/common/layout/optima/portals/OptimaPortalsIn.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import { OptimaPortalId, useOptimaPortalsStore } from './store-optima-portals'; + + +export function OptimaDrawerIn(props: { children: React.ReactNode }) { + const portalElement = _useOptimaPortalTargetElement('optima-portal-drawer'); + return portalElement ? createPortal(props.children, portalElement) : null; +} + +export function OptimaToolbarIn(props: { children: React.ReactNode }) { + const portalElement = _useOptimaPortalTargetElement('optima-portal-toolbar'); + return portalElement ? createPortal(props.children, portalElement) : null; +} + + +/** + * Hook to get the target element for a portal. + */ +function _useOptimaPortalTargetElement(targetPortalId: OptimaPortalId) { + // get the output element + const targetPortalEl = useOptimaPortalsStore(state => state.portals[targetPortalId]?.element ?? null); + + // increment/decrement input count + React.useEffect(() => { + const { incrementInputs, decrementInputs } = useOptimaPortalsStore.getState(); + incrementInputs(targetPortalId); + return () => decrementInputs(targetPortalId); + }, [targetPortalId]); + + // return the output element + return targetPortalEl; +} diff --git a/src/common/layout/optima/portals/store-optima-portals.ts b/src/common/layout/optima/portals/store-optima-portals.ts index ae72431b0..b2d213f50 100644 --- a/src/common/layout/optima/portals/store-optima-portals.ts +++ b/src/common/layout/optima/portals/store-optima-portals.ts @@ -1,3 +1,4 @@ +import * as React from 'react'; import { create } from 'zustand'; @@ -7,51 +8,71 @@ const DEBUG_OPTIMA_PORTALS = false; export type OptimaPortalId = | 'optima-portal-drawer' - // | 'optima-portal-properties' - | 'optima-portal-toolbar' - ; + | 'optima-portal-toolbar'; -interface PortalState { - portalElements: Map; + +interface OptimaPortalState { + portals: Record; } -interface PortalActions { - addPortal: (id: OptimaPortalId, element: HTMLElement) => void; - removePortal: (id: OptimaPortalId) => void; +interface OptimaPortalActions { + + // store output elements + setElement: (id: OptimaPortalId, element: HTMLElement | null) => void; + + // reference counting + incrementInputs: (id: OptimaPortalId) => void; + decrementInputs: (id: OptimaPortalId) => void; + } -type PortalStore = PortalState & PortalActions; - -const useOptimaPortalsStore = create((set, get) => ({ +export const useOptimaPortalsStore = create((_set) => ({ // init state - portalElements: new Map(), + portals: { + 'optima-portal-drawer': { element: null, inputs: 0 }, + 'optima-portal-toolbar': { element: null, inputs: 0 }, + }, // actions - addPortal: (id, element) => set((state) => { - const newPortals = new Map(state.portalElements); - newPortals.set(id, element); + + setElement: (id, element) => _set((state) => { if (DEBUG_OPTIMA_PORTALS) - console.log(' > store.addPortal', id, !!element); - return { portalElements: newPortals }; + console.log(`${element ? 'Set' : 'Remove'} portal element`, id); + return { + portals: { + ...state.portals, + [id]: { ...state.portals[id], element: element }, + }, + }; }), - removePortal: (id) => set((state) => { - const newPortals = new Map(state.portalElements); - newPortals.delete(id); + incrementInputs: (id) => _set((state) => { + const newInputs = state.portals[id].inputs + 1; if (DEBUG_OPTIMA_PORTALS) - console.log(' < store.removePortal', id); - return { portalElements: newPortals }; + console.log(' + store.incrementInputs', id, newInputs); + return { + portals: { + ...state.portals, + [id]: { ...state.portals[id], inputs: newInputs }, + }, + }; + }), + + decrementInputs: (id) => _set((state) => { + const newInputs = Math.max(0, state.portals[id].inputs - 1); + if (DEBUG_OPTIMA_PORTALS) + console.log(' - store.decrementInputs', id, newInputs); + return { + portals: { + ...state.portals, + [id]: { ...state.portals[id], inputs: newInputs }, + }, + }; }), })); - - -export function optimaPortalsActions(): PortalActions { - return useOptimaPortalsStore.getState(); -} - -export function usePortalElement(id: OptimaPortalId) { - return useOptimaPortalsStore((state) => state.portalElements.get(id) || null); -} diff --git a/src/common/layout/optima/portals/useOptimaPortalOut.tsx b/src/common/layout/optima/portals/useOptimaPortalOut.tsx deleted file mode 100644 index 765ac4e7f..000000000 --- a/src/common/layout/optima/portals/useOptimaPortalOut.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from 'react'; - -import { OptimaPortalId, optimaPortalsActions } from './store-optima-portals'; - - -/** - * Note: this hook assumes that the ref is created by when useLayoutEffect is called, - * and will warn otherwise. - */ -export function useOptimaPortalOut(portalTargetId: OptimaPortalId, debugCallerName: string) { - - // state - const ref = React.useRef(null); - - React.useLayoutEffect(() => { - const { addPortal, removePortal } = optimaPortalsActions(); - if (!ref.current) { - console.warn(`useOptimaPortalOut: ref.current is null for type ${portalTargetId} (called by ${debugCallerName})`); - } else { - addPortal(portalTargetId, ref.current); - } - return () => removePortal(portalTargetId); - }, [debugCallerName, portalTargetId]); - - return ref; -} - -/* -// This version will add the portal only when really getting the ref -export function useOptimaPortalOut(portalTargetId: OptimaPortalId, debugCallerName: string) { - - const setRef = React.useCallback((node: HTMLElement | null) => { - const { addPortal, removePortal } = optimaPortalsActions(); - console.log('useOptimaPortalOut.setRef', portalTargetId, node); - if (node) { - console.log(' - useOptimaPortalOut call AddPortal', portalTargetId); - addPortal(portalTargetId, node); - } else { - removePortal(portalTargetId); - } - }, [portalTargetId]); - - return setRef; -} -*/ \ No newline at end of file diff --git a/src/common/layout/optima/portals/useOptimaPortalOutRef.ts b/src/common/layout/optima/portals/useOptimaPortalOutRef.ts new file mode 100644 index 000000000..64bb108da --- /dev/null +++ b/src/common/layout/optima/portals/useOptimaPortalOutRef.ts @@ -0,0 +1,33 @@ +import * as React from 'react'; + +import { OptimaPortalId, useOptimaPortalsStore } from './store-optima-portals'; + + +/** + * Defines a React output portal for a given target id. Will return a ref that + * must be attached to the target element. + * + * Note: this hook assumes that the ref is created by when useLayoutEffect is called, + * and will warn otherwise. + * + * If the ref is created after the layout effect, the portal will not be added. In + * that case, consider returning a Callback instead, with: + * `const setRef = React.useCallback((node: HTMLElement | null) => { ... }, [portalTargetId]);` + */ +export function useOptimaPortalOutRef(portalTargetId: OptimaPortalId, debugCallerName: string) { + + // state + const ref = React.useRef(null); + + React.useLayoutEffect(() => { + const { setElement } = useOptimaPortalsStore.getState(); + if (!ref.current) { + console.warn(`useOptimaPortalOut: ref.current is null for type ${portalTargetId} (called by ${debugCallerName})`); + } else { + setElement(portalTargetId, ref.current); + } + return () => setElement(portalTargetId, null); + }, [debugCallerName, portalTargetId]); + + return ref; +}