From 92fd633adc6e0247b020db3dbdb417c951fae403 Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Mon, 13 Jul 2026 17:14:14 -0300 Subject: [PATCH 1/4] feat(ui): move the props editor into a panel with a highlighted JSON editor The props editor moves out of the bottom toolbar - whose tabs are diagnoses, not inputs, and whose drawer left almost no room to edit - into a collapsible panel docked right of the preview canvas, toggled from the topbar. The panel insets between the topbar and toolbar so neither is displaced, stays open across template navigation (its state lives in a provider mounted in the preview layout and persists per workspace), and only animates on explicit toggles. The editing surface is a syntax-highlighted JSON editor: a transparent textarea overlaid on a prism-react-renderer render sharing the source view's theme, adding no dependency. Valid JSON re-renders the preview live, invalid JSON shows the parse error inline and leaves the preview on the last valid render, and values are deliberately not validated against the component - wrong values break the render, which the error overlay surfaces. Static builds show the props read-only. Stale ?toolbar-panel=props links coerce to a closed toolbar. --- .changeset/props-editor-redesign.md | 6 + .../ui/src/app/preview/[...slug]/preview.tsx | 230 ++++++++++-------- packages/ui/src/app/preview/layout.tsx | 9 + packages/ui/src/components/code.tsx | 4 +- .../src/components/icons/icon-panel-right.tsx | 19 ++ packages/ui/src/components/json-editor.tsx | 71 ++++++ .../src/components/preview-props-editor.tsx | 102 ++++++++ .../ui/src/components/preview-props-panel.tsx | 29 +++ packages/ui/src/components/toolbar.tsx | 42 +--- .../ui/src/components/toolbar/copy-for-ai.tsx | 1 - .../toolbar/preview-props-editor.tsx | 102 -------- .../components/topbar/props-panel-toggle.tsx | 31 +++ packages/ui/src/contexts/props-panel.tsx | 70 ++++++ 13 files changed, 476 insertions(+), 240 deletions(-) create mode 100644 .changeset/props-editor-redesign.md create mode 100644 packages/ui/src/app/preview/layout.tsx create mode 100644 packages/ui/src/components/icons/icon-panel-right.tsx create mode 100644 packages/ui/src/components/json-editor.tsx create mode 100644 packages/ui/src/components/preview-props-editor.tsx create mode 100644 packages/ui/src/components/preview-props-panel.tsx delete mode 100644 packages/ui/src/components/toolbar/preview-props-editor.tsx create mode 100644 packages/ui/src/components/topbar/props-panel-toggle.tsx create mode 100644 packages/ui/src/contexts/props-panel.tsx diff --git a/.changeset/props-editor-redesign.md b/.changeset/props-editor-redesign.md new file mode 100644 index 0000000000..cb39ee06cc --- /dev/null +++ b/.changeset/props-editor-redesign.md @@ -0,0 +1,6 @@ +--- +'@react-email/ui': minor +'react-email': 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
+//