Skip to content

Commit d79c91b

Browse files
makhnatkinclaude
andcommitted
feat(YfmHtmlBlock): add HTML templates button
Add a templates button inside the YfmHtmlBlock node that opens a popup with a searchable list of templates and an "add" action. - templates are merged from plugin options and localStorage (localStorage overrides by id); adding writes only to localStorage, never to markup - the add form accepts several <template id title> blocks at once - selecting a template overwrites the block srcdoc - new `templates` plugin option: { items, showButton, allowAdd } Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e8739a5 commit d79c91b

16 files changed

Lines changed: 494 additions & 5 deletions

File tree

demo/src/components/Playground.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {YfmPageConstructorExtension} from '@gravity-ui/markdown-editor-page-cons
3737
import {wYfmPageConstructorItemData} from '@gravity-ui/markdown-editor-page-constructor-extension/configs';
3838
import {Button, DropdownMenu} from '@gravity-ui/uikit';
3939

40+
import {htmlBlockTemplates} from '../defaults/html-templates';
4041
import {getPlugins} from '../defaults/md-plugins';
4142
import {useLogs} from '../hooks/useLogs';
4243
import useYfmHtmlBlockStyles from '../hooks/useYfmHtmlBlockStyles';
@@ -236,6 +237,11 @@ export const Playground = memo<PlaygroundProps>((props) => {
236237
storyAdditionalControls?.yfmHtmlBlockAutoSaveEnabled ?? true,
237238
delay: storyAdditionalControls?.yfmHtmlBlockAutoSaveDelay ?? 1000,
238239
},
240+
templates: {
241+
items: htmlBlockTemplates,
242+
showButton: true,
243+
allowAdd: true,
244+
},
239245
head: `
240246
<base target="_blank" />
241247
<style>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export const htmlBlockTemplates = [
2+
{
3+
id: 'callout',
4+
title: 'Callout',
5+
content: `<div style="padding:16px;border-left:4px solid #2563eb;background:#eff6ff;border-radius:8px;">
6+
<strong>Heads up</strong>
7+
<p style="margin:8px 0 0;">Replace this text with your message.</p>
8+
</div>`,
9+
},
10+
{
11+
id: 'two-columns',
12+
title: 'Two columns',
13+
content: `<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
14+
<div style="padding:16px;background:#f8fafc;border-radius:8px;">Left</div>
15+
<div style="padding:16px;background:#f8fafc;border-radius:8px;">Right</div>
16+
</div>`,
17+
},
18+
{
19+
id: 'cta-button',
20+
title: 'CTA button',
21+
content: `<a href="#" style="display:inline-block;padding:12px 20px;background:#2563eb;color:#fff;border-radius:8px;text-decoration:none;font-weight:600;">
22+
Get started
23+
</a>`,
24+
},
25+
];
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
.g-md-yfm-html-block-templates {
2+
display: flex;
3+
flex-direction: column;
4+
5+
min-width: 240px;
6+
max-width: 360px;
7+
8+
&__search {
9+
padding: 8px 8px 4px;
10+
}
11+
12+
&__editor {
13+
display: flex;
14+
flex-direction: column;
15+
16+
gap: 8px;
17+
padding: 8px;
18+
}
19+
20+
&__controls {
21+
display: flex;
22+
justify-content: end;
23+
24+
gap: 8px;
25+
}
26+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {useMemo, useState} from 'react';
2+
3+
import {Plus} from '@gravity-ui/icons';
4+
import {Button, Icon, Menu, Popup, TextInput} from '@gravity-ui/uikit';
5+
6+
import {cn} from 'src/classname';
7+
import {TextAreaFixed as TextArea} from 'src/forms/TextInput';
8+
import {i18n} from 'src/i18n/yfm-html-block';
9+
10+
import {type HtmlTemplate, parseTemplates, saveTemplates} from '../templates';
11+
12+
import {STOP_EVENT_CLASSNAME} from './const';
13+
14+
import './TemplatesPopup.scss';
15+
16+
const b = cn('yfm-html-block-templates');
17+
const stop = STOP_EVENT_CLASSNAME;
18+
19+
interface TemplatesPopupProps {
20+
anchor: HTMLElement | null;
21+
open: boolean;
22+
templates: HtmlTemplate[];
23+
allowAdd: boolean;
24+
onClose: () => void;
25+
onApply: (template: HtmlTemplate) => void;
26+
onAdded: (templates: HtmlTemplate[]) => void;
27+
}
28+
29+
export const TemplatesPopup: React.FC<TemplatesPopupProps> = ({
30+
anchor,
31+
open,
32+
templates,
33+
allowAdd,
34+
onClose,
35+
onApply,
36+
onAdded,
37+
}) => {
38+
const [adding, setAdding] = useState(false);
39+
const [input, setInput] = useState('');
40+
const [filter, setFilter] = useState('');
41+
42+
const filtered = useMemo(() => {
43+
const query = filter.trim().toLowerCase();
44+
if (!query) return templates;
45+
return templates.filter((t) => t.title.toLowerCase().includes(query));
46+
}, [templates, filter]);
47+
48+
const close = () => {
49+
setAdding(false);
50+
setInput('');
51+
setFilter('');
52+
onClose();
53+
};
54+
55+
const handleSave = () => {
56+
const parsed = parseTemplates(input);
57+
if (parsed.length) onAdded(saveTemplates(parsed));
58+
setInput('');
59+
setAdding(false);
60+
};
61+
62+
return (
63+
<Popup anchorElement={anchor} open={open} onOpenChange={close} placement="bottom-end">
64+
<div className={b(null, [stop])}>
65+
{adding ? (
66+
<div className={b('editor')}>
67+
<TextArea
68+
controlProps={{className: stop}}
69+
value={input}
70+
onUpdate={setInput}
71+
placeholder={i18n('templates_input_placeholder')}
72+
minRows={6}
73+
autoFocus
74+
/>
75+
<div className={b('controls')}>
76+
<Button view="flat" className={stop} onClick={() => setAdding(false)}>
77+
<span className={stop}>{i18n('cancel')}</span>
78+
</Button>
79+
<Button
80+
view="action"
81+
className={stop}
82+
disabled={!input.trim()}
83+
onClick={handleSave}
84+
>
85+
<span className={stop}>{i18n('save')}</span>
86+
</Button>
87+
</div>
88+
</div>
89+
) : (
90+
<>
91+
{templates.length > 0 && (
92+
<div className={b('search')}>
93+
<TextInput
94+
className={stop}
95+
controlProps={{className: stop}}
96+
size="s"
97+
value={filter}
98+
onUpdate={setFilter}
99+
placeholder={i18n('search_templates')}
100+
autoFocus
101+
/>
102+
</div>
103+
)}
104+
<Menu className={stop}>
105+
{allowAdd && (
106+
<Menu.Item
107+
className={stop}
108+
iconStart={<Icon data={Plus} />}
109+
onClick={() => setAdding(true)}
110+
>
111+
{i18n('add_template')}
112+
</Menu.Item>
113+
)}
114+
{filtered.map((template) => (
115+
<Menu.Item
116+
key={template.id}
117+
className={stop}
118+
onClick={() => {
119+
onApply(template);
120+
close();
121+
}}
122+
>
123+
{template.title}
124+
</Menu.Item>
125+
))}
126+
{filtered.length === 0 && (
127+
<Menu.Item disabled className={stop}>
128+
{i18n('templates_empty')}
129+
</Menu.Item>
130+
)}
131+
</Menu>
132+
</>
133+
)}
134+
</div>
135+
</Popup>
136+
);
137+
};

packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/YfmHtmlBlockView.tsx

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,31 @@ import {useEffect, useMemo, useRef, useState} from 'react';
22

33
import {getStyles} from '@diplodoc/html-extension';
44
import type {IHTMLIFrameElementConfig} from '@diplodoc/html-extension/runtime';
5-
import {Ellipsis as DotsIcon, Eye} from '@gravity-ui/icons';
5+
import {Ellipsis as DotsIcon, Eye, LayoutHeaderColumns} from '@gravity-ui/icons';
66
import {Button, Icon, Label, Menu, Popup} from '@gravity-ui/uikit';
77
import type {Node} from 'prosemirror-model';
88
import type {EditorView} from 'prosemirror-view';
99

10-
import {cn} from 'src/classname';
1110
import {SharedStateKey} from 'src/extensions/behavior/SharedState';
1211
import {TextAreaFixed as TextArea} from 'src/forms/TextInput';
1312
import {i18n} from 'src/i18n/common';
13+
import {i18n as i18nTemplates} from 'src/i18n/yfm-html-block';
1414
import {debounce} from 'src/lodash';
1515
import {useAutoSave, useBooleanState, useElementState} from 'src/react-utils/hooks';
1616
import {useSharedEditingState} from 'src/react-utils/useSharedEditingState';
1717
import {removeNode} from 'src/utils/remove-node';
1818

1919
import {YfmHtmlBlockConsts} from '../YfmHtmlBlockSpecs/const';
2020
import type {YfmHtmlBlockOptions} from '../index';
21+
import {type HtmlTemplate, mergeTemplatesById, readStoredTemplates} from '../templates';
2122
import type {YfmHtmlBlockEntitySharedState} from '../types';
2223

24+
import {TemplatesPopup} from './TemplatesPopup';
25+
import {STOP_EVENT_CLASSNAME, cnYfmHtmlBlock} from './const';
26+
2327
import './YfmHtmlBlock.scss';
2428

25-
export const cnYfmHtmlBlock = cn('yfm-html-block');
26-
export const STOP_EVENT_CLASSNAME = 'prosemirror-stop-event';
29+
export {STOP_EVENT_CLASSNAME, cnYfmHtmlBlock} from './const';
2730

2831
const b = cnYfmHtmlBlock;
2932

@@ -245,7 +248,14 @@ export const YfmHtmlBlockView: React.FC<{
245248
options: YfmHtmlBlockOptions;
246249
view: EditorView;
247250
}> = ({onChange, node, getPos, view, options}) => {
248-
const {useConfig, sanitize, styles, baseTarget = '_parent', head: headContent = ''} = options;
251+
const {
252+
useConfig,
253+
sanitize,
254+
styles,
255+
baseTarget = '_parent',
256+
head: headContent = '',
257+
templates,
258+
} = options;
249259
const entityId: string = node.attrs[YfmHtmlBlockConsts.NodeAttrs.EntityId];
250260
const entityKey = useMemo(
251261
() => SharedStateKey.define<YfmHtmlBlockEntitySharedState>({name: entityId}),
@@ -258,6 +268,22 @@ export const YfmHtmlBlockView: React.FC<{
258268
const [menuOpen, _openMenu, closeMenu, toggleMenuOpen] = useBooleanState(false);
259269
const [anchorElement, setAnchorElement] = useElementState();
260270

271+
const allowAdd = Boolean(templates?.allowAdd);
272+
const [storedTemplates, setStoredTemplates] = useState<HtmlTemplate[]>(readStoredTemplates);
273+
const effectiveTemplates = useMemo(
274+
() => mergeTemplatesById(templates?.items ?? [], storedTemplates),
275+
[templates?.items, storedTemplates],
276+
);
277+
const showTemplatesButton =
278+
Boolean(templates?.showButton) && (allowAdd || effectiveTemplates.length > 0);
279+
const [templatesOpen, , closeTemplates, toggleTemplatesOpen] = useBooleanState(false);
280+
const [templatesAnchor, setTemplatesAnchor] = useElementState();
281+
282+
const applyTemplate = (template: HtmlTemplate) => {
283+
onChange({[YfmHtmlBlockConsts.NodeAttrs.srcdoc]: template.content});
284+
closeTemplates();
285+
};
286+
261287
if (editing) {
262288
return (
263289
<CodeEditMode
@@ -296,6 +322,28 @@ export const YfmHtmlBlockView: React.FC<{
296322
<YfmHtmlBlockPreview html={resultHtml} onClick={setEditing} config={config} />
297323

298324
<div className={b('menu')}>
325+
{showTemplatesButton && (
326+
<>
327+
<Button
328+
onClick={toggleTemplatesOpen}
329+
ref={setTemplatesAnchor}
330+
size="s"
331+
className={STOP_EVENT_CLASSNAME}
332+
aria-label={i18nTemplates('templates')}
333+
>
334+
<Icon data={LayoutHeaderColumns} className={STOP_EVENT_CLASSNAME} />
335+
</Button>
336+
<TemplatesPopup
337+
anchor={templatesAnchor}
338+
open={templatesOpen}
339+
templates={effectiveTemplates}
340+
allowAdd={allowAdd}
341+
onClose={closeTemplates}
342+
onApply={applyTemplate}
343+
onAdded={setStoredTemplates}
344+
/>
345+
</>
346+
)}
299347
<Button
300348
onClick={toggleMenuOpen}
301349
ref={setAnchorElement}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import {cn} from 'src/classname';
2+
3+
export const cnYfmHtmlBlock = cn('yfm-html-block');
4+
export const STOP_EVENT_CLASSNAME = 'prosemirror-stop-event';

packages/editor/src/extensions/additional/YfmHtmlBlock/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {WYfmHtmlBlockNodeView} from './YfmHtmlBlockNodeView';
77
import {YfmHtmlBlockSpecs} from './YfmHtmlBlockSpecs';
88
import {YfmHtmlBlockAction} from './YfmHtmlBlockSpecs/const';
99
import {addYfmHtmlBlock} from './actions';
10+
import type {YfmHtmlBlockTemplatesOptions} from './templates';
1011

1112
export interface YfmHtmlBlockOptions extends Omit<
1213
PluginOptions,
@@ -17,6 +18,7 @@ export interface YfmHtmlBlockOptions extends Omit<
1718
enabled: boolean;
1819
delay?: number; // по умолчанию 1000ms
1920
};
21+
templates?: YfmHtmlBlockTemplatesOptions;
2022
}
2123

2224
export const YfmHtmlBlock: ExtensionAuto<YfmHtmlBlockOptions> = (
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type {HtmlTemplate, YfmHtmlBlockTemplatesOptions} from './types';
2+
export {parseTemplates} from './parse';
3+
export {
4+
YFM_HTML_BLOCK_TEMPLATES_STORAGE_KEY,
5+
mergeTemplatesById,
6+
readStoredTemplates,
7+
saveTemplates,
8+
} from './storage';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {parseTemplates} from './parse';
2+
3+
describe('parseTemplates', () => {
4+
it('parses a single template with id and title', () => {
5+
const result = parseTemplates(
6+
'<template id="hero" title="Hero block"><div>Hello</div></template>',
7+
);
8+
9+
expect(result).toEqual([{id: 'hero', title: 'Hero block', content: '<div>Hello</div>'}]);
10+
});
11+
12+
it('parses multiple templates at once', () => {
13+
const result = parseTemplates(`
14+
<template id="a" title="First"><p>1</p></template>
15+
<template id="b" title="Second"><p>2</p></template>
16+
`);
17+
18+
expect(result).toHaveLength(2);
19+
expect(result[0]).toMatchObject({id: 'a', title: 'First', content: '<p>1</p>'});
20+
expect(result[1]).toMatchObject({id: 'b', title: 'Second', content: '<p>2</p>'});
21+
});
22+
23+
it('falls back title to id when title is missing', () => {
24+
const [template] = parseTemplates('<template id="only-id"><span>x</span></template>');
25+
26+
expect(template.id).toBe('only-id');
27+
expect(template.title).toBe('only-id');
28+
});
29+
30+
it('generates an id when it is missing', () => {
31+
const [template] = parseTemplates('<template title="No id"><span>x</span></template>');
32+
33+
expect(template.id).toBeTruthy();
34+
expect(template.title).toBe('No id');
35+
});
36+
37+
it('treats input without template tags as a single template', () => {
38+
const result = parseTemplates('<div class="card">card</div>');
39+
40+
expect(result).toHaveLength(1);
41+
expect(result[0].content).toBe('<div class="card">card</div>');
42+
expect(result[0].id).toBeTruthy();
43+
});
44+
45+
it('returns an empty array for blank input', () => {
46+
expect(parseTemplates(' ')).toEqual([]);
47+
expect(parseTemplates('')).toEqual([]);
48+
});
49+
});

0 commit comments

Comments
 (0)