Skip to content

Commit 62c0d33

Browse files
allilevineclaude
andauthored
Email design: set the live root font size for image captions in the canvas (#53939)
* Email design: set the live root font size for image captions The email image renderer wraps an image in `font-size: 0px` tables so mail clients leave no gap under it, and the caption cell beneath would inherit that 0px once the canvas replaces the root size with `inherit` so text can follow the Styles panel. WordPress.com sizes that cell from `var(--wpcom-email-root-font-size, <size at load>)`; this sets the variable from the live design, so a caption follows the panel without a reload. The canvas is built with the size the bundle carried, and `watchRootFontSize` republishes the editor settings' styles — which the package appends to the CSS it generates — whenever the creator changes it. A preset pick is passed through as the canvas variable rather than resolved, so the caption tracks whatever the body text shows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Email design: wait for the design record before republishing the size The package answers null for the design record until it resolves, which is a tick after the editor mounts, so the watcher's first run read the theme alone and republished a size the creator had already replaced — flashing the wrong caption size over the one the canvas opened on, then flashing back. Confirmed by probe before fixing. Also covers the two canvas rules landing in one array together, and stops the fallback assertion throwing on a style entry with no `css`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2ef0e86 commit 62c0d33

5 files changed

Lines changed: 454 additions & 11 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: enhancement
3+
4+
Email design: Follow the Styles panel's text size for image captions in the editor canvas.

‎projects/plugins/jetpack/modules/subscriptions/email-design-editor/src/index.jsx‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { store as noticesStore } from '@wordpress/notices';
3737
import { registerPlugin } from '@wordpress/plugins';
3838
import { addQueryArgs } from '@wordpress/url';
3939
import { watchDerivedTextColor } from './derived-text-color';
40+
import { bundleRootFontSize, watchRootFontSize, withRootFontSize } from './root-font-size';
4041

4142
// Declared by the Jetpack plugin on every platform, answered by WordPress.com, so
4243
// the browser calls one local URL everywhere.
@@ -191,28 +192,30 @@ export function buildEditorConfig( bundle, data ) {
191192
* Appended to `styles` rather than replacing it: WordPress.com ships the email's own stylesheet
192193
* through that same array, and the package reads the whole of it.
193194
*
194-
* The one rule so far hides the featured image. `core/post-featured-image` is a core block, so
195-
* WordPress.com deliberately leaves it out of `blocks` — sending core definitions would replace
196-
* this site's working implementations with placeholders — and no `preview_html` can reach it.
197-
* Only WordPress.com knows whether a send carries the image, so the bundle reports that instead.
195+
* One hides the featured image. `core/post-featured-image` is a core block, so WordPress.com
196+
* deliberately leaves it out of `blocks` — sending core definitions would replace this site's
197+
* working implementations with placeholders — and no `preview_html` can reach it. Only
198+
* WordPress.com knows whether a send carries the image, so the bundle reports that instead.
199+
*
200+
* The other publishes the root font size image captions are sized from, which `watchRootFontSize`
201+
* then keeps current.
198202
*
199203
* @param {object} settings - The editor settings assembled so far.
200204
* @param {object} bundle - The response from the bootstrap route.
201205
* @return {object} The settings, with any extra canvas rules appended.
202206
*/
203207
function withCanvasStyles( settings, bundle ) {
208+
const styles = withRootFontSize( settings.styles, bundleRootFontSize( bundle ) );
209+
204210
// Strictly false. A bundle from before WordPress.com reported this omits the key, and hiding
205211
// the image on that reading would be a regression on every blog that does send one.
206-
if ( false !== bundle?.shows_featured_image ) {
207-
return settings;
212+
if ( false === bundle?.shows_featured_image ) {
213+
styles.push( { css: '.wp-block-post-featured-image { display: none; }' } );
208214
}
209215

210216
return {
211217
...settings,
212-
styles: [
213-
...( Array.isArray( settings.styles ) ? settings.styles : [] ),
214-
{ css: '.wp-block-post-featured-image { display: none; }' },
215-
],
218+
styles,
216219
};
217220
}
218221

@@ -1114,6 +1117,7 @@ export async function mountEmailDesignEditor() {
11141117
const root = createRoot( container );
11151118
let stopWatchingSidebar = null;
11161119
let stopDerivingTextColor = null;
1120+
let stopWatchingRootFontSize = null;
11171121

11181122
try {
11191123
const bundle = await apiFetch( { path: bootstrapPath( data ) } );
@@ -1158,6 +1162,10 @@ export async function mountEmailDesignEditor() {
11581162
config.globalStylesPostId,
11591163
inheritedColors( bundle )
11601164
);
1165+
1166+
// Only a creator with the panel can change the size, and the canvas was built with the
1167+
// one the screen opened on.
1168+
stopWatchingRootFontSize = watchRootFontSize( bundleRootFontSize( bundle ) );
11611169
}
11621170

11631171
root.render(
@@ -1172,6 +1180,7 @@ export async function mountEmailDesignEditor() {
11721180
} catch ( error ) {
11731181
stopWatchingSidebar?.();
11741182
stopDerivingTextColor?.();
1183+
stopWatchingRootFontSize?.();
11751184

11761185
// The notice deliberately does not name which half failed; this does.
11771186
// eslint-disable-next-line no-console
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Carry the email's root font size into the canvas on the custom property captions read.
3+
*
4+
* The email image renderer wraps an image in `font-size: 0px` tables so mail clients leave no gap
5+
* under it, and the caption cell beneath would inherit that 0px once the canvas replaces the root
6+
* size with `inherit` so text can follow the Styles panel. WordPress.com therefore sizes that cell
7+
* from `var(--wpcom-email-root-font-size, <size at load>)`, and this sets the variable from the
8+
* live design so a caption follows the panel without a reload. See NL-1075 and NL-951.
9+
*/
10+
11+
import { storeName as EMAIL_EDITOR_STORE } from '@woocommerce/email-editor';
12+
import { store as coreStore } from '@wordpress/core-data';
13+
import { dispatch, select, subscribe } from '@wordpress/data';
14+
15+
// What the panel stores for a preset pick. Rewritten to the custom property rather than resolved:
16+
// the canvas defines the preset variables above the root container, so the caption then tracks
17+
// whatever the body text shows. The `var( ... )` form sanitizing rewrites it to passes through.
18+
const PRESET_SHORTHAND = /^var:preset\|font-size\|([\w-]+)$/;
19+
20+
// What may reach a declaration. A size is the creator's own input, and a `}` would end the rule
21+
// early; the same set WordPress.com validates this value against.
22+
const SAFE_VALUE = /^[a-z0-9.%(),+*/ -]+$/i;
23+
24+
// How our entry is recognised in a style array that also carries WordPress.com's email stylesheet.
25+
// Matched as a prefix, never as a substring, so no other entry declaring the property is dropped.
26+
const RULE_PREFIX = '.is-root-container{--wpcom-email-root-font-size:';
27+
28+
/**
29+
* The email's root font size, as the Styles panel's own merge resolves it.
30+
*
31+
* @param {object} theme - The editor theme WordPress.com derived from the site.
32+
* @param {object} design - The creator's own layer over it.
33+
* @return {string|null} The size, or null when neither layer sets one.
34+
*/
35+
export function rootFontSize( theme, design ) {
36+
const size = design?.styles?.typography?.fontSize ?? theme?.styles?.typography?.fontSize;
37+
38+
return 'string' === typeof size && size.trim() ? size.trim() : null;
39+
}
40+
41+
/**
42+
* The root font size the screen opens on, before the editor has a store to read.
43+
*
44+
* @param {object} bundle - The response from the bootstrap route.
45+
* @return {string|null} The size, or null when the bundle sets none.
46+
*/
47+
export function bundleRootFontSize( bundle ) {
48+
return rootFontSize( bundle?.editor_theme, bundle?.global_styles?.record );
49+
}
50+
51+
/**
52+
* The canvas rule that publishes a root font size, or nothing when there is none to publish.
53+
*
54+
* @param {*} fontSize - A stored font size.
55+
* @return {string|null} The CSS, or null to leave WordPress.com's fallback in force.
56+
*/
57+
export function rootFontSizeCss( fontSize ) {
58+
const value = cssValue( fontSize );
59+
60+
return value ? `${ RULE_PREFIX }${ value }; }` : null;
61+
}
62+
63+
/**
64+
* A stored font size as a value a declaration can hold.
65+
*
66+
* @param {*} fontSize - A stored font size.
67+
* @return {string|null} The value, or null when it is absent or not a size.
68+
*/
69+
function cssValue( fontSize ) {
70+
if ( 'string' !== typeof fontSize ) {
71+
return null;
72+
}
73+
74+
const trimmed = fontSize.trim();
75+
const preset = PRESET_SHORTHAND.exec( trimmed );
76+
const value = preset ? `var(--wp--preset--font-size--${ preset[ 1 ] })` : trimmed;
77+
78+
return SAFE_VALUE.test( value ) ? value : null;
79+
}
80+
81+
/**
82+
* A canvas style array holding this root font size and no earlier one.
83+
*
84+
* @param {*} styles - The canvas styles as they stand.
85+
* @param {*} fontSize - The root font size to publish.
86+
* @return {Array} The new styles.
87+
*/
88+
export function withRootFontSize( styles, fontSize ) {
89+
const kept = ( Array.isArray( styles ) ? styles : [] ).filter(
90+
entry => 'string' !== typeof entry?.css || ! entry.css.startsWith( RULE_PREFIX )
91+
);
92+
93+
const css = rootFontSizeCss( fontSize );
94+
95+
return css ? [ ...kept, { css } ] : kept;
96+
}
97+
98+
/**
99+
* Republish the root font size whenever the creator changes it.
100+
*
101+
* The package appends the editor settings' `styles` to the canvas CSS it generates and reads those
102+
* settings from its own store, so re-dispatching them is what repaints the canvas; the design the
103+
* panel edits lives in core-data, which is what this watches. Only a change is dispatched, so the
104+
* settings the package assembled from everything else are left alone.
105+
*
106+
* @param {string|null} [initial] - The size the canvas was built with, which needs no repaint.
107+
* @return {Function} Stops watching, for a caller that unmounts the editor.
108+
*/
109+
export function watchRootFontSize( initial = null ) {
110+
let previous = initial;
111+
112+
return subscribe( () => {
113+
const settings = select( EMAIL_EDITOR_STORE )?.getInitialEditorSettings?.();
114+
115+
// Before the editor has taken the configuration there is nothing to append to, and
116+
// dispatching would replace it.
117+
if ( ! settings ) {
118+
return;
119+
}
120+
121+
const design = select( EMAIL_EDITOR_STORE ).getGlobalEmailStylesPost();
122+
123+
// The package answers null until the design record resolves, and the theme alone would
124+
// republish a size the creator has already replaced. The canvas opened on the right one.
125+
if ( ! design ) {
126+
return;
127+
}
128+
129+
const current = rootFontSize( select( EMAIL_EDITOR_STORE ).getTheme(), design );
130+
131+
if ( current === previous ) {
132+
return;
133+
}
134+
135+
previous = current;
136+
137+
dispatch( EMAIL_EDITOR_STORE ).setEditorSettings( {
138+
...settings,
139+
styles: withRootFontSize( settings.styles, current ),
140+
} );
141+
}, coreStore );
142+
}

‎projects/plugins/jetpack/modules/subscriptions/email-design-editor/test/index.test.jsx‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ jest.mock( '@woocommerce/email-editor', () => {
4545
);
4646
mockCapturedHeadingTitle = __( 'Headings', 'jetpack' );
4747

48-
return { ExperimentalEmailEditor: MockEmailEditor };
48+
// The store name as well as the editor: the watchers address the package's store by it, and a
49+
// mock without it makes `select()` throw rather than report an unregistered store.
50+
return { ExperimentalEmailEditor: MockEmailEditor, storeName: 'email-editor/editor' };
4951
} );
5052

5153
const mockRegisterBlockType = jest.fn();
@@ -550,6 +552,44 @@ describe( 'Email design editor entry point', () => {
550552
expect( mountedEditorProps().config.editorSettings.styles ).toEqual( [ { css: 'body{}' } ] );
551553
} );
552554

555+
it( 'sizes image captions from the design the bundle carries, beside the other rules', async () => {
556+
mockApiFetch.mockResolvedValue(
557+
bootstrapBundle( {
558+
shows_featured_image: false,
559+
global_styles: {
560+
...bootstrapBundle().global_styles,
561+
record: {
562+
...bootstrapBundle().global_styles.record,
563+
styles: { typography: { fontSize: '22px' } },
564+
},
565+
},
566+
} )
567+
);
568+
window.JetpackEmailDesignEditor = pageData();
569+
570+
await loadEntryPoint();
571+
572+
// Without the variable the caption cells inherit the `font-size: 0px` the image tables
573+
// carry. Asserted whole, since every rule travels in this one array.
574+
expect( mountedEditorProps().config.editorSettings.styles ).toEqual( [
575+
{ css: 'body{}' },
576+
{ css: '.is-root-container{--wpcom-email-root-font-size:22px; }' },
577+
{ css: '.wp-block-post-featured-image { display: none; }' },
578+
] );
579+
} );
580+
581+
it( 'leaves captions to the fallback when neither half of the bundle sets a size', async () => {
582+
window.JetpackEmailDesignEditor = pageData();
583+
584+
await loadEntryPoint();
585+
586+
expect(
587+
mountedEditorProps().config.editorSettings.styles.some( entry =>
588+
entry.css?.includes( '--wpcom-email-root-font-size' )
589+
)
590+
).toBe( false );
591+
} );
592+
553593
it( 'does not pass the bundle through in its own shape', async () => {
554594
window.JetpackEmailDesignEditor = pageData();
555595

0 commit comments

Comments
 (0)