Skip to content

Commit

Permalink
Introduce HtmlPortal to render HTML elements inside custom containers
Browse files Browse the repository at this point in the history
  • Loading branch information
axelboc committed Aug 8, 2023
1 parent d912a67 commit 69a3ec2
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 153 deletions.
1 change: 1 addition & 0 deletions apps/storybook/src/Context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { visSize, dataToWorld, worldToData } = useVisCanvasContext();
| `canvasSize` | Canvas size (equivalent to `useThree((state) => state.size)`) | <code>Size</code> |
| `canvasRatio` | Canvas ratio (i.e. `width / height`) | <code>number</code> |
| `canvasBox` | [`Box`](https://h5web-docs.panosc.eu/?path=/story/utilities--page#box) spanning the canvas in HTML space | <code>Box</code> |
| `canvasWrapper` | Wrapper `div` element rendered by `VisCanvas`, which wraps React Three Fiber's own `div.r3fRoot` wrapper | <code>HTMLElement</code> |
| `visRatio` | Visualization ratio: defined when `VisCanvas` receives `aspect="equal"` or `aspect={number}` (e.g. `HeatmapVis` with "keep ratio" enabled); `undefined` otherwise | <code>number &#124; undefined</code> |
| `visSize` | Visualization size (different from canvas size when `visRatio` is defined) | <code>Size</code> |
| `abscissaConfig` | Abscissa configuration object passed to `VisCanvas` | <code>AxisConfig</code> |
Expand Down
146 changes: 74 additions & 72 deletions apps/storybook/src/Html.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,105 +14,103 @@ const meta = {
component: Html,
decorators: [FillHeight],
parameters: { layout: 'fullscreen' },
argTypes: {
container: { control: false },
},
} satisfies Meta<typeof Html>;

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

export const Default = {
render: (args) => {
const { overflowCanvas } = args;
return (
<VisCanvas
abscissaConfig={{ visDomain: [0, 3], showGrid: true }}
ordinateConfig={{ visDomain: [50, 100], showGrid: true }}
>
<DefaultInteractions />
<Html overflowCanvas={overflowCanvas}>
<Html {...args}>
<div
style={{
position: 'absolute',
top: overflowCanvas ? 45 : 30,
left: overflowCanvas ? 30 : -50,
width: '30em',
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>{' '}
This <code>div</code> element is a child of <code>VisCanvas</code>.
Wrapping it with{' '}
<strong>
{overflowCanvas ? 'overflows' : 'does not overflow'}
<code>Html</code>
</strong>{' '}
the canvas.
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>
</VisCanvas>
);
},
} 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>();

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>
function MyHtml({ children }: PropsWithChildren<object>) {
const { canvasSize } = useVisCanvasContext();

{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>

<div ref={(elem) => elem && setContainer(elem)} />
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>
);
},
argTypes: {
overflowCanvas: { control: false },
},
} satisfies Story;
{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>
);
}
82 changes: 82 additions & 0 deletions apps/storybook/src/HtmlPortal.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof HtmlPortal>;

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

export const Default = {
render: () => {
return (
<VisCanvas
abscissaConfig={{ visDomain: [0, 3], showGrid: true }}
ordinateConfig={{ visDomain: [50, 100], showGrid: true }}
>
<DefaultInteractions />
<MyHtml />
</VisCanvas>
);
},
} satisfies Story;

function MyHtml() {
const { canvasWrapper } = useVisCanvasContext();

return (
<HtmlPortal container={canvasWrapper}>
<div
style={{
position: 'absolute',
top: 40,
left: 20,
width: '36em',
padding: '0 1rem',
border: '3px solid blue',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
}}
>
<p>
While <code>Html</code> always renders its children next to the{' '}
<code>canvas</code> element,{' '}
<strong>
<code>HtmlPortal</code>
</strong>{' '}
can render its children into any given <code>container</code> element.
</p>
<p>
The two main use cases for{' '}
<strong>
<code>HtmlPortal</code>
</strong>{' '}
are:
</p>
<ul>
<li>
rendering HTML elements into <code>canvasWrapper</code> so they
overflow the canvas (like this <code>div</code> or the axes);
</li>
<li>
rendering the children of multiple <code>HtmlPortal</code> elements
inside the same container (<code>SvgElement</code> and{' '}
<code>FloatingControl</code> both rely on <code>HtmlPortal</code>{' '}
for this purpose).
</li>
</ul>
</div>
</HtmlPortal>
);
}
9 changes: 2 additions & 7 deletions packages/lib/src/interactions/svg/SvgElement.tsx
Original file line number Diff line number Diff line change
@@ -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 {}
Expand All @@ -10,11 +9,7 @@ function SvgElement(props: PropsWithChildren<Props>) {
const { children } = props;
const { svgOverlay } = useVisCanvasContext();

if (!svgOverlay) {
return null;
}

return <Html>{createPortal(children, svgOverlay)}</Html>;
return <HtmlPortal container={svgOverlay}>{children}</HtmlPortal>;
}

export type { Props as SvgElementProps };
Expand Down
9 changes: 2 additions & 7 deletions packages/lib/src/toolbar/floating/FloatingControl.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,11 +11,7 @@ function FloatingControl(props: Props) {
const { children } = props;
const { floatingToolbar } = useVisCanvasContext();

if (!floatingToolbar) {
return null;
}

return <Html>{createPortal(children, floatingToolbar)}</Html>;
return <HtmlPortal container={floatingToolbar}>{children}</HtmlPortal>;
}

export default FloatingControl;
39 changes: 22 additions & 17 deletions packages/lib/src/vis/shared/Annotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement> {
Expand Down Expand Up @@ -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)),
Expand All @@ -45,23 +46,27 @@ function Annotation(props: Props) {
scaleOnZoom ? `scale(${1 / cameraScale.x}, ${1 / cameraScale.y})` : '',
];

return (
<Html overflowCanvas={overflowCanvas}>
<div
style={{
position: 'absolute',
top: htmlPt.y,
left: htmlPt.x,
transformOrigin: scaleOnZoom && !center ? 'top left' : undefined,
transform: transforms.join(' ').trim(),
...style,
}}
{...divProps}
>
{children}
</div>
</Html>
const Elem = (
<div
style={{
position: 'absolute',
top: htmlPt.y,
left: htmlPt.x,
transformOrigin: scaleOnZoom && !center ? 'top left' : undefined,
transform: transforms.join(' ').trim(),
...style,
}}
{...divProps}
>
{children}
</div>
);

if (overflowCanvas) {
return <HtmlPortal container={canvasWrapper}>{Elem}</HtmlPortal>;
}

return <Html>{Elem}</Html>;
}

export default Annotation;
Loading

0 comments on commit 69a3ec2

Please sign in to comment.