From 339a2bbf5cb178631540547ab7dc5997a5d7f407 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 10 Jun 2026 21:37:38 +0200 Subject: [PATCH 1/2] feat(GridBlock): add Pretty Pages grid extension with templates Add a prototype WYSIWYG "grid" extension: a toolbar button inserts a container node; a "+" button adds numbered blocks (block-N); each block and the container have a gear popup for CSS; clicking a block edits its text. A templates menu next to the gear applies one of 10 ready-made layouts/styles in one click. The node serializes to a fenced ```html block. Wired up via a dedicated demo story. Co-Authored-By: Claude Opus 4.8 --- .../examples/grid-block/GridBlock.stories.tsx | 11 + .../stories/examples/grid-block/GridBlock.tsx | 62 +++++ .../GridBlockNodeView/GridBlock.scss | 92 +++++++ .../GridBlockNodeView/GridBlockView.tsx | 232 ++++++++++++++++ .../GridBlock/GridBlockNodeView/NodeView.tsx | 103 +++++++ .../GridBlock/GridBlockNodeView/index.ts | 1 + .../GridBlock/GridBlockNodeView/templates.ts | 259 ++++++++++++++++++ .../GridBlock/GridBlockSpecs/const.ts | 24 ++ .../GridBlock/GridBlockSpecs/index.ts | 83 ++++++ .../additional/GridBlock/actions.ts | 24 ++ .../extensions/additional/GridBlock/index.ts | 23 ++ .../extensions/additional/GridBlock/types.ts | 6 + 12 files changed, 920 insertions(+) create mode 100644 demo/src/stories/examples/grid-block/GridBlock.stories.tsx create mode 100644 demo/src/stories/examples/grid-block/GridBlock.tsx create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/GridBlock.scss create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/GridBlockView.tsx create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/NodeView.tsx create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/index.ts create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/templates.ts create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/const.ts create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts create mode 100644 packages/editor/src/extensions/additional/GridBlock/actions.ts create mode 100644 packages/editor/src/extensions/additional/GridBlock/index.ts create mode 100644 packages/editor/src/extensions/additional/GridBlock/types.ts diff --git a/demo/src/stories/examples/grid-block/GridBlock.stories.tsx b/demo/src/stories/examples/grid-block/GridBlock.stories.tsx new file mode 100644 index 000000000..f837b9399 --- /dev/null +++ b/demo/src/stories/examples/grid-block/GridBlock.stories.tsx @@ -0,0 +1,11 @@ +import type {StoryObj} from '@storybook/react'; + +import {GridBlockDemo as component} from './GridBlock'; + +export const Story: StoryObj = {}; +Story.storyName = 'Grid block'; + +export default { + title: 'Examples / Grid block', + component, +}; diff --git a/demo/src/stories/examples/grid-block/GridBlock.tsx b/demo/src/stories/examples/grid-block/GridBlock.tsx new file mode 100644 index 000000000..789516322 --- /dev/null +++ b/demo/src/stories/examples/grid-block/GridBlock.tsx @@ -0,0 +1,62 @@ +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 {GridBlock as GridBlockExtension} from '@gravity-ui/markdown-editor/extensions/additional/GridBlock/index.js'; + +import {PlaygroundLayout} from '../../../components/PlaygroundLayout'; + +const gridBlockItemId = 'gridBlock'; + +const toolbarsPreset: ToolbarsPreset = { + items: { + ...defaultPreset.items, + [gridBlockItemId]: { + view: { + icon: {data: LayoutCells}, + title: 'Grid block', + }, + wysiwyg: { + exec: (e) => e.actions.createGridBlock.run(), + isActive: (e) => e.actions.createGridBlock.isActive(), + isEnable: (e) => e.actions.createGridBlock.isEnable(), + }, + }, + }, + orders: { + ...defaultPreset.orders, + [Toolbar.wysiwygMain]: [[gridBlockItemId], ...defaultPreset.orders[Toolbar.wysiwygMain]], + }, +}; + +export const GridBlockDemo = memo(function GridBlockDemo() { + const editor = useMarkdownEditor( + { + initial: {mode: 'wysiwyg', markup: ''}, + wysiwygConfig: {extensions: GridBlockExtension}, + }, + [], + ); + + return ( + ( + + )} + /> + ); +}); diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/GridBlock.scss b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/GridBlock.scss new file mode 100644 index 000000000..c46daa7a7 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/GridBlock.scss @@ -0,0 +1,92 @@ +.g-md-grid-block { + position: relative; + + margin-bottom: 15px; + padding: 32px 12px 12px; + + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-m); + + &__toolbar { + position: absolute; + top: 4px; + right: 4px; + + display: flex; + gap: 2px; + } + + &__grid { + // default layout; overridden by the scoped + +
+ + + +
+ + setTemplatesOpen(false)} + placement="bottom-end" + > + + {GRID_TEMPLATES.map((template) => ( + applyTemplate(template)} + > + {template.title} + + ))} + + + +
+ {blocks.map((block, i) => ( +
+ +
patchBlock(block.id, {text: e.currentTarget.innerText})} + > + {block.text} +
+
+ ))} + + +
+ + setContainerCssOpen(false)} + value={containerCss} + onChange={(value) => onChange({[GridBlockConsts.NodeAttrs.containerCss]: value})} + placeholder={'grid-template-columns: 1fr 1fr;\ngap: 12px;'} + /> + + {editingBlock && ( + setEditingBlockId(null)} + value={editingBlock.css} + onChange={(value) => patchBlock(editingBlock.id, {css: value})} + placeholder={'background: #eee;\nborder-radius: 8px;'} + /> + )} + + ); +}; diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/NodeView.tsx b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/NodeView.tsx new file mode 100644 index 000000000..01806a6b5 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/NodeView.tsx @@ -0,0 +1,103 @@ +import {Portal} from '@gravity-ui/uikit'; +import type {Node} from 'prosemirror-model'; +import type {EditorView, NodeView} from 'prosemirror-view'; + +import {getReactRendererFromState} from 'src/extensions/behavior/ReactRenderer'; +import {generateEntityId, isInvalidEntityId} from 'src/utils/entity-id'; + +import {GridBlockConsts, defaultGridBlockEntityId} from '../GridBlockSpecs/const'; + +import {GridBlockView, STOP_EVENT_CLASSNAME} from './GridBlockView'; + +export class WGridBlockNodeView implements NodeView { + readonly dom: HTMLElement; + private node: Node; + private readonly view: EditorView; + private readonly getPos: () => number | undefined; + private readonly renderItem; + + constructor({ + node, + view, + getPos, + }: { + node: Node; + view: EditorView; + getPos: () => number | undefined; + }) { + this.node = node; + this.dom = document.createElement('div'); + this.dom.classList.add('grid-block-container'); + this.dom.contentEditable = 'false'; + this.view = view; + this.getPos = getPos; + + this.renderItem = getReactRendererFromState(view.state).createItem( + 'gridBlock-view', + this.renderGridBlock.bind(this), + ); + + this.validateEntityId(); + } + + update(node: Node) { + if (node.type !== this.node.type) return false; + this.node = node; + this.renderItem.rerender(); + return true; + } + + destroy() { + this.renderItem.remove(); + } + + ignoreMutation() { + return true; + } + + stopEvent(e: Event) { + const target = e.target as Element; + return Boolean(target.closest?.(`.${STOP_EVENT_CLASSNAME}`)); + } + + private validateEntityId() { + if ( + isInvalidEntityId({ + node: this.node, + doc: this.view.state.doc, + defaultId: defaultGridBlockEntityId, + }) + ) { + const newId = generateEntityId(GridBlockConsts.NodeName); + this.view.dispatch( + this.view.state.tr.setNodeAttribute( + this.getPos()!, + GridBlockConsts.NodeAttrs.EntityId, + newId, + ), + ); + } + } + + private onChange(attrs: Partial) { + const pos = this.getPos(); + if (pos === undefined) return; + + this.view.dispatch( + this.view.state.tr.setNodeMarkup(pos, undefined, {...this.node.attrs, ...attrs}, []), + ); + } + + private renderGridBlock() { + return ( + + + + ); + } +} diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/index.ts b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/index.ts new file mode 100644 index 000000000..1bf4b14d4 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/index.ts @@ -0,0 +1 @@ +export * from './NodeView'; diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/templates.ts b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/templates.ts new file mode 100644 index 000000000..941b57312 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockNodeView/templates.ts @@ -0,0 +1,259 @@ +export type GridTemplateBlock = {text: string; css: string}; + +export type GridTemplate = { + id: string; + title: string; + /** layout-only CSS for the grid container */ + containerCss: string; + blocks: GridTemplateBlock[]; +}; + +const layout = (extra: string) => `display: grid; gap: 12px; padding: 12px; ${extra}`; +const fullWidth = 'grid-column: 1 / -1;'; + +export const GRID_TEMPLATES: GridTemplate[] = [ + // 1. Solid filled header on white columns, bold large heading + { + id: 'solid-header-white', + title: 'Solid header · white cards', + containerCss: layout('grid-template-columns: repeat(3, 1fr);'), + blocks: [ + { + text: 'Main heading', + css: `${fullWidth} padding: 28px 32px; font-size: 30px; font-weight: 800; line-height: 1.1; letter-spacing: -0.5px; color: #fff; background: #2563eb; border-radius: 14px;`, + }, + { + text: 'First', + css: 'padding: 20px; color: #1f2937; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;', + }, + { + text: 'Second', + css: 'padding: 20px; color: #1f2937; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;', + }, + { + text: 'Third', + css: 'padding: 20px; color: #1f2937; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;', + }, + ], + }, + + // 2. Long columns (tall full-height columns) + { + id: 'long-columns', + title: 'Long columns', + containerCss: layout('grid-template-columns: repeat(3, 1fr); grid-auto-rows: 320px;'), + blocks: [ + { + text: 'Column one', + css: 'padding: 24px; min-height: 320px; color: #0f172a; background: #f8fafc; border-top: 4px solid #0ea5e9; border-radius: 10px;', + }, + { + text: 'Column two', + css: 'padding: 24px; min-height: 320px; color: #0f172a; background: #f8fafc; border-top: 4px solid #6366f1; border-radius: 10px;', + }, + { + text: 'Column three', + css: 'padding: 24px; min-height: 320px; color: #0f172a; background: #f8fafc; border-top: 4px solid #ec4899; border-radius: 10px;', + }, + ], + }, + + // 3. Dark theme — dark block backgrounds, light text + { + id: 'dark', + title: 'Dark theme', + containerCss: layout( + 'grid-template-columns: repeat(3, 1fr); background: #0b1120; border-radius: 14px;', + ), + blocks: [ + { + text: 'Header', + css: `${fullWidth} padding: 24px; font-size: 24px; font-weight: 700; color: #f8fafc; background: #1e293b; border: 1px solid #334155; border-radius: 12px;`, + }, + { + text: 'One', + css: 'padding: 20px; color: #cbd5e1; background: #111827; border: 1px solid #334155; border-radius: 12px;', + }, + { + text: 'Two', + css: 'padding: 20px; color: #cbd5e1; background: #111827; border: 1px solid #334155; border-radius: 12px;', + }, + { + text: 'Three', + css: 'padding: 20px; color: #cbd5e1; background: #111827; border: 1px solid #334155; border-radius: 12px;', + }, + ], + }, + + // 4. Festive (kitsch) — gold on deep red, ornamental double borders + { + id: 'festive', + title: 'Festive theme', + containerCss: layout( + 'grid-template-columns: repeat(3, 1fr); background: #5b0a0a; border: 4px double #ffd700; border-radius: 16px;', + ), + blocks: [ + { + text: '✦ Праздник ✦', + css: `${fullWidth} padding: 22px; font-size: 26px; font-weight: 800; text-align: center; letter-spacing: 1px; color: #ffd700; text-shadow: 0 1px 0 #7a0000; background: linear-gradient(135deg, #8b0000, #b8860b); border: 3px double #ffd700; border-radius: 12px;`, + }, + { + text: '❖ Узор', + css: 'padding: 18px; text-align: center; color: #fff6d5; background: #7a0000; border: 2px dashed #ffd700; border-radius: 12px;', + }, + { + text: '❖ Орнамент', + css: 'padding: 18px; text-align: center; color: #7a0000; background: #ffd700; border: 2px solid #8b0000; border-radius: 12px;', + }, + { + text: '❖ Золото', + css: 'padding: 18px; text-align: center; color: #fff6d5; background: #7a0000; border: 2px dashed #ffd700; border-radius: 12px;', + }, + { + text: '✦ Festive footer ✦', + css: `${fullWidth} padding: 14px; text-align: center; font-weight: 700; color: #5b0a0a; background: linear-gradient(90deg, #ffd700, #f0c000, #ffd700); border-radius: 12px;`, + }, + ], + }, + + // 5. Minimal — hairlines, no fill, lots of whitespace + { + id: 'minimal', + title: 'Minimal', + containerCss: layout('grid-template-columns: repeat(2, 1fr); gap: 24px; padding: 24px;'), + blocks: [ + { + text: 'Left', + css: 'padding: 8px 0 24px; color: #111; background: transparent; border-bottom: 1px solid #111;', + }, + { + text: 'Right', + css: 'padding: 8px 0 24px; color: #111; background: transparent; border-bottom: 1px solid #111;', + }, + ], + }, + + // 6. Pastel — soft tinted cards, no borders + { + id: 'pastel', + title: 'Pastel cards', + containerCss: layout('grid-template-columns: repeat(2, 1fr);'), + blocks: [ + { + text: 'Peach', + css: 'padding: 22px; color: #7c2d12; background: #ffedd5; border-radius: 16px;', + }, + { + text: 'Mint', + css: 'padding: 22px; color: #065f46; background: #d1fae5; border-radius: 16px;', + }, + { + text: 'Lavender', + css: 'padding: 22px; color: #5b21b6; background: #ede9fe; border-radius: 16px;', + }, + { + text: 'Sky', + css: 'padding: 22px; color: #075985; background: #e0f2fe; border-radius: 16px;', + }, + ], + }, + + // 7. Gradient cards — vivid diagonal gradients, white text + { + id: 'gradient', + title: 'Gradient cards', + containerCss: layout('grid-template-columns: repeat(3, 1fr);'), + blocks: [ + { + text: 'Sunset', + css: 'padding: 26px; color: #fff; font-weight: 600; background: linear-gradient(135deg, #f97316, #db2777); border-radius: 16px;', + }, + { + text: 'Ocean', + css: 'padding: 26px; color: #fff; font-weight: 600; background: linear-gradient(135deg, #0ea5e9, #6366f1); border-radius: 16px;', + }, + { + text: 'Forest', + css: 'padding: 26px; color: #fff; font-weight: 600; background: linear-gradient(135deg, #22c55e, #0d9488); border-radius: 16px;', + }, + ], + }, + + // 8. Neon — dark bg + glowing cyan/magenta outlines + { + id: 'neon', + title: 'Neon', + containerCss: layout( + 'grid-template-columns: repeat(2, 1fr); background: #05010f; border-radius: 14px;', + ), + blocks: [ + { + text: 'CYBER', + css: `${fullWidth} padding: 22px; text-align: center; font-size: 24px; font-weight: 800; letter-spacing: 4px; color: #22d3ee; background: #0a0a1a; border: 2px solid #22d3ee; border-radius: 12px; box-shadow: 0 0 16px rgba(34,211,238,0.7);`, + }, + { + text: 'NODE 01', + css: 'padding: 22px; color: #f0abfc; background: #0a0a1a; border: 2px solid #e879f9; border-radius: 12px; box-shadow: 0 0 14px rgba(232,121,249,0.6);', + }, + { + text: 'NODE 02', + css: 'padding: 22px; color: #5eead4; background: #0a0a1a; border: 2px solid #2dd4bf; border-radius: 12px; box-shadow: 0 0 14px rgba(45,212,191,0.6);', + }, + ], + }, + + // 9. Newspaper — sepia, serif, ruled columns + { + id: 'newspaper', + title: 'Newspaper', + containerCss: layout( + 'grid-template-columns: 2fr 1fr; gap: 0; background: #f4ecd8; border: 2px solid #3a2f1b;', + ), + blocks: [ + { + text: 'THE DAILY GRID', + css: `${fullWidth} padding: 18px; text-align: center; font-family: Georgia, serif; font-size: 30px; font-weight: 700; letter-spacing: 2px; color: #2b2317; background: #f4ecd8; border-bottom: 3px double #3a2f1b;`, + }, + { + text: 'Lead story', + css: 'padding: 18px; font-family: Georgia, serif; color: #2b2317; background: #f4ecd8; border-right: 1px solid #3a2f1b;', + }, + { + text: 'Sidebar', + css: 'padding: 18px; font-family: Georgia, serif; color: #2b2317; background: #efe6cf;', + }, + ], + }, + + // 10. Header / 3 columns / footer using grid-template-areas → swap header & footer + // by simply reordering the rows in grid-template-areas (see chat answer). + { + id: 'areas-header-footer', + title: 'Header · 3 cols · footer (areas)', + containerCss: layout( + 'grid-template-columns: repeat(3, 1fr); grid-template-areas: "head head head" "c1 c2 c3" "foot foot foot";', + ), + blocks: [ + { + text: 'Header', + css: 'grid-area: head; padding: 22px; font-size: 22px; font-weight: 700; color: #fff; background: #4338ca; border-radius: 12px;', + }, + { + text: 'One', + css: 'grid-area: c1; padding: 18px; color: #312e81; background: #eef2ff; border-radius: 12px;', + }, + { + text: 'Two', + css: 'grid-area: c2; padding: 18px; color: #312e81; background: #eef2ff; border-radius: 12px;', + }, + { + text: 'Three', + css: 'grid-area: c3; padding: 18px; color: #312e81; background: #eef2ff; border-radius: 12px;', + }, + { + text: 'Footer', + css: 'grid-area: foot; padding: 16px; color: #fff; background: #6366f1; border-radius: 12px;', + }, + ], + }, +]; diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/const.ts b/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/const.ts new file mode 100644 index 000000000..096bc8232 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/const.ts @@ -0,0 +1,24 @@ +import {entityIdAttr} from 'src/utils/entity-id'; +import {nodeTypeFactory} from 'src/utils/schema'; + +export enum GridBlockAttrs { + // @ts-expect-error error TS18055 + EntityId = entityIdAttr, + /** Array of grid blocks (see GridBlock type) */ + blocks = 'blocks', + /** CSS rules applied to the grid viewport */ + containerCss = 'containerCss', +} + +export const gridBlockNodeName = 'grid_block'; +export const gridBlockNodeType = nodeTypeFactory(gridBlockNodeName); + +export const GridBlockAction = 'createGridBlock'; + +export const GridBlockConsts = { + NodeName: gridBlockNodeName, + NodeAttrs: GridBlockAttrs, + nodeType: gridBlockNodeType, +} as const; + +export const defaultGridBlockEntityId = gridBlockNodeName + '#0'; diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts b/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts new file mode 100644 index 000000000..602df4328 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts @@ -0,0 +1,83 @@ +import type {Node} from 'prosemirror-model'; + +import type {ExtensionAuto, ExtensionNodeSpec} from '#core'; + +import type {GridBlock} from '../types'; + +import {GridBlockConsts, defaultGridBlockEntityId, gridBlockNodeName} from './const'; + +export {gridBlockNodeName, GridBlockConsts} from './const'; + +export type GridBlockSpecsOptions = { + nodeView?: ExtensionNodeSpec['view']; +}; + +const readBlocks = (node: Node): GridBlock[] => { + const value = node.attrs[GridBlockConsts.NodeAttrs.blocks]; + return Array.isArray(value) ? value : []; +}; + +const indent = (text: string, by = ' ') => + text + .split('\n') + .map((line) => (line ? by + line : line)) + .join('\n'); + +/** Assembles the static HTML the prototype writes into a fenced ```html block. */ +export const buildGridHtml = (node: Node): string => { + const containerCss: string = node.attrs[GridBlockConsts.NodeAttrs.containerCss] || ''; + const containerStyle = containerCss.trim() ? ` style="${containerCss.trim()}"` : ''; + + const blocks = readBlocks(node) + .map((block, i) => { + const style = block.css.trim() ? ` style="${block.css.trim()}"` : ''; + const text = block.text ?? ''; + return indent(`
${text}
`); + }) + .join('\n'); + + return `
\n${blocks}\n
`; +}; + +const GridBlockSpecsExtension: ExtensionAuto = (builder, {nodeView}) => { + builder.addNode(gridBlockNodeName, () => ({ + // PROTOTYPE: the node is created only via the toolbar action; no markdown + // token is emitted for it, so this token spec is inert (kept to satisfy the type). + fromMd: { + tokenSpec: {name: gridBlockNodeName, type: 'node', noCloseToken: true}, + }, + spec: { + atom: true, + selectable: true, + group: 'block', + attrs: { + [GridBlockConsts.NodeAttrs.blocks]: {default: []}, + [GridBlockConsts.NodeAttrs.containerCss]: {default: ''}, + [GridBlockConsts.NodeAttrs.EntityId]: {default: defaultGridBlockEntityId}, + }, + parseDOM: [], + toDOM(node) { + return [ + 'div', + { + class: 'grid-block', + [GridBlockConsts.NodeAttrs.EntityId]: + node.attrs[GridBlockConsts.NodeAttrs.EntityId], + }, + ]; + }, + dnd: {props: {offset: [8, 1]}}, + }, + toMd: (state, node) => { + state.write('```html'); + state.ensureNewLine(); + state.write(buildGridHtml(node)); + state.ensureNewLine(); + state.write('```'); + state.closeBlock(node); + }, + view: nodeView, + })); +}; + +export const GridBlockSpecs = Object.assign(GridBlockSpecsExtension, GridBlockConsts); diff --git a/packages/editor/src/extensions/additional/GridBlock/actions.ts b/packages/editor/src/extensions/additional/GridBlock/actions.ts new file mode 100644 index 000000000..d719c22b2 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/actions.ts @@ -0,0 +1,24 @@ +import type {ActionSpec} from '#core'; +import {generateEntityId} from 'src/utils/entity-id'; + +import {GridBlockConsts, gridBlockNodeType} from './GridBlockSpecs/const'; + +export const addGridBlock: ActionSpec = { + isEnable(state) { + return state.selection.empty; + }, + run(state, dispatch) { + const entityId = generateEntityId(GridBlockConsts.NodeName); + + const tr = state.tr.insert( + state.selection.from, + gridBlockNodeType(state.schema).create({ + [GridBlockConsts.NodeAttrs.blocks]: [], + [GridBlockConsts.NodeAttrs.containerCss]: '', + [GridBlockConsts.NodeAttrs.EntityId]: entityId, + }), + ); + + dispatch(tr); + }, +}; diff --git a/packages/editor/src/extensions/additional/GridBlock/index.ts b/packages/editor/src/extensions/additional/GridBlock/index.ts new file mode 100644 index 000000000..c8e6a4457 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/index.ts @@ -0,0 +1,23 @@ +import type {Action, ExtensionAuto, ExtensionDeps, NodeViewConstructor} from '../../../core'; + +import {WGridBlockNodeView} from './GridBlockNodeView'; +import {GridBlockSpecs} from './GridBlockSpecs'; +import {GridBlockAction} from './GridBlockSpecs/const'; +import {addGridBlock} from './actions'; + +export const GridBlock: ExtensionAuto = (builder) => { + builder.use(GridBlockSpecs, {nodeView: gridBlockNodeViewFactory()}); + builder.addAction(GridBlockAction, () => addGridBlock); +}; + +const gridBlockNodeViewFactory: () => (deps: ExtensionDeps) => NodeViewConstructor = + () => () => (node, view, getPos) => + new WGridBlockNodeView({node, view, getPos}); + +declare global { + namespace WysiwygEditor { + interface Actions { + [GridBlockAction]: Action; + } + } +} diff --git a/packages/editor/src/extensions/additional/GridBlock/types.ts b/packages/editor/src/extensions/additional/GridBlock/types.ts new file mode 100644 index 000000000..623973f3f --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/types.ts @@ -0,0 +1,6 @@ +export type GridBlock = { + id: string; + /** Inline CSS applied to this block (background, border-radius, …) */ + css: string; + text: string; +}; From ca9d70b4136390221d14076dc8e122038ffdbc38 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 11 Jun 2026 14:55:26 +0200 Subject: [PATCH 2/2] Serialize grid block as YFM HTML --- .../additional/GridBlock/GridBlock.test.ts | 46 +++++++++++++++++++ .../GridBlock/GridBlockSpecs/index.ts | 8 ++-- 2 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 packages/editor/src/extensions/additional/GridBlock/GridBlock.test.ts diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlock.test.ts b/packages/editor/src/extensions/additional/GridBlock/GridBlock.test.ts new file mode 100644 index 000000000..68b4d0fa9 --- /dev/null +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlock.test.ts @@ -0,0 +1,46 @@ +import {builders} from 'prosemirror-test-builder'; + +import {ExtensionsManager} from '../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../specs'; + +import {GridBlockAttrs, gridBlockNodeName} from './GridBlockSpecs/const'; +import {GridBlockSpecs} from './GridBlockSpecs'; + +const {schema, serializer} = new ExtensionsManager({ + extensions: (builder) => builder.use(BaseSchemaSpecs, {}).use(GridBlockSpecs, {}), +}).buildDeps(); + +const {doc, gridBlock} = builders<'doc' | 'gridBlock'>(schema, { + doc: {nodeType: BaseNode.Doc}, + gridBlock: {nodeType: gridBlockNodeName}, +}); + +describe('GridBlock extension', () => { + it('should serialize to yfm html block', () => { + expect( + serializer.serialize( + doc( + gridBlock({ + [GridBlockAttrs.blocks]: [ + { + id: 'block-1', + css: 'padding: 12px;', + text: 'First', + }, + ], + [GridBlockAttrs.containerCss]: 'display: grid;', + [GridBlockAttrs.EntityId]: 'grid_block-1', + }), + ), + ), + ).toBe( + [ + '::: html', + '
', + '
First
', + '
', + ':::', + ].join('\n'), + ); + }); +}); diff --git a/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts b/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts index 602df4328..43db2914e 100644 --- a/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts +++ b/packages/editor/src/extensions/additional/GridBlock/GridBlockSpecs/index.ts @@ -23,7 +23,7 @@ const indent = (text: string, by = ' ') => .map((line) => (line ? by + line : line)) .join('\n'); -/** Assembles the static HTML the prototype writes into a fenced ```html block. */ +/** Assembles the static HTML the prototype writes into a YFM HTML block. */ export const buildGridHtml = (node: Node): string => { const containerCss: string = node.attrs[GridBlockConsts.NodeAttrs.containerCss] || ''; const containerStyle = containerCss.trim() ? ` style="${containerCss.trim()}"` : ''; @@ -69,11 +69,11 @@ const GridBlockSpecsExtension: ExtensionAuto = (builder, dnd: {props: {offset: [8, 1]}}, }, toMd: (state, node) => { - state.write('```html'); - state.ensureNewLine(); + state.write('::: html'); + state.write('\n'); state.write(buildGridHtml(node)); state.ensureNewLine(); - state.write('```'); + state.write(':::'); state.closeBlock(node); }, view: nodeView,