diff --git a/react-components/src/components/RevealContainer/RevealContainer.tsx b/react-components/src/components/RevealContainer/RevealContainer.tsx index ea0229cf6db..fd45ab5bc9c 100644 --- a/react-components/src/components/RevealContainer/RevealContainer.tsx +++ b/react-components/src/components/RevealContainer/RevealContainer.tsx @@ -9,6 +9,7 @@ import { type Color } from 'three'; import { ModelsLoadingStateContext } from '../Reveal3DResources/ModelsLoadingContext'; import { SDKProvider } from './SDKProvider'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; +import { AuxillaryDivProvider } from '../ViewerAnchor/AuxillaryDivProvider'; type RevealContainerProps = { color?: Color; @@ -47,9 +48,11 @@ export function RevealContainer({ return ( -
- {mountChildren()} -
+ +
+ {mountChildren()} +
+
{uiElements}
diff --git a/react-components/src/components/ViewerAnchor/AuxillaryDivProvider.tsx b/react-components/src/components/ViewerAnchor/AuxillaryDivProvider.tsx new file mode 100644 index 00000000000..d847a9d1499 --- /dev/null +++ b/react-components/src/components/ViewerAnchor/AuxillaryDivProvider.tsx @@ -0,0 +1,58 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { createContext, useContext, useState, useCallback, type ReactElement } from 'react'; + +export const AuxillaryDivProvider = ({ children }: { children: ReactElement }): ReactElement => { + const [elements, setElements] = useState([]); + + // Maintain a local copy of the elements, needed for properly supporting multiple + // `addElement` calls between rerenders + let cachedElements = elements; + + const addElement = useCallback( + (element: ReactElement) => { + const newElementList = [...cachedElements, element]; + + setElements(newElementList); + cachedElements = newElementList; + }, + [elements, setElements] + ); + + const removeElement = useCallback( + (element: ReactElement) => { + const newElementList = cachedElements.filter((e) => e !== element); + + setElements(newElementList); + cachedElements = newElementList; + }, + [elements, setElements] + ); + + return ( + <> + +
{elements}
+ {children} +
+ + ); +}; + +type AuxillaryContextData = { + addElement: (element: ReactElement) => void; + removeElement: (element: ReactElement) => void; +}; + +const AuxillaryDivContext = createContext(null); + +export const useAuxillaryDivContext = (): AuxillaryContextData => { + const auxContext = useContext(AuxillaryDivContext); + if (auxContext === null) { + throw new Error('useAuxillaryDivContext must be used inside AuxillaryDivContext'); + } + + return auxContext; +}; diff --git a/react-components/src/components/ViewerAnchor/ViewerAnchor.tsx b/react-components/src/components/ViewerAnchor/ViewerAnchor.tsx new file mode 100644 index 00000000000..f8b301ce7ef --- /dev/null +++ b/react-components/src/components/ViewerAnchor/ViewerAnchor.tsx @@ -0,0 +1,64 @@ +/*! + * Copyright 2023 Cognite AS + */ + +import { useEffect, useRef, type ReactElement, type RefObject } from 'react'; +import { type Vector3 } from 'three'; + +import { useReveal } from '../RevealContainer/RevealContext'; + +import { HtmlOverlayTool } from '@cognite/reveal/tools'; +import { useAuxillaryDivContext } from './AuxillaryDivProvider'; + +export type ViewerAnchorElementMapping = { + ref: RefObject; + position: Vector3; +}; + +export type ViewerAnchorProps = { + position: Vector3; + children: ReactElement; + uniqueKey: string; +}; + +export const ViewerAnchor = ({ + position, + children, + uniqueKey +}: ViewerAnchorProps): ReactElement => { + const viewer = useReveal(); + + const htmlTool = useRef(new HtmlOverlayTool(viewer)); + + const auxContext = useAuxillaryDivContext(); + + const htmlRef = useRef(null); + const element = ( +
+ {children} +
+ ); + + useEffect(() => { + auxContext.addElement(element); + return () => { + auxContext.removeElement(element); + }; + }, []); + + useEffect(() => { + if (htmlRef.current === null) { + return; + } + + const elementRef = htmlRef.current; + + htmlTool.current.add(elementRef, position); + + return () => { + htmlTool.current.remove(elementRef); + }; + }, [auxContext, children, htmlRef.current]); + + return <>; +}; diff --git a/react-components/src/index.ts b/react-components/src/index.ts index 007c48add1e..fc84d481b80 100644 --- a/react-components/src/index.ts +++ b/react-components/src/index.ts @@ -23,6 +23,7 @@ export { type Reveal3DResourcesStyling, type FdmAssetStylingGroup } from './components/Reveal3DResources/Reveal3DResources'; +export { ViewerAnchor } from './components/ViewerAnchor/ViewerAnchor'; export { CameraController } from './components/CameraController/CameraController'; export type { AddImageCollection360Options, diff --git a/react-components/stories/Reveal3DResources.stories.tsx b/react-components/stories/Reveal3DResources.stories.tsx index 019f2dcc04e..2710bcb5b4e 100644 --- a/react-components/stories/Reveal3DResources.stories.tsx +++ b/react-components/stories/Reveal3DResources.stories.tsx @@ -3,8 +3,8 @@ */ import type { Meta, StoryObj } from '@storybook/react'; import { Reveal3DResources, RevealContainer } from '../src'; -import { Color, Matrix4 } from 'three'; -import { CameraController } from '../src/'; +import { Color, Matrix4, Vector3 } from 'three'; +import { CameraController, ViewerAnchor } from '../src/'; import { createSdkByUrlToken } from './utilities/createSdkByUrlToken'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -123,31 +123,62 @@ export const Main: Story = { assetFdmSpace: 'bark-corporation' } }, - render: ({ resources, styling, fdmAssetMappingConfig }) => ( - } - viewerOptions={{ - loadingIndicatorStyle: { - opacity: 1, - placement: 'topRight' - } - }}> - - - - ) + render: ({ resources, styling, fdmAssetMappingConfig }) => { + const position = new Vector3(50, 30, 50); + const position2 = new Vector3(0, 0, 0); + + return ( + } + viewerOptions={{ + loadingIndicatorStyle: { + opacity: 1, + placement: 'topRight' + } + }}> + + +

+ This label is stuck at position {position.toArray().join(',')} +

+
+ +

+ This label is stuck at position {position2.toArray().join(',')} +

+
+ +
+ ); + } }; diff --git a/react-components/stories/ViewerAnchor.stories.tsx b/react-components/stories/ViewerAnchor.stories.tsx new file mode 100644 index 00000000000..68d9e17a9dd --- /dev/null +++ b/react-components/stories/ViewerAnchor.stories.tsx @@ -0,0 +1,103 @@ +/*! + * Copyright 2023 Cognite AS + */ +import type { Meta, StoryObj } from '@storybook/react'; +import { Reveal3DResources, RevealContainer } from '../src'; +import { Color, Matrix4, Vector3 } from 'three'; +import { CameraController, ViewerAnchor } from '../src/'; +import { createSdkByUrlToken } from './utilities/createSdkByUrlToken'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; + +const meta = { + title: 'Example/ViewerAnchor', + component: Reveal3DResources, + tags: ['autodocs'], + argTypes: { + styling: {} + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sdk = createSdkByUrlToken(); + +export const Main: Story = { + args: { + resources: [ + { + modelId: 2551525377383868, + revisionId: 2143672450453400, + transform: new Matrix4().makeTranslation(-340, -480, 80) + } + ], + styling: {}, + fdmAssetMappingConfig: { + source: { + space: 'hf_3d_schema', + version: '1', + type: 'view', + externalId: 'cdf_3d_connection_data' + }, + assetFdmSpace: 'hf_customer_a' + } + }, + render: ({ resources, styling, fdmAssetMappingConfig }) => { + const position = new Vector3(50, 30, 50); + const position2 = new Vector3(0, 0, 0); + + return ( + } + viewerOptions={{ + loadingIndicatorStyle: { + opacity: 1, + placement: 'topRight' + } + }}> + + +

+ This label is stuck at position {position.toArray().join(',')} +

+
+ +

+ This label is stuck at position {position2.toArray().join(',')} +

+
+ +
+ ); + } +};