Skip to content

Commit 01b4c96

Browse files
makhnatkinclaude
andcommitted
feat(GridBlockTemplates): switch to selector-based CSS, drop inline styles
Replace inline CSS everywhere with a single selector-based model. The container gear has one CSS field (.grid {}, .block-1 {}); the block gear keeps HTML + a CSS field where & targets the block root and other selectors are scoped under it. All rules are scoped to the instance and emitted into a <style> tag instead of style="" attributes; templates convert their inline style to rules on insert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fb62e81 commit 01b4c96

9 files changed

Lines changed: 96 additions & 108 deletions

File tree

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplates.test.ts

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,57 +16,56 @@ const {doc, gridBlockTemplates} = builders<'doc' | 'gridBlockTemplates'>(schema,
1616
});
1717

1818
describe('GridBlockTemplates extension', () => {
19-
it('should serialize to yfm html block', () => {
19+
it('should serialize blocks without inline styles', () => {
2020
expect(
2121
serializer.serialize(
2222
doc(
2323
gridBlockTemplates({
2424
[GridBlockTemplatesAttrs.blocks]: [
25-
{
26-
id: 'block-1',
27-
css: 'padding: 12px;',
28-
content: '<strong>First</strong>',
29-
},
25+
{id: 'block-1', css: '', content: '<strong>First</strong>'},
3026
],
31-
[GridBlockTemplatesAttrs.containerCss]: 'display: grid;',
3227
[GridBlockTemplatesAttrs.EntityId]: 'grid_block_templates-1',
3328
}),
3429
),
3530
),
3631
).toBe(
3732
[
3833
'::: html',
39-
'<div class="grid grid-templates-scope-grid_block_templates-1" style="display: grid;">',
40-
' <div class="block-1" style="padding: 12px;"><strong>First</strong></div>',
34+
'<div class="grid-templates-scope-grid_block_templates-1">',
35+
' <div class="grid">',
36+
' <div class="block-1"><strong>First</strong></div>',
37+
' </div>',
4138
'</div>',
4239
':::',
4340
].join('\n'),
4441
);
4542
});
4643

47-
it('should emit scoped custom css into a style tag', () => {
44+
it('should emit scoped container and per-block css into a style tag', () => {
4845
expect(
4946
serializer.serialize(
5047
doc(
5148
gridBlockTemplates({
5249
[GridBlockTemplatesAttrs.blocks]: [
53-
{id: 'block-1', css: '', content: 'First'},
50+
{id: 'block-1', css: '& { padding: 12px; }\nh3 { margin: 0; }', content: 'First'},
5451
],
55-
[GridBlockTemplatesAttrs.customCss]:
56-
'.grid { align-items: center; }\n.block-1 { background: #eee; }',
52+
[GridBlockTemplatesAttrs.customCss]: '.grid { align-items: center; }',
5753
[GridBlockTemplatesAttrs.EntityId]: 'grid_block_templates-1',
5854
}),
5955
),
6056
),
6157
).toBe(
6258
[
6359
'::: html',
64-
'<style>',
65-
' .grid-templates-scope-grid_block_templates-1 .grid { align-items: center; }',
66-
' .grid-templates-scope-grid_block_templates-1 .block-1 { background: #eee; }',
67-
'</style>',
68-
'<div class="grid grid-templates-scope-grid_block_templates-1">',
69-
' <div class="block-1">First</div>',
60+
'<div class="grid-templates-scope-grid_block_templates-1">',
61+
' <style>',
62+
' .grid-templates-scope-grid_block_templates-1 .grid { align-items: center; }',
63+
' .grid-templates-scope-grid_block_templates-1 .block-1 { padding: 12px; }',
64+
' .grid-templates-scope-grid_block_templates-1 .block-1 h3 { margin: 0; }',
65+
' </style>',
66+
' <div class="grid">',
67+
' <div class="block-1">First</div>',
68+
' </div>',
7069
'</div>',
7170
':::',
7271
].join('\n'),

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/GridBlockTemplatesView.tsx

Lines changed: 39 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {i18n} from 'src/i18n/grid-block-templates';
1010
import {useBooleanState, useElementState} from 'src/react-utils/hooks';
1111
import {removeNode} from 'src/utils/remove-node';
1212

13-
import {gridScopeClass, scopeCss} from '../css';
13+
import {blockClass, gridScopeClass, inlineToRule, scopeCss} from '../css';
1414
import {GridBlockTemplatesConsts} from '../GridBlockTemplatesSpecs/const';
1515
import type {
1616
GridBlock,
@@ -34,26 +34,11 @@ const b = cnGridBlockTemplates;
3434
const stop = STOP_EVENT_CLASSNAME;
3535
const BLOCK_ID_ATTR = 'data-grid-block-id';
3636

37-
// PROTOTYPE: raw inline declarations, no scoping or sanitization.
38-
const parseInlineCss = (css: string): React.CSSProperties => {
39-
const style: Record<string, string> = {};
40-
for (const rule of css.split(';')) {
41-
const idx = rule.indexOf(':');
42-
if (idx === -1) continue;
43-
const prop = rule.slice(0, idx).trim();
44-
const value = rule.slice(idx + 1).trim();
45-
if (!prop || !value) continue;
46-
const camel = prop.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
47-
style[camel] = value;
48-
}
49-
return style as React.CSSProperties;
50-
};
51-
5237
const genId = () => Math.random().toString(36).slice(2, 10);
5338

5439
const toBlock = (template: GridBlockBlockTemplate): GridBlock => ({
5540
id: genId(),
56-
css: template.block.css,
41+
css: inlineToRule(template.block.css),
5742
content: template.block.content,
5843
});
5944

@@ -184,8 +169,8 @@ const BlockSettingsPopup: React.FC<{
184169
controlProps={{className: stop}}
185170
value={css}
186171
onUpdate={onCssChange}
187-
placeholder={'background: #eee;\nborder-radius: 8px;'}
188-
minRows={4}
172+
placeholder={'& {\n padding: 16px;\n border-radius: 8px;\n}\nh3 {\n margin: 0;\n}'}
173+
minRows={5}
189174
/>
190175
</div>
191176
</div>
@@ -196,34 +181,19 @@ const ContainerCssPopup: React.FC<{
196181
anchor: HTMLElement | null;
197182
open: boolean;
198183
onClose: () => void;
199-
inlineCss: string;
200-
customCss: string;
201-
onInlineCssChange: (value: string) => void;
202-
onCustomCssChange: (value: string) => void;
203-
}> = ({anchor, open, onClose, inlineCss, customCss, onInlineCssChange, onCustomCssChange}) => (
184+
css: string;
185+
onCssChange: (value: string) => void;
186+
}> = ({anchor, open, onClose, css, onCssChange}) => (
204187
<Popup anchorElement={anchor} open={open} onOpenChange={onClose} placement="bottom-end">
205-
<div className={b('block-settings-editor', [stop])}>
206-
<div className={b('field')}>
207-
<div className={b('field-label')}>{i18n('grid_css')}</div>
208-
<TextArea
209-
controlProps={{className: stop}}
210-
value={inlineCss}
211-
onUpdate={onInlineCssChange}
212-
placeholder={'grid-template-columns: 1fr 1fr;\ngap: 12px;'}
213-
minRows={4}
214-
autoFocus
215-
/>
216-
</div>
217-
<div className={b('field')}>
218-
<div className={b('field-label')}>{i18n('container_css')}</div>
219-
<TextArea
220-
controlProps={{className: stop}}
221-
value={customCss}
222-
onUpdate={onCustomCssChange}
223-
placeholder={'.grid {\n align-items: center;\n}\n.block-1 {\n background: #eee;\n}'}
224-
minRows={6}
225-
/>
226-
</div>
188+
<div className={b('css-editor', [stop])}>
189+
<TextArea
190+
controlProps={{className: stop}}
191+
value={css}
192+
onUpdate={onCssChange}
193+
placeholder={'.grid {\n grid-template-columns: 1fr 1fr;\n gap: 12px;\n}\n.block-1 {\n background: #eee;\n}'}
194+
minRows={6}
195+
autoFocus
196+
/>
227197
</div>
228198
</Popup>
229199
);
@@ -236,16 +206,22 @@ export const GridBlockTemplatesView: React.FC<{
236206
options: {templates?: GridBlockTemplatesOptions};
237207
}> = ({node, getPos, view, onChange, options}) => {
238208
const entityId: string = node.attrs[GridBlockTemplatesConsts.NodeAttrs.EntityId];
239-
const containerCss: string = node.attrs[GridBlockTemplatesConsts.NodeAttrs.containerCss] ?? '';
240209
const customCss: string = node.attrs[GridBlockTemplatesConsts.NodeAttrs.customCss] ?? '';
241210
const blocks: GridBlock[] = node.attrs[GridBlockTemplatesConsts.NodeAttrs.blocks] ?? [];
242211
const {templates} = options;
243212

244213
const scopeClass = useMemo(() => gridScopeClass(entityId), [entityId]);
245-
const scopedCustomCss = useMemo(
246-
() => (customCss.trim() ? scopeCss(customCss, scopeClass) : ''),
247-
[customCss, scopeClass],
248-
);
214+
const scopedCss = useMemo(() => {
215+
const rules = [
216+
customCss.trim() && scopeCss(customCss, `.${scopeClass}`).trim(),
217+
...blocks.map(
218+
(block, i) =>
219+
block.css.trim() &&
220+
scopeCss(block.css, `.${scopeClass} .${blockClass(i)}`).trim(),
221+
),
222+
].filter(Boolean);
223+
return rules.join('\n');
224+
}, [customCss, blocks, scopeClass]);
249225

250226
const [containerAnchor, setContainerAnchor] = useElementState();
251227
const [containerCssOpen, setContainerCssOpen] = useState(false);
@@ -291,11 +267,13 @@ export const GridBlockTemplatesView: React.FC<{
291267

292268
const applyContainerTemplate = (template: GridBlockContainerTemplate) => {
293269
onChange({
294-
[GridBlockTemplatesConsts.NodeAttrs.containerCss]: template.containerCss,
295-
[GridBlockTemplatesConsts.NodeAttrs.customCss]: '',
270+
[GridBlockTemplatesConsts.NodeAttrs.customCss]: inlineToRule(
271+
template.containerCss,
272+
'.grid',
273+
),
296274
[GridBlockTemplatesConsts.NodeAttrs.blocks]: template.blocks.map((block) => ({
297275
id: genId(),
298-
css: block.css,
276+
css: inlineToRule(block.css),
299277
content: block.content,
300278
})),
301279
});
@@ -399,9 +377,9 @@ export const GridBlockTemplatesView: React.FC<{
399377
blocks.find((block) => block.id === editingBlockSettingsId) ?? null;
400378

401379
return (
402-
<div className={b()}>
403-
{/* PROTOTYPE scoping: wraps user CSS into the grid viewport selector. */}
404-
<style>{`.${scopeClass}{display:grid;gap:8px;${containerCss}}\n${scopedCustomCss}`}</style>
380+
<div className={`${b()} ${scopeClass}`}>
381+
{/* PROTOTYPE scoping: selectors in user CSS are prefixed with the instance scope. */}
382+
<style>{`.${scopeClass} .grid{display:grid;gap:8px}\n${scopedCss}`}</style>
405383

406384
<div className={b('toolbar', [stop])}>
407385
<Button
@@ -456,7 +434,7 @@ export const GridBlockTemplatesView: React.FC<{
456434
</Button>
457435
</div>
458436

459-
<div className={`${b('grid')} grid ${scopeClass}`}>
437+
<div className={`${b('grid')} grid`}>
460438
{blocks.map((block, i) => (
461439
<div
462440
key={block.id}
@@ -466,9 +444,8 @@ export const GridBlockTemplatesView: React.FC<{
466444
dropTarget?.id === block.id && dropTarget.placement === 'before',
467445
'drop-after':
468446
dropTarget?.id === block.id && dropTarget.placement === 'after',
469-
})} ${stop} block-${i + 1}`}
447+
})} ${stop} ${blockClass(i)}`}
470448
data-grid-block-id={block.id}
471-
style={parseInlineCss(block.css)}
472449
>
473450
<button
474451
type="button"
@@ -549,12 +526,8 @@ export const GridBlockTemplatesView: React.FC<{
549526
anchor={containerAnchor}
550527
open={containerCssOpen}
551528
onClose={() => setContainerCssOpen(false)}
552-
inlineCss={containerCss}
553-
customCss={customCss}
554-
onInlineCssChange={(value) =>
555-
onChange({[GridBlockTemplatesConsts.NodeAttrs.containerCss]: value})
556-
}
557-
onCustomCssChange={(value) =>
529+
css={customCss}
530+
onCssChange={(value) =>
558531
onChange({[GridBlockTemplatesConsts.NodeAttrs.customCss]: value})
559532
}
560533
/>

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesSpecs/const.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ export enum GridBlockTemplatesAttrs {
66
EntityId = entityIdAttr,
77
/** Array of grid blocks. */
88
blocks = 'blocks',
9-
/** Inline CSS declarations applied to the grid viewport. */
10-
containerCss = 'containerCss',
11-
/** Free-form CSS rules with selectors, scoped to this grid instance. */
9+
/** CSS rules with selectors, scoped to this grid instance. */
1210
customCss = 'customCss',
1311
}
1412

packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesSpecs/index.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {Node} from 'prosemirror-model';
22

33
import type {ExtensionAuto, ExtensionNodeSpec} from '#core';
44

5-
import {gridScopeClass, scopeCss} from '../css';
5+
import {blockClass, gridScopeClass, scopeCss} from '../css';
66
import type {GridBlock} from '../types';
77

88
import {
@@ -30,25 +30,29 @@ const indent = (text: string, by = ' ') =>
3030

3131
/** Assembles the static HTML written into a YFM HTML block. */
3232
export const buildGridHtml = (node: Node): string => {
33-
const containerCss: string = node.attrs[GridBlockTemplatesConsts.NodeAttrs.containerCss] || '';
3433
const customCss: string = node.attrs[GridBlockTemplatesConsts.NodeAttrs.customCss] || '';
3534
const entityId: string = node.attrs[GridBlockTemplatesConsts.NodeAttrs.EntityId];
3635
const scopeClass = gridScopeClass(entityId);
36+
const gridBlocks = readBlocks(node);
3737

38-
const containerStyle = containerCss.trim() ? ` style="${containerCss.trim()}"` : '';
38+
const blocks = gridBlocks
39+
.map((block, i) => indent(`<div class="${blockClass(i)}">${block.content ?? ''}</div>`))
40+
.join('\n');
3941

40-
const blocks = readBlocks(node)
41-
.map((block, i) => {
42-
const style = block.css.trim() ? ` style="${block.css.trim()}"` : '';
43-
return indent(`<div class="block-${i + 1}"${style}>${block.content ?? ''}</div>`);
44-
})
42+
const rules = [
43+
customCss.trim() && scopeCss(customCss, `.${scopeClass}`).trim(),
44+
...gridBlocks.map(
45+
(block, i) =>
46+
block.css.trim() && scopeCss(block.css, `.${scopeClass} .${blockClass(i)}`).trim(),
47+
),
48+
]
49+
.filter(Boolean)
4550
.join('\n');
4651

47-
const styleTag = customCss.trim()
48-
? `<style>\n${indent(scopeCss(customCss.trim(), scopeClass).trim())}\n</style>\n`
49-
: '';
52+
const styleTag = rules ? `<style>\n${indent(rules.trim())}\n</style>\n` : '';
53+
const grid = `<div class="grid">\n${blocks}\n</div>`;
5054

51-
return `${styleTag}<div class="grid ${scopeClass}"${containerStyle}>\n${blocks}\n</div>`;
55+
return `<div class="${scopeClass}">\n${styleTag ? indent(styleTag.trimEnd()) + '\n' : ''}${indent(grid)}\n</div>`;
5256
};
5357

5458
const GridBlockTemplatesSpecsExtension: ExtensionAuto<GridBlockTemplatesSpecsOptions> = (
@@ -66,7 +70,6 @@ const GridBlockTemplatesSpecsExtension: ExtensionAuto<GridBlockTemplatesSpecsOpt
6670
group: 'block',
6771
attrs: {
6872
[GridBlockTemplatesConsts.NodeAttrs.blocks]: {default: []},
69-
[GridBlockTemplatesConsts.NodeAttrs.containerCss]: {default: ''},
7073
[GridBlockTemplatesConsts.NodeAttrs.customCss]: {default: ''},
7174
[GridBlockTemplatesConsts.NodeAttrs.EntityId]: {
7275
default: defaultGridBlockTemplatesEntityId,

packages/editor/src/extensions/additional/GridBlockTemplates/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const addGridBlockTemplates: ActionSpec = {
1717
state.selection.from,
1818
gridBlockTemplatesNodeType(state.schema).create({
1919
[GridBlockTemplatesConsts.NodeAttrs.blocks]: [],
20-
[GridBlockTemplatesConsts.NodeAttrs.containerCss]: '',
20+
[GridBlockTemplatesConsts.NodeAttrs.customCss]: '',
2121
[GridBlockTemplatesConsts.NodeAttrs.EntityId]: entityId,
2222
}),
2323
);
Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
11
export const gridScopeClass = (entityId: string) =>
22
'grid-templates-scope-' + entityId.replace(/[^a-z0-9_-]/gi, '');
33

4-
/** Prefixes every top-level selector with the scope class so rules stay local to one grid. */
5-
export const scopeCss = (css: string, scopeClass: string): string =>
4+
export const blockClass = (index: number) => `block-${index + 1}`;
5+
6+
/** Wraps inline declarations (from a template's `style`) into a `selector { ... }` rule. */
7+
export const inlineToRule = (declarations: string, selector = '&'): string => {
8+
const decls = declarations.trim().replace(/;?$/, ';');
9+
return decls === ';' ? '' : `${selector} {\n ${decls}\n}`;
10+
};
11+
12+
const scopeSelector = (selector: string, base: string) =>
13+
selector.includes('&')
14+
? selector.replace(/&/g, base)
15+
: `${base} ${selector}`;
16+
17+
/**
18+
* Prefixes every top-level selector with `base` so rules stay local. `&` in a selector
19+
* is replaced with `base` itself (to target the scoped root), otherwise the selector
20+
* becomes a descendant of `base`.
21+
*/
22+
export const scopeCss = (css: string, base: string): string =>
623
css.replace(/(^|\})\s*([^{}]+)\{/g, (_match, brace, selectorList) => {
724
const scoped = selectorList
825
.split(',')
926
.map((selector: string) => selector.trim())
1027
.filter(Boolean)
11-
.map((selector: string) => `.${scopeClass} ${selector}`)
28+
.map((selector: string) => scopeSelector(selector, base))
1229
.join(', ');
1330
return `${brace}\n${scoped} {`;
1431
});

packages/editor/src/extensions/additional/GridBlockTemplates/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type GridBlock = {
22
id: string;
3-
/** Inline CSS applied to this block. */
3+
/** CSS rules with selectors, scoped to this block. `&` targets the block root. */
44
css: string;
55
/** Raw HTML content rendered inside the block. */
66
content: string;

packages/editor/src/i18n/grid-block-templates/en.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"block_templates_empty": "No block templates",
77
"cancel": "Cancel",
88
"clear_templates": "Clear all templates",
9-
"container_css": "Container CSS",
109
"container_templates": "Container templates",
1110
"container_templates_empty": "No container templates",
1211
"custom_html": "Custom HTML",

0 commit comments

Comments
 (0)