Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/props-editor-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@react-email/ui': minor
Comment thread
linear-code[bot] marked this conversation as resolved.
---

The preview props editor now lives in a collapsible panel beside the preview, with a syntax-highlighted JSON editor. Static builds show the props read-only.
230 changes: 126 additions & 104 deletions packages/ui/src/app/preview/[...slug]/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Toaster } from 'sonner';
import { useDebouncedCallback } from 'use-debounce';
import { Topbar } from '../../../components';
import { CodeContainer } from '../../../components/code-container';
import { PreviewPropsPanel } from '../../../components/preview-props-panel';
import {
makeIframeDocumentBubbleEvents,
ResizableWrapper,
Expand All @@ -16,8 +17,10 @@ import { useToolbarState } from '../../../components/toolbar';
import { Tooltip } from '../../../components/tooltip';
import { ActiveViewToggleGroup } from '../../../components/topbar/active-view-toggle-group';
import { EmulatedDarkModeToggle } from '../../../components/topbar/emulated-dark-mode-toggle';
import { PropsPanelToggle } from '../../../components/topbar/props-panel-toggle';
import { ViewSizeControls } from '../../../components/topbar/view-size-controls';
import { usePreviewContext } from '../../../contexts/preview';
import { usePropsPanel } from '../../../contexts/props-panel';
import { useClampedState } from '../../../hooks/use-clamped-state';
import { cn } from '../../../utils';
import { inferEmailTitle } from '../../../utils/infer-email-title';
Expand Down Expand Up @@ -104,6 +107,9 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {

const { toggled: toolbarToggled } = useToolbarState();

const { open: propsPanelOpen, setOpen: handlePropsPanelChange } =
usePropsPanel();

return (
<>
<Topbar emailTitle={emailTitle}>
Expand Down Expand Up @@ -137,6 +143,12 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
activeView={activeView}
setActiveView={handleViewChange}
/>
{hasRenderingMetadata && !isRawHtmlEmail ? (
<PropsPanelToggle
onChange={handlePropsPanelChange}
open={propsPanelOpen}
/>
) : null}
{hasRenderingMetadata ? (
<div className="flex justify-end">
<Send
Expand All @@ -150,121 +162,131 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => {
</Topbar>

<div
{...props}
className={cn(
'h-[calc(100%-3.5rem-2.375rem)] will-change-[height] flex p-4 transition-[height] duration-300 relative',
activeView === 'preview' && 'bg-gray-200',
activeView === 'preview' && isDarkModeEnabled && 'bg-gray-400',
'flex h-[calc(100%-3.5rem-2.375rem)] will-change-[height] transition-[height] duration-300',
toolbarToggled && 'h-[calc(100%-3.5rem-13rem)]',
className,
)}
ref={(element) => {
const observer = new ResizeObserver((entry) => {
const [elementEntry] = entry;
if (elementEntry) {
setMaxWidth(elementEntry.contentRect.width);
setMaxHeight(elementEntry.contentRect.height);
>
<div
{...props}
className={cn(
'relative flex min-w-0 grow p-4',
activeView === 'preview' && 'bg-gray-200',
activeView === 'preview' && isDarkModeEnabled && 'bg-gray-400',
className,
)}
ref={(element) => {
const observer = new ResizeObserver((entry) => {
const [elementEntry] = entry;
if (elementEntry) {
setMaxWidth(elementEntry.contentRect.width);
setMaxHeight(elementEntry.contentRect.height);
}
});

if (element) {
observer.observe(element);
}
});

if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
};
}}
>
{hasErrors ? <ErrorOverlay error={renderingResult.error} /> : null}

return () => {
observer.disconnect();
};
}}
>
{hasErrors ? <ErrorOverlay error={renderingResult.error} /> : null}

{hasRenderingMetadata ? (
<>
{activeView === 'preview' && (
<ResizableWrapper
minHeight={minHeight}
minWidth={minWidth}
maxHeight={maxHeight}
maxWidth={maxWidth}
height={height}
onResizeEnd={() => {
handleSaveViewSize();
}}
onResize={(value, direction) => {
const isHorizontal =
direction === 'east' || direction === 'west';
if (isHorizontal) {
setWidth(Math.round(value));
} else {
setHeight(Math.round(value));
}
}}
width={width}
>
<EmailFrame
className="max-h-full rounded-lg bg-white [color-scheme:auto]"
darkMode={isDarkModeEnabled}
markup={renderedEmailMetadata.markup}
width={width}
{hasRenderingMetadata ? (
<>
{activeView === 'preview' && (
<ResizableWrapper
minHeight={minHeight}
minWidth={minWidth}
maxHeight={maxHeight}
maxWidth={maxWidth}
height={height}
title={emailTitle}
ref={(iframe) => {
if (!iframe) return;

return makeIframeDocumentBubbleEvents(iframe);
onResizeEnd={() => {
handleSaveViewSize();
}}
onResize={(value, direction) => {
const isHorizontal =
direction === 'east' || direction === 'west';
if (isHorizontal) {
setWidth(Math.round(value));
} else {
setHeight(Math.round(value));
}
}}
/>
</ResizableWrapper>
)}
width={width}
>
<EmailFrame
className="max-h-full rounded-lg bg-white [color-scheme:auto]"
darkMode={isDarkModeEnabled}
markup={renderedEmailMetadata.markup}
width={width}
height={height}
title={emailTitle}
ref={(iframe) => {
if (!iframe) return;

return makeIframeDocumentBubbleEvents(iframe);
}}
/>
</ResizableWrapper>
)}

{activeView === 'source' && (
<div className="h-full w-full">
<div className="m-auto h-full flex max-w-3xl p-6">
<Tooltip.Provider>
<CodeContainer
activeLang={activeLang}
basename={renderedEmailMetadata.basename}
markups={
isRawHtmlEmail
? [
{
language: 'html',
extension: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]
: [
{
language: 'tsx',
extension: renderedEmailMetadata.extname,
content: renderedEmailMetadata.reactMarkup,
},
{
language: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]
}
setActiveLang={handleLangChange}
/>
</Tooltip.Provider>
{activeView === 'source' && (
<div className="h-full w-full">
<div className="m-auto h-full flex max-w-3xl p-6">
<Tooltip.Provider>
<CodeContainer
activeLang={activeLang}
basename={renderedEmailMetadata.basename}
markups={
isRawHtmlEmail
? [
{
language: 'html',
extension: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]
: [
{
language: 'tsx',
extension: renderedEmailMetadata.extname,
content: renderedEmailMetadata.reactMarkup,
},
{
language: 'html',
content: renderedEmailMetadata.prettyMarkup,
},
{
language: 'markdown',
extension: 'md',
content: renderedEmailMetadata.plainText,
},
]
}
setActiveLang={handleLangChange}
/>
</Tooltip.Provider>
</div>
</div>
</div>
)}
</>
) : null}
)}
</>
) : null}

<Toaster />
<Toaster />
</div>

{hasRenderingMetadata && !isRawHtmlEmail ? (
<PreviewPropsPanel open={propsPanelOpen} />
) : null}
</div>
</>
);
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/app/preview/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PropsPanelProvider } from '../../contexts/props-panel';

export default function PreviewLayout({
children,
}: {
children: React.ReactNode;
}) {
return <PropsPanelProvider>{children}</PropsPanelProvider>;
}
4 changes: 2 additions & 2 deletions packages/ui/src/components/code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface CodeProps {
language?: Language;
}

const theme = {
export const codeTheme = {
plain: {
color: '#EDEDEF',
fontSize: 13,
Expand Down Expand Up @@ -93,7 +93,7 @@ export const Code: React.FC<Readonly<CodeProps>> = ({
const value = children.trim();

return (
<Highlight code={value} language={language} theme={theme}>
<Highlight code={value} language={language} theme={codeTheme}>
{({ tokens, getLineProps, getTokenProps }) => (
<>
<div
Expand Down
19 changes: 19 additions & 0 deletions packages/ui/src/components/icons/icon-panel-right.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as React from 'react';
import type { IconElement, IconProps } from './icon-base';
import { IconBase } from './icon-base';

export const IconPanelRight = React.forwardRef<
IconElement,
Readonly<IconProps>
>(({ ...props }, forwardedRef) => (
<IconBase ref={forwardedRef} {...props}>
<path
clipRule="evenodd"
d="M5 2.25H19C20.5188 2.25 21.75 3.48122 21.75 5V19C21.75 20.5188 20.5188 21.75 19 21.75L5 21.75C3.48122 21.75 2.25 20.5188 2.25 19L2.25 5C2.25 3.48122 3.48122 2.25 5 2.25ZM5 20.25C4.30964 20.25 3.75 19.6904 3.75 19L3.75 5C3.75 4.30964 4.30964 3.75 5 3.75L14.25 3.75L14.25 20.25L5 20.25ZM15.75 20.25L15.75 3.75L19 3.75C19.6904 3.75 20.25 4.30965 20.25 5L20.25 19C20.25 19.6904 19.6904 20.25 19 20.25H15.75Z"
fill="currentColor"
fillRule="evenodd"
/>
</IconBase>
));

IconPanelRight.displayName = 'IconPanelRight';
Loading
Loading