Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f6842b3
wip
gabrielmfern Mar 23, 2026
109a0c5
move things around
gabrielmfern Mar 23, 2026
fe3f7dd
feat(editor): add render-props Inspector.Panel API for global inspector
gabrielmfern Mar 23, 2026
799ecc8
docs(editor): add Inspector USAGE.md and update breadcrumb/root
gabrielmfern Mar 24, 2026
cb2526a
lint
gabrielmfern Mar 24, 2026
11b5571
add proper default field
gabrielmfern Mar 24, 2026
c20bdcf
add global inspector example
gabrielmfern Mar 24, 2026
a6f023f
fix doc focusing
gabrielmfern Mar 24, 2026
69a85ad
use color picker input
gabrielmfern Mar 24, 2026
63dc6fe
updates
gabrielmfern Mar 24, 2026
1cf1bac
write some notes
gabrielmfern Mar 24, 2026
5eff083
rename to explorations for different iterations and comparisons
gabrielmfern Mar 25, 2026
4fe9b35
iterate
gabrielmfern Mar 25, 2026
e4120af
add classReference (unsure about this)
gabrielmfern Mar 25, 2026
4e874b5
more explorations, initial implementation of third exploration
gabrielmfern Mar 25, 2026
8d7f1bf
improve exports for theming
gabrielmfern Mar 25, 2026
b903b26
add an exmaple for document inspecting only
gabrielmfern Mar 25, 2026
11d96d3
remove stale global inspector
gabrielmfern Mar 25, 2026
2b10d0b
lint
gabrielmfern Mar 26, 2026
461ad34
add typings
gabrielmfern Mar 26, 2026
c2948bd
remove key from breadcrumb segments
gabrielmfern Mar 26, 2026
5dae2cf
add htmlFor on labels
gabrielmfern Mar 26, 2026
66ee7d3
remove old API
gabrielmfern Mar 26, 2026
ee996cb
update bradcrumb API
gabrielmfern Mar 26, 2026
8d31777
improve findStyleValue
gabrielmfern Mar 26, 2026
4bd56e0
useId for label htmlFors
gabrielmfern Mar 26, 2026
e8f279c
use setGlobalStyles
gabrielmfern Mar 26, 2026
064a4c4
improvements, add EmailTheming to example 🤦‍♂️
gabrielmfern Mar 26, 2026
846c568
add example back into index
gabrielmfern Mar 26, 2026
8b2e358
fix types for email theming
gabrielmfern Mar 27, 2026
89a73e4
remove hooks that we're not using anymore
gabrielmfern Mar 27, 2026
3213262
make it so the editor.isFocused is merged with the focus on the inspe…
gabrielmfern Mar 27, 2026
ca59250
make it resemble Resend's document inspector
gabrielmfern Mar 30, 2026
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
259 changes: 259 additions & 0 deletions packages/editor/examples/src/examples/document-inspector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { StarterKit } from '@react-email/editor/extensions';
import { EmailTheming } from '@react-email/editor/plugins';
import { Inspector } from '@react-email/editor/ui';
import { EditorContent, EditorContext, useEditor } from '@tiptap/react';
import { useId } from 'react';

const extensions = [StarterKit, EmailTheming];

const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This example uses Inspector.Provider and Inspector.Document to render document-level style properties through a render-props API.',
},
],
},
],
};

export function DocumentInspector() {
const editor = useEditor({
extensions,
content,
});

return (
<div>
<p className="text-sm text-(--re-text-muted) mb-4">
Using <code>Inspector.Provider</code> and{' '}
<code>Inspector.Document</code> to render document-level global styles
with predefined sections and a render-props API.
</p>
<EditorContext.Provider value={{ editor }}>
<div className="flex gap-4 border border-(--re-border) rounded-xl min-h-75">
<div className="flex-1 p-4">
<EditorContent editor={editor} />
</div>

<aside className="w-80 border-l border-(--re-border) p-4 flex flex-col gap-4 overflow-y-auto">
<Inspector.Provider>
<nav>
<ol className="flex items-center gap-1 text-xs list-none m-0 p-0">
<Inspector.Breadcrumb>
{(segments) =>
segments.map((segment, i) => (
<li key={i} className="flex items-center gap-1">
{i !== 0 && (
<span className="text-(--re-text-muted)">/</span>
)}
<button
type="button"
data-clickable={i !== segments.length - 1}
className="bg-transparent border-0 data-clickable:cursor-pointer text-(--re-text) p-0 text-xs data-clickable:hover:underline"
onClick={() => segment.focus()}
>
{segment.node?.nodeType ?? 'Layout'}
</button>
</li>
))
}
</Inspector.Breadcrumb>
</ol>
</nav>

<Inspector.Document>
{({ findStyleValue, setGlobalStyle, batchSetGlobalStyle }) => (
<div className="flex flex-col gap-4">
{/* Background */}
<Section title="Background">
<ColorRow
label="Color"
value={findStyleValue('body', 'backgroundColor')}
onChange={(v) =>
setGlobalStyle('body', 'backgroundColor', v)
}
/>
<NumberRow
label="Padding"
unit="px"
value={findStyleValue('body', 'padding')}
onChange={(v) =>
setGlobalStyle('body', 'padding', v)
}
/>
</Section>

{/* Body */}
<Section title="Body">
<ColorRow
label="Color"
value={findStyleValue('container', 'backgroundColor')}
onChange={(v) =>
setGlobalStyle('container', 'backgroundColor', v)
}
/>
<NumberRow
label="Width"
unit="px"
value={findStyleValue('container', 'width')}
onChange={(v) =>
setGlobalStyle('container', 'width', v)
}
/>
<NumberRow
label="Height"
unit="px"
value={findStyleValue('container', 'height')}
onChange={(v) =>
setGlobalStyle('container', 'height', v)
}
/>
<NumberRow
label="Padding"
unit="px"
value={findStyleValue('container', 'padding')}
onChange={(v) =>
setGlobalStyle('container', 'padding', v)
}
/>
<NumberRow
label="Rounded"
unit="px"
value={findStyleValue('container', 'borderRadius')}
onChange={(v) =>
setGlobalStyle('container', 'borderRadius', v)
}
/>
<NumberRow
label="Border"
unit="px"
value={findStyleValue('container', 'borderWidth')}
onChange={(v) =>
setGlobalStyle('container', 'borderWidth', v)
}
/>
<ColorRow
label=""
value={findStyleValue('container', 'borderColor')}
onChange={(v) =>
setGlobalStyle('container', 'borderColor', v)
}
/>
</Section>
</div>
)}
</Inspector.Document>
</Inspector.Provider>
</aside>
</div>
</EditorContext.Provider>
</div>
);
}

/* ------------------------------------------------------------------ */
/* Small presentational helpers for the predefined sections */
/* ------------------------------------------------------------------ */

function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="border-t border-(--re-border) pt-3 first:border-0 first:pt-0">
<h3 className="text-xs font-semibold text-(--re-text) m-0 mb-2">
{title}
</h3>
<div className="flex flex-col gap-2">{children}</div>
</section>
);
}

function ColorRow({
label,
value,
onChange,
}: {
label: string;
value: string | number | undefined;
onChange: (v: string) => void;
}) {
console.log(label, value);
const strValue = String(value);
const id = useId();
return (
<div className="flex items-center justify-between gap-2">
<label htmlFor={id} className="text-xs text-(--re-text-muted) min-w-20">
{label}
</label>
<span className="flex items-center gap-1">
<input
id={id}
type="color"
value={normalizeHex(strValue)}
onChange={(e) => onChange(e.target.value)}
className="w-6 h-6 border-0 p-0 cursor-pointer"
/>
<input
type="text"
value={strValue}
onChange={(e) => onChange(e.target.value)}
className="w-20 text-xs bg-transparent border border-(--re-border) rounded px-1.5 py-1 text-(--re-text)"
/>
</span>
</div>
);
}

function NumberRow({
label,
value,
unit,
onChange,
}: {
label: string;
value: string | number | undefined;
unit?: string;
onChange: (v: number | '') => void;
}) {
const id = useId();
return (
<div className="flex items-center justify-between gap-2">
<label htmlFor={id} className="text-xs text-(--re-text-muted) min-w-20">
{label}
</label>
<span className="flex items-center gap-1">
<input
id={id}
type="number"
value={value}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === '' ? '' : Number.parseFloat(raw));
}}
className="w-16 text-xs bg-transparent border border-(--re-border) rounded px-1.5 py-1 text-(--re-text)"
/>
{unit && <span className="text-xs text-(--re-text-muted)">{unit}</span>}
</span>
</div>
);
}

function normalizeHex(value: string): string {
if (!value) return '#000000';
const v = value.trim();
const shortHex = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(v);
if (shortHex) {
return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`;
}
if (/^#[0-9a-f]{6}$/i.test(v)) return v;
return '#000000';
}
6 changes: 6 additions & 0 deletions packages/editor/examples/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BubbleMenuExample } from './bubble-menu';
import { ColumnLayouts } from './column-layouts';
import { CustomBubbleMenu } from './custom-bubble-menu';
import { CustomExtensions } from './custom-extensions';
import { DocumentInspector } from './document-inspector';
import { EmailExport } from './email-export';
import { EmailThemingExample } from './email-theming';
import { FullEmailBuilder } from './full-email-builder';
Expand Down Expand Up @@ -95,6 +96,11 @@ export const sections: ExampleSection[] = [
label: 'Full Email Builder',
component: FullEmailBuilder,
},
{
id: 'document-inspector',
label: 'Document Inspector',
component: DocumentInspector,
},
],
},
];
6 changes: 4 additions & 2 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"./styles/button-bubble-menu.css": "./dist/ui/button-bubble-menu/button-bubble-menu.css",
"./styles/image-bubble-menu.css": "./dist/ui/image-bubble-menu/image-bubble-menu.css",
"./styles/slash-command.css": "./dist/ui/slash-command/slash-command.css",
"./styles/inspector.css": "./dist/ui/inspector/inspector.css",
"./themes/default.css": "./dist/ui/themes/default.css"
},
"license": "MIT",
Expand Down Expand Up @@ -106,9 +107,10 @@
},
"dependencies": {
"@floating-ui/react-dom": "^2.1.8",
"@radix-ui/react-slot": "catalog:",
"@tiptap/core": "^3.17.1",
"@tiptap/extension-blockquote": "^3.17.1",
"@tiptap/extension-bold": "^3.20.1",
"@tiptap/extension-bold": "^3.17.1",
"@tiptap/extension-bullet-list": "^3.17.1",
"@tiptap/extension-code": "^3.17.1",
"@tiptap/extension-code-block": "^3.17.1",
Expand All @@ -117,14 +119,14 @@
"@tiptap/extension-horizontal-rule": "^3.17.1",
"@tiptap/extension-italic": "^3.17.1",
"@tiptap/extension-link": "^3.17.1",
"@tiptap/extension-text": "^3.17.1",
"@tiptap/extension-list-item": "^3.17.1",
"@tiptap/extension-mention": "^3.17.1",
"@tiptap/extension-ordered-list": "^3.17.1",
"@tiptap/extension-paragraph": "^3.17.1",
"@tiptap/extension-placeholder": "^3.17.1",
"@tiptap/extension-strike": "^3.17.1",
"@tiptap/extension-superscript": "^3.17.1",
"@tiptap/extension-text": "^3.17.1",
"@tiptap/extension-underline": "^3.17.1",
"@tiptap/extensions": "^3.17.1",
"@tiptap/html": "^3.17.1",
Expand Down
14 changes: 14 additions & 0 deletions packages/editor/src/core/event-bus.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect } from 'react';

const EVENT_PREFIX = '@react-email/editor:';

/**
Expand Down Expand Up @@ -46,6 +48,18 @@ class EditorEventBus {
target.dispatchEvent(event);
}

useEvent<T extends EditorEventName>(
eventName: T,
handler: EditorEventHandler<T>,
options?: AddEventListenerOptions & { target?: EventTarget },
) {
useEffect(() => {
const subscription = this.on(eventName, handler, options);

return () => subscription.unsubscribe();
}, [eventName, handler, options]);
}

on<T extends EditorEventName>(
eventName: T,
handler: EditorEventHandler<T>,
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export * from './is-document-visually-empty';
export * from './serializer/compose-react-email';
export * from './serializer/email-mark';
export * from './serializer/email-node';
export * from './types';
export * from './use-editor';
15 changes: 5 additions & 10 deletions packages/editor/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

import type { Attrs } from '@tiptap/pm/model';
import type { EditorEventMap, EditorEventName } from './event-bus';

export type NodeClickedEvent = {
nodeType: string;
Expand Down Expand Up @@ -42,18 +43,12 @@ export type CustomPlaceholder = {
fallback_value?: string | null;
};

/**
* Event map for the editor event bus.
*/
export interface EditorEventMap {
'node-clicked': NodeClickedEvent;
declare module './event-bus' {
export interface EditorEventMap {
'node-clicked': NodeClickedEvent;
}
}

/**
* Available event names in the editor event bus.
*/
export type EditorEventName = keyof EditorEventMap;

/**
* Event handler function type.
*/
Expand Down
Loading
Loading