Skip to content

Commit

Permalink
Refactor Html with portal
Browse files Browse the repository at this point in the history
  • Loading branch information
axelboc committed Aug 10, 2023
1 parent d912a67 commit 633f88f
Show file tree
Hide file tree
Showing 4 changed files with 210 additions and 116 deletions.
252 changes: 177 additions & 75 deletions apps/storybook/src/Html.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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) => (
<VisCanvas
abscissaConfig={{ visDomain: [0, 3], showGrid: true }}
ordinateConfig={{ visDomain: [50, 100], showGrid: true }}
>
<DefaultInteractions />
<Story />
</VisCanvas>
),
FillHeight,
],
} satisfies Meta<typeof Html>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default = {
render: () => (
<>
<Html>
<div
style={{
position: 'absolute',
top: 30,
left: 40,
width: '32em',
padding: '0.5rem',
border: '3px solid blue',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
}}
>
This <code>div</code> element is a child of <code>VisCanvas</code>.
Wrapping it with{' '}
<strong>
<code>Html</code>
</strong>{' '}
allows it to be rendered with React DOM instead of React Three Fiber's
own renderer, which cannot render HTML elements.
</div>
</Html>

<MyHtml>
<MyDiv />
</MyHtml>
</>
),
argTypes: {
overflowCanvas: { control: false },
},
} satisfies Story;

function MyHtml({ children }: PropsWithChildren<object>) {
const { canvasSize } = useVisCanvasContext();

return (
<Html>
<div
style={{
position: 'absolute',
top: 130,
left: 70,
width: '32em',
padding: '0.5rem',
border: '3px solid magenta',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
}}
>
This <code>div</code> element is wrapped in{' '}
<strong>
<code>Html</code>
</strong>{' '}
inside a custom React component called <code>MyHtml</code>, which has
access to the <code>VisCanvas</code> and React Three Fiber contexts –
e.g. <code>canvasWidth = {canvasSize.width}</code>
</div>
{children}
</Html>
);
}

function MyDiv() {
return (
<div
style={{
position: 'absolute',
top: 230,
left: 130,
width: '40em',
padding: '0.5rem',
border: '3px solid darkviolet',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
}}
>
This <code>div</code> element is declared inside a component called{' '}
<code>MyDiv</code>, which is passed as a child to <code>MyHtml</code>. It
shows that HTML elements and their corresponding{' '}
<strong>
<code>Html</code>
</strong>{' '}
wrappers don't have to live inside the same React components. However,
note that <code>MyDiv</code> does not have access to the{' '}
<code>VisCanvas</code> and React Three Fiber contexts.
</div>
);
}

export const OverflowCanvas = {
render: (args) => {
const { overflowCanvas } = args;
return (
<VisCanvas
abscissaConfig={{ visDomain: [0, 3], showGrid: true }}
ordinateConfig={{ visDomain: [50, 100], showGrid: true }}
>
<DefaultInteractions />
<Html overflowCanvas={overflowCanvas}>
<div
style={{
position: 'absolute',
top: overflowCanvas ? 45 : 30,
left: overflowCanvas ? 30 : -50,
width: '30em',
padding: '0.5rem',
border: '3px solid blue',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
}}
>
<Html overflowCanvas={overflowCanvas}>
<div
style={{
position: 'absolute',
top: overflowCanvas ? 45 : 30,
left: overflowCanvas ? 30 : -50,
width: '35em',
padding: '0.5rem',
border: '3px solid blue',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
}}
>
<p>
By default, <code>Html</code> renders its children next to the{' '}
<code>canvas</code> element. With prop{' '}
<strong>
<code>overflowCanvas</code>
</strong>
, the children are rendered one level higher in the DOM instead,
allowing them to overflow above the axes.
</p>
<p>
This <code>div</code>{' '}
<strong>
{overflowCanvas ? 'overflows' : 'does not overflow'}
</strong>{' '}
the canvas.
</div>
</Html>
</VisCanvas>
the bounds of the canvas.
</p>
</div>
</Html>
);
},
} satisfies Story;

export const OverflowCanvas = {
...Default,
args: {
overflowCanvas: true,
},
} satisfies Story;

export const CustomContainer = {
render: (args) => {
const [container, setContainer] = useState<HTMLDivElement>();
const [portalTarget, setPortalTarget] = useState<HTMLDivElement>();
export const Portal = {
render: () => {
const [containerMounted, toggleContainer] = useToggle(true);
const [customContainer, setCustomContainer] =
useState<HTMLDivElement | null>(null);

return (
<div style={{ display: 'flex' }}>
<VisCanvas
abscissaConfig={{ visDomain: [0, 3], showGrid: true }}
ordinateConfig={{ visDomain: [50, 100], showGrid: true }}
>
<DefaultInteractions />
<Html {...args} container={container}>
<div
ref={(elem) => setPortalTarget(elem || undefined)}
style={{
position: 'absolute',
top: 0,
left: 0,
padding: '0.5rem',
border: '3px solid blue',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
}}
>
<p>
This <code>div</code> is rendered in a custom container{' '}
<strong>next to</strong> <code>VisCanvas</code>.
</p>
</div>
</Html>

{portalTarget && (
<Html>
{createPortal(
<p>
This paragraph appears in the same <code>div</code> but is
rendered with a separate <code>Html</code> element and a
portal.
</p>,
portalTarget,
)}
</Html>
)}
</VisCanvas>
<>
<Html>{containerMounted && <div ref={setCustomContainer} />}</Html>
<Html>
{customContainer &&
createPortal(
<p
style={{
position: 'absolute',
top: 30,
left: 40,
width: '35em',
padding: '0.5rem',
border: '3px solid blue',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
textAlign: 'center',
}}
>
This example demonstrates that, using a portal, children of{' '}
<code>Html</code> can be rendered into a{' '}
<strong>custom container</strong> (itself potentially rendered
with <code>Html</code>).
</p>,
customContainer,
)}
</Html>

<div ref={(elem) => elem && setContainer(elem)} />
</div>
<FloatingControl>
<button type="button" onClick={() => toggleContainer()}>
{customContainer ? 'Unmount' : 'Mount'} container
</button>
</FloatingControl>
</>
);
},
argTypes: {
Expand Down
50 changes: 23 additions & 27 deletions packages/lib/src/vis/shared/Html.tsx
Original file line number Diff line number Diff line change
@@ -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<Props>) {
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;
}
Expand Down
16 changes: 6 additions & 10 deletions packages/lib/src/vis/shared/VisCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ function VisCanvas(props: PropsWithChildren<Props>) {
})
: NO_OFFSETS;

const [svgOverlay, setSvgOverlay] = useState<SVGSVGElement>();
const [floatingToolbar, setFloatingToolbar] = useState<HTMLDivElement>();
const [svgOverlay, setSvgOverlay] = useState<SVGSVGElement | null>(null);
const [floatingToolbar, setFloatingToolbar] = useState<HTMLDivElement | null>(
null,
);

return (
<div
Expand Down Expand Up @@ -84,16 +86,10 @@ function VisCanvas(props: PropsWithChildren<Props>) {
</VisCanvasProvider>

<Html>
<svg
ref={(elem) => setSvgOverlay(elem || undefined)}
className={styles.svgOverlay}
/>
<svg ref={setSvgOverlay} className={styles.svgOverlay} />
</Html>
<Html>
<div
ref={(elem) => setFloatingToolbar(elem || undefined)}
className={styles.floatingToolbar}
/>
<div ref={setFloatingToolbar} className={styles.floatingToolbar} />
</Html>
</R3FCanvas>
</div>
Expand Down
Loading

0 comments on commit 633f88f

Please sign in to comment.