diff --git a/demo/src/stories/examples/grid-block-templates/GridBlockTemplates.stories.tsx b/demo/src/stories/examples/grid-block-templates/GridBlockTemplates.stories.tsx new file mode 100644 index 00000000..79709059 --- /dev/null +++ b/demo/src/stories/examples/grid-block-templates/GridBlockTemplates.stories.tsx @@ -0,0 +1,11 @@ +import type {StoryObj} from '@storybook/react'; + +import {GridBlockTemplatesDemo as component} from './GridBlockTemplates'; + +export const Story: StoryObj = {}; +Story.storyName = 'Grid block templates'; + +export default { + title: 'Examples / Grid block templates', + component, +}; diff --git a/demo/src/stories/examples/grid-block-templates/GridBlockTemplates.tsx b/demo/src/stories/examples/grid-block-templates/GridBlockTemplates.tsx new file mode 100644 index 00000000..d7c59583 --- /dev/null +++ b/demo/src/stories/examples/grid-block-templates/GridBlockTemplates.tsx @@ -0,0 +1,73 @@ +import {memo} from 'react'; + +import {LayoutCells} from '@gravity-ui/icons'; +import { + MarkdownEditorView, + type ToolbarsPreset, + useMarkdownEditor, +} from '@gravity-ui/markdown-editor'; +import {ToolbarName as Toolbar} from '@gravity-ui/markdown-editor/_/modules/toolbars/constants.js'; +import {defaultPreset} from '@gravity-ui/markdown-editor/_/modules/toolbars/presets.js'; +import {GridBlockTemplates as GridBlockTemplatesExtension} from '@gravity-ui/markdown-editor/extensions/additional/GridBlockTemplates/index.js'; + +import {PlaygroundLayout} from '../../../components/PlaygroundLayout'; + +const gridBlockTemplatesItemId = 'gridBlockTemplates'; + +const toolbarsPreset: ToolbarsPreset = { + items: { + ...defaultPreset.items, + [gridBlockTemplatesItemId]: { + view: { + icon: {data: LayoutCells}, + title: 'Grid block templates', + }, + wysiwyg: { + exec: (e) => e.actions.createGridBlockTemplates.run(), + isActive: (e) => e.actions.createGridBlockTemplates.isActive(), + isEnable: (e) => e.actions.createGridBlockTemplates.isEnable(), + }, + }, + }, + orders: { + ...defaultPreset.orders, + [Toolbar.wysiwygMain]: [ + [gridBlockTemplatesItemId], + ...defaultPreset.orders[Toolbar.wysiwygMain], + ], + }, +}; + +export const GridBlockTemplatesDemo = memo(function GridBlockTemplatesDemo() { + const editor = useMarkdownEditor( + { + initial: {mode: 'wysiwyg', markup: ''}, + wysiwygConfig: { + extensions: (builder) => + builder.use(GridBlockTemplatesExtension, { + templates: { + showButton: true, + allowAdd: true, + }, + }), + }, + }, + [], + ); + + return ( + ( + + )} + /> + ); +}); diff --git a/packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplates.test.ts b/packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplates.test.ts new file mode 100644 index 00000000..5d981c35 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplates.test.ts @@ -0,0 +1,78 @@ +import {builders} from 'prosemirror-test-builder'; + +import {ExtensionsManager} from '../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../specs'; + +import {GridBlockTemplatesSpecs} from './GridBlockTemplatesSpecs'; +import {GridBlockTemplatesAttrs, gridBlockTemplatesNodeName} from './GridBlockTemplatesSpecs/const'; + +const {schema, serializer} = new ExtensionsManager({ + extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(GridBlockTemplatesSpecs, {}), +}).buildDeps(); + +const {doc, gridBlockTemplates} = builders<'doc' | 'gridBlockTemplates'>(schema, { + doc: {nodeType: BaseNode.Doc}, + gridBlockTemplates: {nodeType: gridBlockTemplatesNodeName}, +}); + +describe('GridBlockTemplates extension', () => { + it('should serialize blocks without inline styles', () => { + expect( + serializer.serialize( + doc( + gridBlockTemplates({ + [GridBlockTemplatesAttrs.blocks]: [ + {id: 'block-1', css: '', content: 'First'}, + ], + [GridBlockTemplatesAttrs.EntityId]: 'grid_block_templates-1', + }), + ), + ), + ).toBe( + [ + '::: html', + '
', + '
', + '
First
', + '
', + '
', + ':::', + ].join('\n'), + ); + }); + + it('should emit scoped container and per-block css into a style tag', () => { + expect( + serializer.serialize( + doc( + gridBlockTemplates({ + [GridBlockTemplatesAttrs.blocks]: [ + { + id: 'block-1', + css: '& { padding: 12px; }\nh3 { margin: 0; }', + content: 'First', + }, + ], + [GridBlockTemplatesAttrs.customCss]: '.grid { align-items: center; }', + [GridBlockTemplatesAttrs.EntityId]: 'grid_block_templates-1', + }), + ), + ), + ).toBe( + [ + '::: html', + '
', + ' ', + '
', + '
First
', + '
', + '
', + ':::', + ].join('\n'), + ); + }); +}); diff --git a/packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/BlockInsertPopup.tsx b/packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/BlockInsertPopup.tsx new file mode 100644 index 00000000..86e53796 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlockTemplates/GridBlockTemplatesNodeView/BlockInsertPopup.tsx @@ -0,0 +1,121 @@ +import {useState} from 'react'; + +import {Code} from '@gravity-ui/icons'; +import {Button, Icon, Menu, Popup, TextInput} from '@gravity-ui/uikit'; + +import {TextAreaFixed as TextArea} from 'src/forms/TextInput'; +import {i18n} from 'src/i18n/grid-block-templates'; + +import {parseRawBlock} from '../templates'; +import type {GridBlockBlockTemplate, GridBlockTemplateBlock} from '../types'; + +import {GroupedTemplatesMenuItems} from './GroupedTemplatesMenuItems'; +import {STOP_EVENT_CLASSNAME, cnGridBlockTemplates} from './const'; + +const b = cnGridBlockTemplates; +const stop = STOP_EVENT_CLASSNAME; + +interface BlockInsertPopupProps { + anchor: HTMLElement | null; + open: boolean; + templates: GridBlockBlockTemplate[]; + onClose: () => void; + onApplyTemplate: (template: GridBlockBlockTemplate) => void; + onApplyHtml: (block: GridBlockTemplateBlock) => void; +} + +export const BlockInsertPopup: React.FC = ({ + anchor, + open, + templates, + onClose, + onApplyTemplate, + onApplyHtml, +}) => { + const [addingCustomHtml, setAddingCustomHtml] = useState(false); + const [input, setInput] = useState(''); + const [filter, setFilter] = useState(''); + const showCustomHtmlEditor = addingCustomHtml || templates.length === 0; + + const close = () => { + setAddingCustomHtml(false); + setInput(''); + setFilter(''); + onClose(); + }; + + const handleApplyHtml = () => { + onApplyHtml(parseRawBlock(input)); + close(); + }; + + return ( + +
+ {showCustomHtmlEditor ? ( +
+