Skip to content

Commit 4e03cd0

Browse files
committed
feat: add grid block templates experiment
1 parent 98b8079 commit 4e03cd0

26 files changed

Lines changed: 1824 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type {GridBlockTemplate} from '@gravity-ui/markdown-editor/extensions/additional/GridBlockTemplates/templates/index.js';
2+
3+
export const gridBlockTemplates: GridBlockTemplate[] = [
4+
{
5+
id: 'feature-grid',
6+
title: 'Feature grid',
7+
type: 'container',
8+
content: `<div class="grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px;padding:16px;background:#f8fafc;border-radius:12px">
9+
<div style="padding:18px;background:#fff;border:1px solid #e5e7eb;border-radius:8px"><strong>Fast setup</strong><p>Start from a reusable layout.</p></div>
10+
<div style="padding:18px;background:#fff;border:1px solid #e5e7eb;border-radius:8px"><strong>Editable blocks</strong><p>Change content inline.</p></div>
11+
<div style="padding:18px;background:#fff;border:1px solid #e5e7eb;border-radius:8px"><strong>Static output</strong><p>Serialize to HTML.</p></div>
12+
</div>`,
13+
containerCss:
14+
'display:grid;grid-template-columns:repeat(3,1fr);gap:16px;padding:16px;background:#f8fafc;border-radius:12px',
15+
blocks: [
16+
{
17+
css: 'padding:18px;background:#fff;border:1px solid #e5e7eb;border-radius:8px',
18+
content: '<strong>Fast setup</strong><p>Start from a reusable layout.</p>',
19+
},
20+
{
21+
css: 'padding:18px;background:#fff;border:1px solid #e5e7eb;border-radius:8px',
22+
content: '<strong>Editable blocks</strong><p>Change content inline.</p>',
23+
},
24+
{
25+
css: 'padding:18px;background:#fff;border:1px solid #e5e7eb;border-radius:8px',
26+
content: '<strong>Static output</strong><p>Serialize to HTML.</p>',
27+
},
28+
],
29+
},
30+
{
31+
id: 'header-two-columns',
32+
title: 'Header and two columns',
33+
type: 'container',
34+
content: `<div class="grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:14px">
35+
<div style="grid-column:1 / -1;padding:24px;background:#2563eb;color:#fff;border-radius:10px"><h2>Section title</h2></div>
36+
<div style="padding:18px;background:#eff6ff;border-radius:8px">Left column</div>
37+
<div style="padding:18px;background:#f0fdf4;border-radius:8px">Right column</div>
38+
</div>`,
39+
containerCss: 'display:grid;grid-template-columns:1fr 1fr;gap:14px;padding:14px',
40+
blocks: [
41+
{
42+
css: 'grid-column:1 / -1;padding:24px;background:#2563eb;color:#fff;border-radius:10px',
43+
content: '<h2>Section title</h2>',
44+
},
45+
{
46+
css: 'padding:18px;background:#eff6ff;border-radius:8px',
47+
content: 'Left column',
48+
},
49+
{
50+
css: 'padding:18px;background:#f0fdf4;border-radius:8px',
51+
content: 'Right column',
52+
},
53+
],
54+
},
55+
];
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type {StoryObj} from '@storybook/react';
2+
3+
import {GridBlockTemplatesDemo as component} from './GridBlockTemplates';
4+
5+
export const Story: StoryObj<typeof component> = {};
6+
Story.storyName = 'Grid block templates';
7+
8+
export default {
9+
title: 'Examples / Grid block templates',
10+
component,
11+
};
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import {memo} from 'react';
2+
3+
import {LayoutCells} from '@gravity-ui/icons';
4+
import {
5+
MarkdownEditorView,
6+
type ToolbarsPreset,
7+
useMarkdownEditor,
8+
} from '@gravity-ui/markdown-editor';
9+
import {ToolbarName as Toolbar} from '@gravity-ui/markdown-editor/_/modules/toolbars/constants.js';
10+
import {defaultPreset} from '@gravity-ui/markdown-editor/_/modules/toolbars/presets.js';
11+
import {GridBlockTemplates as GridBlockTemplatesExtension} from '@gravity-ui/markdown-editor/extensions/additional/GridBlockTemplates/index.js';
12+
13+
import {gridBlockTemplates} from '../../../defaults/grid-block-templates';
14+
import {PlaygroundLayout} from '../../../components/PlaygroundLayout';
15+
16+
const gridBlockTemplatesItemId = 'gridBlockTemplates';
17+
18+
const toolbarsPreset: ToolbarsPreset = {
19+
items: {
20+
...defaultPreset.items,
21+
[gridBlockTemplatesItemId]: {
22+
view: {
23+
icon: {data: LayoutCells},
24+
title: 'Grid block templates',
25+
},
26+
wysiwyg: {
27+
exec: (e) => e.actions.createGridBlockTemplates.run(),
28+
isActive: (e) => e.actions.createGridBlockTemplates.isActive(),
29+
isEnable: (e) => e.actions.createGridBlockTemplates.isEnable(),
30+
},
31+
},
32+
},
33+
orders: {
34+
...defaultPreset.orders,
35+
[Toolbar.wysiwygMain]: [
36+
[gridBlockTemplatesItemId],
37+
...defaultPreset.orders[Toolbar.wysiwygMain],
38+
],
39+
},
40+
};
41+
42+
export const GridBlockTemplatesDemo = memo(function GridBlockTemplatesDemo() {
43+
const editor = useMarkdownEditor(
44+
{
45+
initial: {mode: 'wysiwyg', markup: ''},
46+
wysiwygConfig: {
47+
extensions: (builder) =>
48+
builder.use(GridBlockTemplatesExtension, {
49+
templates: {
50+
items: gridBlockTemplates,
51+
showButton: true,
52+
allowAdd: true,
53+
},
54+
}),
55+
},
56+
},
57+
[],
58+
);
59+
60+
return (
61+
<PlaygroundLayout
62+
editor={editor}
63+
view={({className}) => (
64+
<MarkdownEditorView
65+
autofocus
66+
stickyToolbar
67+
settingsVisible
68+
editor={editor}
69+
className={className}
70+
toolbarsPreset={toolbarsPreset}
71+
/>
72+
)}
73+
/>
74+
);
75+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {builders} from 'prosemirror-test-builder';
2+
3+
import {ExtensionsManager} from '../../../core';
4+
import {BaseNode, BaseSchemaSpecs} from '../../specs';
5+
6+
import {GridBlockTemplatesSpecs} from './GridBlockTemplatesSpecs';
7+
import {GridBlockTemplatesAttrs, gridBlockTemplatesNodeName} from './GridBlockTemplatesSpecs/const';
8+
9+
const {schema, serializer} = new ExtensionsManager({
10+
extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(GridBlockTemplatesSpecs, {}),
11+
}).buildDeps();
12+
13+
const {doc, gridBlockTemplates} = builders<'doc' | 'gridBlockTemplates'>(schema, {
14+
doc: {nodeType: BaseNode.Doc},
15+
gridBlockTemplates: {nodeType: gridBlockTemplatesNodeName},
16+
});
17+
18+
describe('GridBlockTemplates extension', () => {
19+
it('should serialize to yfm html block', () => {
20+
expect(
21+
serializer.serialize(
22+
doc(
23+
gridBlockTemplates({
24+
[GridBlockTemplatesAttrs.blocks]: [
25+
{
26+
id: 'block-1',
27+
css: 'padding: 12px;',
28+
content: '<strong>First</strong>',
29+
},
30+
],
31+
[GridBlockTemplatesAttrs.containerCss]: 'display: grid;',
32+
[GridBlockTemplatesAttrs.EntityId]: 'grid_block_templates-1',
33+
}),
34+
),
35+
),
36+
).toBe(
37+
[
38+
'::: html',
39+
'<div class="grid" style="display: grid;">',
40+
' <div class="block-1" style="padding: 12px;"><strong>First</strong></div>',
41+
'</div>',
42+
':::',
43+
].join('\n'),
44+
);
45+
});
46+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {useMemo, useState} from 'react';
2+
3+
import {Code, Plus} from '@gravity-ui/icons';
4+
import {Button, Icon, Menu, Popup, TextInput} from '@gravity-ui/uikit';
5+
6+
import {TextAreaFixed as TextArea} from 'src/forms/TextInput';
7+
import {i18n} from 'src/i18n/grid-block-templates';
8+
9+
import {parseTemplateBlock} from '../templates';
10+
import type {GridBlockBlockTemplate, GridBlockTemplateBlock} from '../types';
11+
12+
import {STOP_EVENT_CLASSNAME, cnGridBlockTemplates} from './const';
13+
14+
const b = cnGridBlockTemplates;
15+
const stop = STOP_EVENT_CLASSNAME;
16+
17+
interface BlockInsertPopupProps {
18+
anchor: HTMLElement | null;
19+
open: boolean;
20+
templates: GridBlockBlockTemplate[];
21+
onClose: () => void;
22+
onApplyTemplate: (template: GridBlockBlockTemplate) => void;
23+
onApplyHtml: (block: GridBlockTemplateBlock) => void;
24+
}
25+
26+
export const BlockInsertPopup: React.FC<BlockInsertPopupProps> = ({
27+
anchor,
28+
open,
29+
templates,
30+
onClose,
31+
onApplyTemplate,
32+
onApplyHtml,
33+
}) => {
34+
const [addingCustomHtml, setAddingCustomHtml] = useState(false);
35+
const [input, setInput] = useState('');
36+
const [filter, setFilter] = useState('');
37+
const showCustomHtmlEditor = addingCustomHtml || templates.length === 0;
38+
39+
const filtered = useMemo(() => {
40+
const query = filter.trim().toLowerCase();
41+
if (!query) return templates;
42+
return templates.filter((template) => template.title.toLowerCase().includes(query));
43+
}, [templates, filter]);
44+
45+
const close = () => {
46+
setAddingCustomHtml(false);
47+
setInput('');
48+
setFilter('');
49+
onClose();
50+
};
51+
52+
const handleApplyHtml = () => {
53+
onApplyHtml(parseTemplateBlock(input));
54+
close();
55+
};
56+
57+
return (
58+
<Popup anchorElement={anchor} open={open} onOpenChange={close} placement="bottom-end">
59+
<div className={b('templates', [stop])}>
60+
{showCustomHtmlEditor ? (
61+
<div className={b('templates-editor')}>
62+
<TextArea
63+
controlProps={{className: stop}}
64+
value={input}
65+
onUpdate={setInput}
66+
placeholder={i18n('block_html_input_placeholder')}
67+
minRows={8}
68+
autoFocus
69+
/>
70+
<div className={b('templates-controls')}>
71+
<Button view="flat" className={stop} onClick={close}>
72+
<span className={stop}>{i18n('cancel')}</span>
73+
</Button>
74+
<Button
75+
view="action"
76+
className={stop}
77+
disabled={!input.trim()}
78+
onClick={handleApplyHtml}
79+
>
80+
<span className={stop}>{i18n('insert')}</span>
81+
</Button>
82+
</div>
83+
</div>
84+
) : (
85+
<>
86+
<div className={b('templates-search')}>
87+
<TextInput
88+
className={stop}
89+
controlProps={{className: stop}}
90+
size="s"
91+
value={filter}
92+
onUpdate={setFilter}
93+
placeholder={i18n('search_templates')}
94+
autoFocus
95+
/>
96+
</div>
97+
<Menu className={stop}>
98+
<Menu.Item
99+
className={stop}
100+
iconStart={<Icon data={Code} />}
101+
onClick={() => setAddingCustomHtml(true)}
102+
>
103+
{i18n('custom_html')}
104+
</Menu.Item>
105+
{filtered.map((template) => (
106+
<Menu.Item
107+
key={template.id}
108+
className={stop}
109+
iconStart={<Icon data={Plus} />}
110+
onClick={() => {
111+
onApplyTemplate(template);
112+
close();
113+
}}
114+
>
115+
{template.title}
116+
</Menu.Item>
117+
))}
118+
{filtered.length === 0 && (
119+
<Menu.Item disabled className={stop}>
120+
{i18n('block_templates_empty')}
121+
</Menu.Item>
122+
)}
123+
</Menu>
124+
</>
125+
)}
126+
</div>
127+
</Popup>
128+
);
129+
};

0 commit comments

Comments
 (0)