Skip to content

Commit 552cac1

Browse files
committed
feat(editor): add inspector controllers
1 parent 26da903 commit 552cac1

4 files changed

Lines changed: 940 additions & 0 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { useCurrentEditor } from '@tiptap/react';
2+
import { useEmailTheming } from '../../../plugins/email-theming/extension';
3+
import {
4+
EDITOR_THEMES,
5+
SUPPORTED_CSS_PROPERTIES,
6+
} from '../../../plugins/email-theming/themes';
7+
import type {
8+
KnownCssProperties,
9+
KnownThemeComponents,
10+
PanelGroup,
11+
PanelSectionId,
12+
} from '../../../plugins/email-theming/types';
13+
import { PropertyGroups } from '../components/property-groups';
14+
import { Text } from '../primitives';
15+
import { DevToolPanel } from '../sections/devtools';
16+
17+
/**
18+
* Ensures every section shows all of its theme-default properties.
19+
*
20+
* For each group in the current styles, we look up the matching group from the
21+
* theme definition. Any property present in the theme default but missing from
22+
* the stored data is added with:
23+
* - `number` inputs → `value: ''` + `placeholder` showing the default
24+
* - everything else → `value` set to the theme default value
25+
*/
26+
function ensureAllProperties(
27+
currentStyles: PanelGroup[],
28+
themeDefaults: PanelGroup[],
29+
): PanelGroup[] {
30+
return currentStyles.map((group) => {
31+
const defaultGroup = themeDefaults.find((g) =>
32+
group.id ? g.id === group.id : g.title === group.title,
33+
);
34+
35+
if (!defaultGroup || defaultGroup.inputs.length === 0) {
36+
return group;
37+
}
38+
39+
const existingProps = new Set(
40+
group.inputs.map((i) => `${i.classReference}:${i.prop}`),
41+
);
42+
43+
const missingInputs = defaultGroup.inputs
44+
.filter(
45+
(defaultInput) =>
46+
!existingProps.has(
47+
`${defaultInput.classReference}:${defaultInput.prop}`,
48+
),
49+
)
50+
.map((defaultInput) => {
51+
const propDef = SUPPORTED_CSS_PROPERTIES[defaultInput.prop];
52+
53+
if (propDef && propDef.type === 'number') {
54+
return {
55+
...defaultInput,
56+
value: '' as string | number,
57+
placeholder: String(propDef.defaultValue),
58+
};
59+
}
60+
61+
return { ...defaultInput };
62+
});
63+
64+
if (missingInputs.length === 0) {
65+
return group;
66+
}
67+
68+
return {
69+
...group,
70+
inputs: [...group.inputs, ...missingInputs],
71+
};
72+
});
73+
}
74+
75+
export function InspectorGlobal({
76+
showSectionIds,
77+
}: {
78+
showSectionIds?: PanelSectionId[];
79+
}) {
80+
const { editor } = useCurrentEditor();
81+
const theming = useEmailTheming(editor);
82+
83+
if (!editor || !theming) {
84+
return null;
85+
}
86+
87+
function handleChange(content: PanelGroup[]) {
88+
// Update only the editor; the Editor update hook will sync the context
89+
editor?.commands.setGlobalContent('styles', content);
90+
}
91+
92+
function resetStyles() {
93+
// Update only the editor; the Editor update hook will sync the context
94+
editor?.commands.setGlobalContent('styles', EDITOR_THEMES[theming!.theme]);
95+
}
96+
97+
/**
98+
* Pure function: apply a single property change to a styles array and
99+
* return the new array. Does NOT call `handleChange` — callers decide
100+
* when to flush.
101+
*/
102+
function applyStyleChange(
103+
styles: PanelGroup[],
104+
{
105+
classReference,
106+
prop,
107+
newValue,
108+
}: {
109+
classReference?: string;
110+
prop: string;
111+
newValue: string | number;
112+
},
113+
): PanelGroup[] {
114+
let found = false;
115+
116+
// First pass: try to update an existing input in the stored styles
117+
const updatedStyles = styles.map((styleGroup) => {
118+
const matchingInput = styleGroup.inputs.find(
119+
(input) =>
120+
input.classReference === classReference && input.prop === prop,
121+
);
122+
123+
if (matchingInput) {
124+
found = true;
125+
return {
126+
...styleGroup,
127+
inputs: styleGroup.inputs.map((input) => {
128+
if (
129+
input.classReference === classReference &&
130+
input.prop === prop
131+
) {
132+
return { ...input, value: newValue };
133+
}
134+
return input;
135+
}),
136+
};
137+
}
138+
139+
return styleGroup;
140+
});
141+
142+
if (found) {
143+
return updatedStyles;
144+
}
145+
146+
// Second pass: if the property wasn't in the stored data yet, add it to
147+
// the matching group (upsert). This handles "filled-in" default properties
148+
// that the user is setting for the first time.
149+
const propDef =
150+
SUPPORTED_CSS_PROPERTIES[prop as KnownCssProperties] ?? null;
151+
152+
return updatedStyles.map((styleGroup) => {
153+
if (styleGroup.classReference !== classReference) {
154+
return styleGroup;
155+
}
156+
157+
// Try to pull metadata from the theme defaults so we get the right
158+
// label / type / unit for this property.
159+
const themeDefaults = EDITOR_THEMES[theming!.theme];
160+
const defaultGroup = themeDefaults.find((g) =>
161+
styleGroup.id ? g.id === styleGroup.id : g.title === styleGroup.title,
162+
);
163+
const defaultInput = defaultGroup?.inputs.find(
164+
(i) => i.prop === prop && i.classReference === classReference,
165+
);
166+
167+
if (defaultInput) {
168+
return {
169+
...styleGroup,
170+
inputs: [...styleGroup.inputs, { ...defaultInput, value: newValue }],
171+
};
172+
}
173+
174+
// Fallback: build the input from SUPPORTED_CSS_PROPERTIES
175+
if (propDef) {
176+
return {
177+
...styleGroup,
178+
inputs: [
179+
...styleGroup.inputs,
180+
{
181+
label: propDef.label,
182+
type: propDef.type,
183+
value: newValue,
184+
prop: prop as KnownCssProperties,
185+
classReference: classReference as
186+
| KnownThemeComponents
187+
| undefined,
188+
unit: propDef.unit,
189+
options: propDef.options,
190+
},
191+
],
192+
};
193+
}
194+
195+
return styleGroup;
196+
});
197+
}
198+
199+
function onChangeValue(change: {
200+
classReference?: string;
201+
prop: string;
202+
newValue: string | number;
203+
}) {
204+
handleChange(applyStyleChange(theming!.styles, change));
205+
}
206+
207+
function onBatchChangeValue(
208+
changes: Array<{
209+
classReference?: string;
210+
prop: string;
211+
newValue: string | number;
212+
}>,
213+
) {
214+
let styles: PanelGroup[] = theming!.styles;
215+
for (const change of changes) {
216+
styles = applyStyleChange(styles, change);
217+
}
218+
handleChange(styles);
219+
}
220+
221+
const themeDefaults = EDITOR_THEMES[theming!.theme];
222+
223+
const groups = ensureAllProperties(theming.styles, themeDefaults).filter(
224+
(group) => {
225+
if (!showSectionIds) {
226+
return true;
227+
}
228+
229+
return group.id
230+
? showSectionIds.includes(group.id as PanelSectionId)
231+
: false;
232+
},
233+
);
234+
235+
return (
236+
<>
237+
<PropertyGroups
238+
renderTree={groups}
239+
onChange={onChangeValue}
240+
onBatchChange={onBatchChangeValue}
241+
showSectionTitles={false}
242+
/>
243+
244+
<div className="flex-1 mt-8" />
245+
246+
<Text
247+
color="white"
248+
weight="bold"
249+
className="flex flex-row items-center justify-between gap-3 pb-5"
250+
>
251+
Global CSS
252+
</Text>
253+
254+
{process.env.NODE_ENV === 'development' && (
255+
<DevToolPanel resetStyles={resetStyles} />
256+
)}
257+
</>
258+
);
259+
}

0 commit comments

Comments
 (0)