From 69a3ec28cbfdb2c915becbfb163a1c386ce0e574 Mon Sep 17 00:00:00 2001 From: Axel Bocciarelli Date: Tue, 8 Aug 2023 14:55:03 +0200 Subject: [PATCH] Introduce `HtmlPortal` to render HTML elements inside custom containers --- apps/storybook/src/Context.mdx | 1 + apps/storybook/src/Html.stories.tsx | 146 +++++++++--------- apps/storybook/src/HtmlPortal.stories.tsx | 82 ++++++++++ .../lib/src/interactions/svg/SvgElement.tsx | 9 +- .../src/toolbar/floating/FloatingControl.tsx | 9 +- packages/lib/src/vis/shared/Annotation.tsx | 39 +++-- packages/lib/src/vis/shared/AxisSystem.tsx | 14 +- packages/lib/src/vis/shared/Html.tsx | 49 ++---- packages/lib/src/vis/shared/HtmlPortal.tsx | 17 ++ packages/lib/src/vis/shared/VisCanvas.tsx | 16 +- .../lib/src/vis/shared/VisCanvasProvider.tsx | 16 +- 11 files changed, 245 insertions(+), 153 deletions(-) create mode 100644 apps/storybook/src/HtmlPortal.stories.tsx create mode 100644 packages/lib/src/vis/shared/HtmlPortal.tsx diff --git a/apps/storybook/src/Context.mdx b/apps/storybook/src/Context.mdx index 4b2522998..770098874 100644 --- a/apps/storybook/src/Context.mdx +++ b/apps/storybook/src/Context.mdx @@ -16,6 +16,7 @@ const { visSize, dataToWorld, worldToData } = useVisCanvasContext(); | `canvasSize` | Canvas size (equivalent to `useThree((state) => state.size)`) | Size | | `canvasRatio` | Canvas ratio (i.e. `width / height`) | number | | `canvasBox` | [`Box`](https://h5web-docs.panosc.eu/?path=/story/utilities--page#box) spanning the canvas in HTML space | Box | +| `canvasWrapper` | Wrapper `div` element rendered by `VisCanvas`, which wraps React Three Fiber's own `div.r3fRoot` wrapper | HTMLElement | | `visRatio` | Visualization ratio: defined when `VisCanvas` receives `aspect="equal"` or `aspect={number}` (e.g. `HeatmapVis` with "keep ratio" enabled); `undefined` otherwise | number | undefined | | `visSize` | Visualization size (different from canvas size when `visRatio` is defined) | Size | | `abscissaConfig` | Abscissa configuration object passed to `VisCanvas` | AxisConfig | diff --git a/apps/storybook/src/Html.stories.tsx b/apps/storybook/src/Html.stories.tsx index 6359d34d2..c2fee6e00 100644 --- a/apps/storybook/src/Html.stories.tsx +++ b/apps/storybook/src/Html.stories.tsx @@ -1,7 +1,11 @@ -import { DefaultInteractions, Html, VisCanvas } from '@h5web/lib'; +import { + DefaultInteractions, + Html, + useVisCanvasContext, + VisCanvas, +} from '@h5web/lib'; import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; -import { createPortal } from 'react-dom'; +import type { PropsWithChildren } from 'react'; import FillHeight from './decorators/FillHeight'; @@ -10,9 +14,6 @@ const meta = { component: Html, decorators: [FillHeight], parameters: { layout: 'fullscreen' }, - argTypes: { - container: { control: false }, - }, } satisfies Meta; export default meta; @@ -20,95 +21,96 @@ type Story = StoryObj; export const Default = { render: (args) => { - const { overflowCanvas } = args; return ( - +
- This div{' '} + This div element is a child of VisCanvas. + Wrapping it with{' '} - {overflowCanvas ? 'overflows' : 'does not overflow'} + Html {' '} - the canvas. + allows it to be rendered with React DOM instead of React Three + Fiber's own renderer, which cannot render HTML elements.
+ + + +
); }, } satisfies Story; -export const OverflowCanvas = { - ...Default, - args: { - overflowCanvas: true, - }, -} satisfies Story; - -export const CustomContainer = { - render: (args) => { - const [container, setContainer] = useState(); - const [portalTarget, setPortalTarget] = useState(); - - 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. -

-
- +function MyHtml({ children }: PropsWithChildren) { + const { canvasSize } = useVisCanvasContext(); - {portalTarget && ( - - {createPortal( -

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

, - portalTarget, - )} - - )} - - -
elem && setContainer(elem)} /> + 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}
- ); - }, - argTypes: { - overflowCanvas: { control: false }, - }, -} satisfies Story; + {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. +
+ ); +} diff --git a/apps/storybook/src/HtmlPortal.stories.tsx b/apps/storybook/src/HtmlPortal.stories.tsx new file mode 100644 index 000000000..bc79f072b --- /dev/null +++ b/apps/storybook/src/HtmlPortal.stories.tsx @@ -0,0 +1,82 @@ +import { + DefaultInteractions, + useVisCanvasContext, + VisCanvas, +} from '@h5web/lib'; +import HtmlPortal from '@h5web/lib/src/vis/shared/HtmlPortal'; +import type { Meta, StoryObj } from '@storybook/react'; + +import FillHeight from './decorators/FillHeight'; + +const meta = { + title: 'Building Blocks/HtmlPortal', + component: HtmlPortal, + decorators: [FillHeight], + parameters: { layout: 'fullscreen' }, + argTypes: { container: { control: false } }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default = { + render: () => { + return ( + + + + + ); + }, +} satisfies Story; + +function MyHtml() { + const { canvasWrapper } = useVisCanvasContext(); + + return ( + +
+

+ While Html always renders its children next to the{' '} + canvas element,{' '} + + HtmlPortal + {' '} + can render its children into any given container element. +

+

+ The two main use cases for{' '} + + HtmlPortal + {' '} + are: +

+
    +
  • + rendering HTML elements into canvasWrapper so they + overflow the canvas (like this div or the axes); +
  • +
  • + rendering the children of multiple HtmlPortal elements + inside the same container (SvgElement and{' '} + FloatingControl both rely on HtmlPortal{' '} + for this purpose). +
  • +
+
+
+ ); +} diff --git a/packages/lib/src/interactions/svg/SvgElement.tsx b/packages/lib/src/interactions/svg/SvgElement.tsx index 7f005b0c0..e5ec4f2ae 100644 --- a/packages/lib/src/interactions/svg/SvgElement.tsx +++ b/packages/lib/src/interactions/svg/SvgElement.tsx @@ -1,7 +1,6 @@ import type { PropsWithChildren } from 'react'; -import { createPortal } from 'react-dom'; -import Html from '../../vis/shared/Html'; +import HtmlPortal from '../../vis/shared/HtmlPortal'; import { useVisCanvasContext } from '../../vis/shared/VisCanvasProvider'; interface Props {} @@ -10,11 +9,7 @@ function SvgElement(props: PropsWithChildren) { const { children } = props; const { svgOverlay } = useVisCanvasContext(); - if (!svgOverlay) { - return null; - } - - return {createPortal(children, svgOverlay)}; + return {children}; } export type { Props as SvgElementProps }; diff --git a/packages/lib/src/toolbar/floating/FloatingControl.tsx b/packages/lib/src/toolbar/floating/FloatingControl.tsx index 517f76caf..b237b41ed 100644 --- a/packages/lib/src/toolbar/floating/FloatingControl.tsx +++ b/packages/lib/src/toolbar/floating/FloatingControl.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react'; -import { createPortal } from 'react-dom'; -import Html from '../../vis/shared/Html'; +import HtmlPortal from '../../vis/shared/HtmlPortal'; import { useVisCanvasContext } from '../../vis/shared/VisCanvasProvider'; interface Props { @@ -12,11 +11,7 @@ function FloatingControl(props: Props) { const { children } = props; const { floatingToolbar } = useVisCanvasContext(); - if (!floatingToolbar) { - return null; - } - - return {createPortal(children, floatingToolbar)}; + return {children}; } export default FloatingControl; diff --git a/packages/lib/src/vis/shared/Annotation.tsx b/packages/lib/src/vis/shared/Annotation.tsx index a8e452a1b..de3d02e11 100644 --- a/packages/lib/src/vis/shared/Annotation.tsx +++ b/packages/lib/src/vis/shared/Annotation.tsx @@ -3,6 +3,7 @@ import { Vector3 } from 'three'; import { useCameraState } from '../hooks'; import Html from './Html'; +import HtmlPortal from './HtmlPortal'; import { useVisCanvasContext } from './VisCanvasProvider'; interface Props extends HTMLAttributes { @@ -31,7 +32,7 @@ function Annotation(props: Props) { ); } - const { dataToHtml } = useVisCanvasContext(); + const { canvasWrapper, dataToHtml } = useVisCanvasContext(); const { htmlPt, cameraScale } = useCameraState( (camera) => ({ htmlPt: dataToHtml(camera, new Vector3(x, y)), @@ -45,23 +46,27 @@ function Annotation(props: Props) { scaleOnZoom ? `scale(${1 / cameraScale.x}, ${1 / cameraScale.y})` : '', ]; - return ( - -
- {children} -
- + const Elem = ( +
+ {children} +
); + + if (overflowCanvas) { + return {Elem}; + } + + return {Elem}; } export default Annotation; diff --git a/packages/lib/src/vis/shared/AxisSystem.tsx b/packages/lib/src/vis/shared/AxisSystem.tsx index 3d2ef8baa..2847c2b56 100644 --- a/packages/lib/src/vis/shared/AxisSystem.tsx +++ b/packages/lib/src/vis/shared/AxisSystem.tsx @@ -3,6 +3,7 @@ import type { AxisOffsets } from '../models'; import Axis from './Axis'; import styles from './AxisSystem.module.css'; import Html from './Html'; +import HtmlPortal from './HtmlPortal'; import { useVisCanvasContext } from './VisCanvasProvider'; interface Props { @@ -13,14 +14,19 @@ interface Props { function AxisSystem(props: Props) { const { axisOffsets, title, showAxes } = props; - const { canvasSize, abscissaConfig, ordinateConfig, getVisibleDomains } = - useVisCanvasContext(); + const { + canvasSize, + canvasWrapper, + abscissaConfig, + ordinateConfig, + getVisibleDomains, + } = useVisCanvasContext(); const visibleDomains = useCameraState(getVisibleDomains, [getVisibleDomains]); return ( // Append to `canvasWrapper` instead of `r3fRoot` - +
- +
); } diff --git a/packages/lib/src/vis/shared/Html.tsx b/packages/lib/src/vis/shared/Html.tsx index 7f1611621..2b3577139 100644 --- a/packages/lib/src/vis/shared/Html.tsx +++ b/packages/lib/src/vis/shared/Html.tsx @@ -1,48 +1,33 @@ +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; -} - -function Html(props: Props) { - const { - overflowCanvas = false, - container: customContainer, - children, - } = props; +function Html(props: PropsWithChildren) { + const { 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 [renderTarget] = useState(() => document.createElement('div')); + const [container] = useState(() => { + const div = document.createElement('div'); + div.setAttribute('hidden', ''); + return div; + }); useLayoutEffect(() => { - ReactDOM.render(<>{children}, renderTarget); // eslint-disable-line react/jsx-no-useless-fragment - }, [children, renderTarget]); - - useLayoutEffect(() => { - return () => { - ReactDOM.unmountComponentAtNode(renderTarget); - }; - }, [renderTarget]); + ReactDOM.render(createPortal(children, r3fRoot), container); + }, [children, r3fRoot, container]); useLayoutEffect(() => { - container?.append(renderTarget); + r3fRoot.append(container); return () => { - renderTarget.remove(); + ReactDOM.unmountComponentAtNode(container); + container.remove(); }; - }, [container, renderTarget]); + }, [r3fRoot, container]); return null; } diff --git a/packages/lib/src/vis/shared/HtmlPortal.tsx b/packages/lib/src/vis/shared/HtmlPortal.tsx new file mode 100644 index 000000000..17cd5c471 --- /dev/null +++ b/packages/lib/src/vis/shared/HtmlPortal.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from 'react'; +import { createPortal } from 'react-dom'; + +import Html from './Html'; + +interface Props { + container: Element | null; +} + +function HtmlPortal(props: PropsWithChildren) { + const { container, children } = props; + + // Render children only when `container` is available (useful when dealing with async refs) + return {container && createPortal(children, container)}; +} + +export default HtmlPortal; diff --git a/packages/lib/src/vis/shared/VisCanvas.tsx b/packages/lib/src/vis/shared/VisCanvas.tsx index 96a1f2c16..2b08cee48 100644 --- a/packages/lib/src/vis/shared/VisCanvas.tsx +++ b/packages/lib/src/vis/shared/VisCanvas.tsx @@ -49,8 +49,10 @@ function VisCanvas(props: PropsWithChildren) { }) : NO_OFFSETS; - const [svgOverlay, setSvgOverlay] = useState(); - const [floatingToolbar, setFloatingToolbar] = useState(); + const [svgOverlay, setSvgOverlay] = useState(null); + const [floatingToolbar, setFloatingToolbar] = useState( + null, + ); return (
) { - setSvgOverlay(elem || undefined)} - className={styles.svgOverlay} - /> + -
setFloatingToolbar(elem || undefined)} - className={styles.floatingToolbar} - /> +
diff --git a/packages/lib/src/vis/shared/VisCanvasProvider.tsx b/packages/lib/src/vis/shared/VisCanvasProvider.tsx index 9e7a3b972..d0407386a 100644 --- a/packages/lib/src/vis/shared/VisCanvasProvider.tsx +++ b/packages/lib/src/vis/shared/VisCanvasProvider.tsx @@ -1,4 +1,5 @@ import type { VisibleDomains } from '@h5web/shared'; +import { assertNonNull } from '@h5web/shared'; import { useThree } from '@react-three/fiber'; import type { PropsWithChildren } from 'react'; import { createContext, useCallback, useContext, useMemo } from 'react'; @@ -13,6 +14,7 @@ export interface VisCanvasContextValue { canvasSize: Size; canvasRatio: number; canvasBox: Box; + canvasWrapper: HTMLElement; visRatio: number | undefined; visSize: Size; abscissaConfig: AxisConfig; @@ -29,8 +31,8 @@ export interface VisCanvasContextValue { getVisibleDomains: (camera: Camera) => VisibleDomains; // For internal use only - svgOverlay: SVGSVGElement | undefined; - floatingToolbar: HTMLDivElement | undefined; + svgOverlay: SVGSVGElement | null; + floatingToolbar: HTMLDivElement | null; } const VisCanvasContext = createContext({} as VisCanvasContextValue); @@ -43,8 +45,8 @@ interface Props { visRatio: number | undefined; abscissaConfig: AxisConfig; ordinateConfig: AxisConfig; - svgOverlay: SVGSVGElement | undefined; - floatingToolbar: HTMLDivElement | undefined; + svgOverlay: SVGSVGElement | null; + floatingToolbar: HTMLDivElement | null; } function VisCanvasProvider(props: PropsWithChildren) { @@ -68,6 +70,11 @@ function VisCanvasProvider(props: PropsWithChildren) { [width, height], ); + const r3fRoot = useThree((state) => state.gl.domElement.parentElement); + assertNonNull(r3fRoot); + const canvasWrapper = r3fRoot.parentElement; + assertNonNull(canvasWrapper); + const abscissaScale = getCanvasAxisScale(abscissaConfig, visSize.width); const ordinateScale = getCanvasAxisScale(ordinateConfig, visSize.height); @@ -155,6 +162,7 @@ function VisCanvasProvider(props: PropsWithChildren) { canvasSize, canvasRatio, canvasBox, + canvasWrapper, visRatio, visSize, abscissaConfig,