From 99b1772615e1de25cf0c92e56691fb163e564bdd Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Tue, 8 Aug 2023 14:55:03 +0200 Subject: [PATCH] Refactor `Html` with portal --- apps/storybook/src/Html.stories.tsx | 252 +++++++++++++++++++-------- packages/lib/src/vis/shared/Html.tsx | 50 +++--- 2 files changed, 200 insertions(+), 102 deletions(-) diff --git a/apps/storybook/src/Html.stories.tsx b/apps/storybook/src/Html.stories.tsx index 6359d34d2..7fb1812a2 100644 --- a/apps/storybook/src/Html.stories.tsx +++ b/apps/storybook/src/Html.stories.tsx @@ -1,5 +1,13 @@ -import { DefaultInteractions, Html, VisCanvas } from '@h5web/lib'; +import { + DefaultInteractions, + FloatingControl, + Html, + useVisCanvasContext, + VisCanvas, +} from '@h5web/lib'; +import { useToggle } from '@react-hookz/web'; import type { Meta, StoryObj } from '@storybook/react'; +import type { PropsWithChildren } from 'react'; import { useState } from 'react'; import { createPortal } from 'react-dom'; @@ -8,104 +16,198 @@ import FillHeight from './decorators/FillHeight'; const meta = { title: 'Building Blocks/Html', component: Html, - decorators: [FillHeight], parameters: { layout: 'fullscreen' }, - argTypes: { - container: { control: false }, - }, + decorators: [ + (Story) => ( + + + + + ), + FillHeight, + ], } satisfies Meta; export default meta; type Story = StoryObj; export const Default = { + render: () => ( + <> + +
+ This div element is a child of VisCanvas. + Wrapping it with{' '} + + Html + {' '} + allows it to be rendered with React DOM instead of React Three Fiber's + own renderer, which cannot render HTML elements. +
+ + + + + + + ), + argTypes: { + overflowCanvas: { control: false }, + }, +} satisfies Story; + +function MyHtml({ children }: PropsWithChildren) { + const { canvasSize } = useVisCanvasContext(); + + return ( + +
+ This div element is wrapped in{' '} + + Html + {' '} + inside a custom React component called MyHtml, which has + access to the VisCanvas and React Three Fiber contexts – + e.g. canvasWidth = {canvasSize.width} +
+ {children} + + ); +} + +function MyDiv() { + return ( +
+ This div element is declared inside a component called{' '} + MyDiv, which is passed as a child to MyHtml. It + shows that HTML elements and their corresponding{' '} + + Html + {' '} + wrappers don't have to live inside the same React components. However, + note that MyDiv does not have access to the{' '} + VisCanvas and React Three Fiber contexts. +
+ ); +} + +export const OverflowCanvas = { render: (args) => { const { overflowCanvas } = args; return ( - - - -
+ +
+

+ By default, Html renders its children next to the{' '} + canvas element. With prop{' '} + + overflowCanvas + + , the children are rendered one level higher in the DOM instead, + allowing them to overflow above the axes. +

+

This div{' '} {overflowCanvas ? 'overflows' : 'does not overflow'} {' '} - the canvas. -

- - + the bounds of the canvas. +

+
+ ); }, -} satisfies Story; - -export const OverflowCanvas = { - ...Default, args: { overflowCanvas: true, }, } satisfies Story; -export const CustomContainer = { - render: (args) => { - const [container, setContainer] = useState(); - const [portalTarget, setPortalTarget] = useState(); +export const Portal = { + render: () => { + const [containerMounted, toggleContainer] = useToggle(true); + const [customContainer, setCustomContainer] = + useState(null); return ( -
- - - -
setPortalTarget(elem || undefined)} - style={{ - position: 'absolute', - top: 0, - left: 0, - padding: '0.5rem', - border: '3px solid blue', - backgroundColor: 'rgba(255, 255, 255, 0.8)', - }} - > -

- This div is rendered in a custom container{' '} - next to VisCanvas. -

-
- - - {portalTarget && ( - - {createPortal( -

- This paragraph appears in the same div but is - rendered with a separate Html element and a - portal. -

, - portalTarget, - )} - - )} -
+ <> + {containerMounted &&
} + + {customContainer && + createPortal( +

+ This example demonstrates that, using a portal, children of{' '} + Html can be rendered into a{' '} + custom container (itself potentially rendered + with Html). +

, + customContainer, + )} + -
elem && setContainer(elem)} /> -
+ + + + ); }, argTypes: { diff --git a/packages/lib/src/vis/shared/Html.tsx b/packages/lib/src/vis/shared/Html.tsx index 7f1611621..cb924f5e6 100644 --- a/packages/lib/src/vis/shared/Html.tsx +++ b/packages/lib/src/vis/shared/Html.tsx @@ -1,48 +1,44 @@ +import { assertNonNull } from '@h5web/shared'; import { useThree } from '@react-three/fiber'; -import type { ReactNode } from 'react'; +import type { PropsWithChildren } from 'react'; import { useLayoutEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM, { createPortal } from 'react-dom'; interface Props { - overflowCanvas?: boolean; // allow children to overflow above axes - container?: HTMLElement; - children?: ReactNode; + overflowCanvas?: boolean; } -function Html(props: Props) { - const { - overflowCanvas = false, - container: customContainer, - children, - } = props; +function Html(props: PropsWithChildren) { + const { overflowCanvas = false, children } = props; const r3fRoot = useThree((state) => state.gl.domElement.parentElement); - const canvasWrapper = r3fRoot?.parentElement; + assertNonNull(r3fRoot); - // Choose DOM container to which to append `renderTarget` - // (with `canvasWrapper`, `Html` children are allowed to overflow above the axes) - const container = - customContainer || (overflowCanvas ? canvasWrapper : r3fRoot); + const canvasWrapper = r3fRoot.parentElement; + assertNonNull(canvasWrapper); - const [renderTarget] = useState(() => document.createElement('div')); + const portalContainer = overflowCanvas ? canvasWrapper : r3fRoot; - useLayoutEffect(() => { - ReactDOM.render(<>{children}, renderTarget); // eslint-disable-line react/jsx-no-useless-fragment - }, [children, renderTarget]); + const [renderContainer] = useState(() => { + const div = document.createElement('div'); + div.setAttribute('hidden', ''); + return div; + }); useLayoutEffect(() => { - return () => { - ReactDOM.unmountComponentAtNode(renderTarget); - }; - }, [renderTarget]); + ReactDOM.render(createPortal(children, portalContainer), renderContainer); + }, [children, portalContainer, renderContainer]); useLayoutEffect(() => { - container?.append(renderTarget); + /* Since the children are rendered in a portal, it doesn't technically matter + which element `renderContainer` is appended to, as long as it stays in the DOM. */ + r3fRoot.append(renderContainer); return () => { - renderTarget.remove(); + ReactDOM.unmountComponentAtNode(renderContainer); + renderContainer.remove(); }; - }, [container, renderTarget]); + }, [r3fRoot, renderContainer]); return null; }