diff --git a/.changeset/props-editor-redesign.md b/.changeset/props-editor-redesign.md new file mode 100644 index 0000000000..583c6d8f08 --- /dev/null +++ b/.changeset/props-editor-redesign.md @@ -0,0 +1,5 @@ +--- +'@react-email/ui': minor +--- + +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. diff --git a/packages/ui/src/app/preview/[...slug]/preview.tsx b/packages/ui/src/app/preview/[...slug]/preview.tsx index 645fdba089..3eb78e4154 100644 --- a/packages/ui/src/app/preview/[...slug]/preview.tsx +++ b/packages/ui/src/app/preview/[...slug]/preview.tsx @@ -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, @@ -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'; @@ -104,6 +107,9 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => { const { toggled: toolbarToggled } = useToolbarState(); + const { open: propsPanelOpen, setOpen: handlePropsPanelChange } = + usePropsPanel(); + return ( <> @@ -137,6 +143,12 @@ const Preview = ({ emailTitle, className, ...props }: PreviewProps) => { activeView={activeView} setActiveView={handleViewChange} /> + {hasRenderingMetadata && !isRawHtmlEmail ? ( + + ) : null} {hasRenderingMetadata ? (
{
{ - const observer = new ResizeObserver((entry) => { - const [elementEntry] = entry; - if (elementEntry) { - setMaxWidth(elementEntry.contentRect.width); - setMaxHeight(elementEntry.contentRect.height); + > +
{ + 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 ? : null} - return () => { - observer.disconnect(); - }; - }} - > - {hasErrors ? : null} - - {hasRenderingMetadata ? ( - <> - {activeView === 'preview' && ( - { - handleSaveViewSize(); - }} - onResize={(value, direction) => { - const isHorizontal = - direction === 'east' || direction === 'west'; - if (isHorizontal) { - setWidth(Math.round(value)); - } else { - setHeight(Math.round(value)); - } - }} - width={width} - > - + {activeView === 'preview' && ( + { - 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)); + } }} - /> - - )} + width={width} + > + { + if (!iframe) return; + + return makeIframeDocumentBubbleEvents(iframe); + }} + /> + + )} - {activeView === 'source' && ( -
-
- - - + {activeView === 'source' && ( +
+
+ + + +
-
- )} - - ) : null} + )} + + ) : null} - + +
+ + {hasRenderingMetadata && !isRawHtmlEmail ? ( + + ) : null}
); diff --git a/packages/ui/src/app/preview/layout.tsx b/packages/ui/src/app/preview/layout.tsx new file mode 100644 index 0000000000..f9739488e7 --- /dev/null +++ b/packages/ui/src/app/preview/layout.tsx @@ -0,0 +1,9 @@ +import { PropsPanelProvider } from '../../contexts/props-panel'; + +export default function PreviewLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index d512a1571e..2fd14760f2 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -13,7 +13,7 @@ interface CodeProps { language?: Language; } -const theme = { +export const codeTheme = { plain: { color: '#EDEDEF', fontSize: 13, @@ -93,7 +93,7 @@ export const Code: React.FC> = ({ const value = children.trim(); return ( - + {({ tokens, getLineProps, getTokenProps }) => ( <>
+>(({ ...props }, forwardedRef) => ( + + + +)); + +IconPanelRight.displayName = 'IconPanelRight'; diff --git a/packages/ui/src/components/json-editor.tsx b/packages/ui/src/components/json-editor.tsx new file mode 100644 index 0000000000..3c10155617 --- /dev/null +++ b/packages/ui/src/components/json-editor.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { Highlight } from 'prism-react-renderer'; +import { cn } from '../utils'; +import { codeTheme } from './code'; + +interface JsonEditorProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + className?: string; + 'aria-label': string; + 'aria-invalid'?: boolean; +} + +// Text metrics must match between the highlighted
 and the transparent
+//