From 3cc502c59ac97a74abe31854df2da7f15ecd6e70 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Wed, 16 Sep 2026 22:11:07 +0200 Subject: [PATCH] feat: add experimental YFM HTML Constructor extension --- demo/.storybook/main.ts | 4 + .../gravity-ui-landing.html | 965 +++++++++++++ .../defaults/yfm-html-constructor/index.ts | 38 + demo/src/global.d.ts | 5 + .../yfm/YfmHtmlConstructor.stories.tsx | 16 + demo/src/stories/yfm/YfmHtmlConstructor.tsx | 77 + .../YfmHtmlConstructor.helpers.tsx | 71 + .../YfmHtmlConstructor.visual.test.tsx | 244 ++++ docs/yfm-html-constructor-theming.md | 139 ++ packages/editor/package.json | 3 +- .../YfmHtmlBlockNodeView/TemplatesPopup.scss | 3 +- .../YfmHtmlConstructor.test.ts | 103 ++ .../BlockTemplatesPanel.tsx | 105 ++ .../CodeEditorPane.tsx | 130 ++ .../CodeSettingsPanel.tsx | 144 ++ .../CustomTemplateEditor.tsx | 90 ++ .../FloatingToolbar.tsx | 261 ++++ .../HtmlBlockItem.tsx | 291 ++++ .../InlineElementEditor.tsx | 279 ++++ .../YfmHtmlConstructorNodeView/NodeView.tsx | 120 ++ .../StructurePanel.tsx | 176 +++ .../StructureTemplatesPanel.scss | 382 +++++ .../StructureTemplatesPanel.tsx | 184 +++ .../TemplatePicker.tsx | 334 +++++ .../TemplatePreview.tsx | 56 + .../ThemePickerPanel.tsx | 53 + .../YfmHtmlConstructor.scss | 1266 +++++++++++++++++ .../YfmHtmlConstructorView.tsx | 220 +++ .../YfmHtmlConstructorNodeView/const.ts | 4 + .../constructorCommands.test.tsx | 131 ++ .../constructorUtils.test.ts | 651 +++++++++ .../YfmHtmlConstructorNodeView/drag.ts | 159 +++ .../groupTemplates.test.ts | 79 + .../groupTemplates.ts | 151 ++ .../YfmHtmlConstructorNodeView/iconLibrary.ts | 97 ++ .../YfmHtmlConstructorNodeView/index.ts | 1 + .../textEditing.test.ts | 235 +++ .../YfmHtmlConstructorNodeView/textEditing.ts | 212 +++ .../toolbar/QuickStyleControls.tsx | 252 ++++ .../toolbar/ToolbarButton.tsx | 27 + .../toolbar/toolbarLayout.test.ts | 38 + .../toolbar/useToolbarLayout.ts | 118 ++ .../YfmHtmlConstructorNodeView/useConfirm.tsx | 73 + .../useConstructorCommands.ts | 98 ++ .../useInlineHtmlEditing.tsx | 273 ++++ .../YfmHtmlConstructorSpecs/const.ts | 24 + .../YfmHtmlConstructorSpecs/index.ts | 184 +++ .../additional/YfmHtmlConstructor/actions.ts | 28 + .../additional/YfmHtmlConstructor/const.ts | 1 + .../constructorHooks.test.tsx | 153 ++ .../additional/YfmHtmlConstructor/css.test.ts | 62 + .../additional/YfmHtmlConstructor/css.ts | 113 ++ .../YfmHtmlConstructor/cssVariables.ts | 118 ++ .../additional/YfmHtmlConstructor/document.ts | 148 ++ .../additional/YfmHtmlConstructor/index.ts | 31 + .../additional/YfmHtmlConstructor/model.ts | 104 ++ .../YfmHtmlConstructor/preferences.ts | 133 ++ .../YfmHtmlConstructor/quickStyle.ts | 172 +++ .../additional/YfmHtmlConstructor/settings.ts | 67 + .../YfmHtmlConstructor/templates/index.ts | 27 + .../templates/parse.test.ts | 226 +++ .../YfmHtmlConstructor/templates/parse.ts | 350 +++++ .../YfmHtmlConstructor/templates/preview.ts | 51 + .../YfmHtmlConstructor/templates/state.ts | 193 +++ .../templates/storage.test.ts | 225 +++ .../YfmHtmlConstructor/templates/storage.ts | 208 +++ .../templates/useTemplateCatalog.ts | 29 + .../additional/YfmHtmlConstructor/types.ts | 141 ++ .../src/i18n/yfm-html-constructor/en.json | 121 ++ .../src/i18n/yfm-html-constructor/index.ts | 8 + .../src/i18n/yfm-html-constructor/ru.json | 121 ++ pnpm-lock.yaml | 9 +- 72 files changed, 11399 insertions(+), 6 deletions(-) create mode 100644 demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html create mode 100644 demo/src/defaults/yfm-html-constructor/index.ts create mode 100644 demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx create mode 100644 demo/src/stories/yfm/YfmHtmlConstructor.tsx create mode 100644 demo/tests/visual-tests/YfmHtmlConstructor.helpers.tsx create mode 100644 demo/tests/visual-tests/YfmHtmlConstructor.visual.test.tsx create mode 100644 docs/yfm-html-constructor-theming.md create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/CodeEditorPane.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/CodeSettingsPanel.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/CustomTemplateEditor.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/FloatingToolbar.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/HtmlBlockItem.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/InlineElementEditor.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/NodeView.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/StructurePanel.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/StructureTemplatesPanel.scss create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/StructureTemplatesPanel.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/TemplatePicker.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/TemplatePreview.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/ThemePickerPanel.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/YfmHtmlConstructor.scss create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/YfmHtmlConstructorView.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/const.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/constructorCommands.test.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/constructorUtils.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/drag.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/groupTemplates.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/iconLibrary.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/index.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/textEditing.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/QuickStyleControls.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/ToolbarButton.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/toolbarLayout.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/toolbar/useToolbarLayout.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConfirm.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useConstructorCommands.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/useInlineHtmlEditing.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/const.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorSpecs/index.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/actions.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/const.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/constructorHooks.test.tsx create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/css.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/css.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/cssVariables.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/document.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/index.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/model.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/preferences.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/quickStyle.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/settings.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/index.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/parse.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/preview.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/state.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.test.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/storage.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/templates/useTemplateCatalog.ts create mode 100644 packages/editor/src/extensions/additional/YfmHtmlConstructor/types.ts create mode 100644 packages/editor/src/i18n/yfm-html-constructor/en.json create mode 100644 packages/editor/src/i18n/yfm-html-constructor/index.ts create mode 100644 packages/editor/src/i18n/yfm-html-constructor/ru.json diff --git a/demo/.storybook/main.ts b/demo/.storybook/main.ts index 98ee7fce7..23901face 100644 --- a/demo/.storybook/main.ts +++ b/demo/.storybook/main.ts @@ -60,6 +60,10 @@ const config: StorybookConfig = { type: 'asset/resource' as const, generator: {emit: false}, }); + config.module.rules.push({ + test: /\.html$/, + type: 'asset/source' as const, + }); config.watchOptions ||= {}; config.watchOptions.ignored = /node_modules([\\]+|\/)+(?!@gravity-ui\/markdown-editor)/; diff --git a/demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html b/demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html new file mode 100644 index 000000000..92dd4d568 --- /dev/null +++ b/demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html @@ -0,0 +1,965 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/src/defaults/yfm-html-constructor/index.ts b/demo/src/defaults/yfm-html-constructor/index.ts new file mode 100644 index 000000000..2280d39b7 --- /dev/null +++ b/demo/src/defaults/yfm-html-constructor/index.ts @@ -0,0 +1,38 @@ +import { + YFM_HTML_CONSTRUCTOR_STORAGE_KEY, + parseTemplates, + saveTemplates, +} from '@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/templates/index.js'; + +import gravityUiLanding from './gravity-ui-landing.html?raw'; + +const SEEDED_FLAG_KEY = 'gravity-md-editor:yfm-html-constructor:demo-seeded'; +let seeded = false; + +/** Seed once so clearing templates remains effective, including without storage. */ +export const seedYfmHtmlConstructorTemplates = (): void => { + if (typeof window === 'undefined' || seeded) return; + + try { + if (window.localStorage.getItem(SEEDED_FLAG_KEY)) { + seeded = true; + return; + } + } catch { + // The template store also works when persistence is unavailable. + } + + const templates = saveTemplates(parseTemplates(gravityUiLanding)); + seeded = true; + + try { + if ( + window.localStorage.getItem(YFM_HTML_CONSTRUCTOR_STORAGE_KEY) === + JSON.stringify(templates) + ) { + window.localStorage.setItem(SEEDED_FLAG_KEY, '1'); + } + } catch { + // Leave the flag unset so another session can retry persistence. + } +}; diff --git a/demo/src/global.d.ts b/demo/src/global.d.ts index 0ae8aa69d..232f68da3 100644 --- a/demo/src/global.d.ts +++ b/demo/src/global.d.ts @@ -21,3 +21,8 @@ declare module 'markdown-it-ins' { declare const plugin: PluginSimple; export = plugin; } + +declare module '*.html?raw' { + const content: string; + export default content; +} diff --git a/demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx b/demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx new file mode 100644 index 000000000..f891e72f3 --- /dev/null +++ b/demo/src/stories/yfm/YfmHtmlConstructor.stories.tsx @@ -0,0 +1,16 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {YfmHtmlConstructorDemo} from './YfmHtmlConstructor'; + +const meta: Meta = { + title: 'Extensions / YFM', + component: YfmHtmlConstructorDemo, +}; + +export default meta; + +type Story = StoryObj; + +export const YfmHtmlConstructor: Story = { + name: 'YFM HTML Constructor', +}; diff --git a/demo/src/stories/yfm/YfmHtmlConstructor.tsx b/demo/src/stories/yfm/YfmHtmlConstructor.tsx new file mode 100644 index 000000000..5cf6aa039 --- /dev/null +++ b/demo/src/stories/yfm/YfmHtmlConstructor.tsx @@ -0,0 +1,77 @@ +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 {YfmHtmlConstructor as YfmHtmlConstructorExtension} from '@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/index.js'; + +import {PlaygroundLayout} from '../../components/PlaygroundLayout'; +import {seedYfmHtmlConstructorTemplates} from '../../defaults/yfm-html-constructor'; + +seedYfmHtmlConstructorTemplates(); + +const yfmHtmlConstructorItemId = 'yfmHtmlConstructor'; + +const toolbarsPreset: ToolbarsPreset = { + items: { + ...defaultPreset.items, + [yfmHtmlConstructorItemId]: { + view: { + icon: {data: LayoutCells}, + title: 'YFM HTML Constructor', + }, + wysiwyg: { + exec: (e) => e.actions.createYfmHtmlConstructor.run(), + isActive: (e) => e.actions.createYfmHtmlConstructor.isActive(), + isEnable: (e) => e.actions.createYfmHtmlConstructor.isEnable(), + }, + }, + }, + orders: { + ...defaultPreset.orders, + [Toolbar.wysiwygMain]: [ + [yfmHtmlConstructorItemId], + ...defaultPreset.orders[Toolbar.wysiwygMain], + ], + }, +}; + +export const YfmHtmlConstructorDemo = memo(function YfmHtmlConstructorDemo() { + const editor = useMarkdownEditor( + { + initial: {mode: 'wysiwyg', markup: ''}, + wysiwygConfig: { + extensions: (builder) => + builder.use(YfmHtmlConstructorExtension, { + scopeStyles: true, + templates: { + showButton: true, + allowAdd: true, + }, + }), + }, + }, + [], + ); + + return ( + ( + + )} + /> + ); +}); diff --git a/demo/tests/visual-tests/YfmHtmlConstructor.helpers.tsx b/demo/tests/visual-tests/YfmHtmlConstructor.helpers.tsx new file mode 100644 index 000000000..1c72eed3f --- /dev/null +++ b/demo/tests/visual-tests/YfmHtmlConstructor.helpers.tsx @@ -0,0 +1,71 @@ +import {useMemo} from 'react'; + +import {LayoutCells} from '@gravity-ui/icons'; +import { + MarkdownEditorView, + type ToolbarsPreset, + useMarkdownEditor, +} from '@gravity-ui/markdown-editor'; +import {ToolbarName} from '@gravity-ui/markdown-editor/_/modules/toolbars/constants.js'; +import {defaultPreset} from '@gravity-ui/markdown-editor/_/modules/toolbars/presets.js'; +import {YfmHtmlConstructor} from '@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/index.js'; +import {parseTemplates} from '@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/templates/index.js'; +import {ThemeProvider} from '@gravity-ui/uikit'; + +const templates = ` + + +`; + +const toolbarsPreset: ToolbarsPreset = { + items: { + ...defaultPreset.items, + htmlConstructor: { + view: {title: 'Insert constructor', icon: {data: LayoutCells}}, + wysiwyg: { + exec: (editor) => editor.actions.createYfmHtmlConstructor.run(), + isActive: (editor) => editor.actions.createYfmHtmlConstructor.isActive(), + isEnable: (editor) => editor.actions.createYfmHtmlConstructor.isEnable(), + }, + }, + }, + orders: {...defaultPreset.orders, [ToolbarName.wysiwygMain]: [['htmlConstructor']]}, +}; + +export const HtmlConstructorFixture = ({theme = 'light'}: {theme?: 'light' | 'dark'}) => { + const items = useMemo(() => parseTemplates(templates), []); + const editor = useMarkdownEditor( + { + initial: {mode: 'wysiwyg', markup: ''}, + wysiwygConfig: { + extensions: (builder) => + builder.use(YfmHtmlConstructor, { + scopeStyles: true, + templates: {items, showButton: true, allowAdd: true}, + }), + }, + }, + [], + ); + + return ( + +
+ +
+
+ ); +}; diff --git a/demo/tests/visual-tests/YfmHtmlConstructor.visual.test.tsx b/demo/tests/visual-tests/YfmHtmlConstructor.visual.test.tsx new file mode 100644 index 000000000..6025e9ab2 --- /dev/null +++ b/demo/tests/visual-tests/YfmHtmlConstructor.visual.test.tsx @@ -0,0 +1,244 @@ +import type {Locator, Page} from '@playwright/test'; + +import {expect, test} from 'playwright/core'; + +import {HtmlConstructorFixture} from './YfmHtmlConstructor.helpers'; + +const blockContent = (page: Page) => page.locator('.g-md-yfm-html-constructor__item-content'); +const heading = (page: Page) => blockContent(page).locator('.card-heading'); + +const capture = async (page: Page, name: string) => { + await page.screenshot({path: test.info().outputPath(`${name}.png`), animations: 'disabled'}); +}; + +const openBlockPicker = async (page: Page, appearance?: string) => { + await page.getByRole('button', {name: 'Insert constructor', exact: true}).click(); + if (appearance) await capture(page, `${appearance}-initial`); + await page + .locator('.g-md-yfm-html-constructor__initial') + .getByRole('button', {name: 'Add block', exact: true}) + .click(); + if (appearance) { + await expect(page.getByRole('button', {name: 'Test card', exact: true})).toBeVisible(); + await capture(page, `${appearance}-picker`); + } +}; + +const addBlock = async (page: Page, appearance?: string) => { + await openBlockPicker(page, appearance); + await page.getByRole('button', {name: 'Test card', exact: true}).click(); + await expect(heading(page)).toHaveText('Alpha beta'); +}; + +const openCodeEditor = async (page: Page) => { + const button = page.getByRole('button', {name: 'Structure settings', exact: true}); + await button.focus(); + await button.press('Enter'); + const input = page.getByRole('textbox', {name: 'HTML', exact: true}); + await expect(input).toBeVisible(); + return input; +}; + +const expectInsideViewport = async (page: Page, locator: Locator) => { + const bounds = await locator.boundingBox(); + const viewport = page.viewportSize(); + if (!bounds || !viewport) throw new Error('Expected visible content and a fixed viewport'); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height); +}; + +test.describe('HTML Constructor', () => { + test('keeps light and dark palette values independent', async ({mount, page}) => { + await mount(); + await addBlock(page); + const toolbar = page.locator('.g-md-yfm-html-constructor__floating-toolbar').first(); + const background = toolbar.getByRole('button', {name: 'Background color', exact: true}); + await background.focus(); + await background.press('Enter'); + await page.getByRole('button', {name: 'Red', exact: true}).click(); + await page.getByRole('button', {name: 'Dark', exact: true}).click(); + await page.getByRole('button', {name: 'Blue', exact: true}).click(); + + const structure = page.locator('.g-md-yfm-html-constructor__structure'); + await expect(structure).toHaveCSS('--g-md-hc-background-light', '#d64545'); + await expect(structure).toHaveCSS('--g-md-hc-background-dark', '#2f6fe0'); + await page.getByRole('button', {name: 'Reset', exact: true}).click(); + expect( + await structure.evaluate((element) => + (element as HTMLElement).style.getPropertyValue('--g-md-hc-background-dark'), + ), + ).toBe(''); + await expect(structure).toHaveCSS('--g-md-hc-background-light', '#d64545'); + await page.keyboard.press('Escape'); + await expect( + page.locator('.g-md-yfm-html-constructor__floating-menu_colors'), + ).not.toBeVisible(); + + const textColor = toolbar.getByRole('button', {name: 'Text color', exact: true}); + await textColor.focus(); + await textColor.press('Enter'); + await page.getByRole('button', {name: 'Blue', exact: true}).click(); + await expect(structure).toHaveCSS('--g-md-hc-text-color-dark', '#2f6fe0'); + await expect(heading(page)).toHaveText('Alpha beta'); + }); + + test('preserves a custom structure draft when replacement is cancelled', async ({ + mount, + page, + }) => { + await mount(); + await addBlock(page); + const templates = page.getByRole('button', {name: 'Structure templates', exact: true}); + await templates.focus(); + await templates.press('Enter'); + await page.getByRole('button', {name: 'Custom structure', exact: true}).click(); + const input = page.getByRole('textbox', {name: 'HTML', exact: true}); + await input.fill('

Custom draft

'); + await page.getByRole('button', {name: 'Insert', exact: true}).click(); + const confirmation = page + .getByRole('dialog') + .filter({has: page.getByText('Replace content?', {exact: true})}); + await confirmation.getByRole('button', {name: 'Cancel', exact: true}).click(); + await expect(input).toHaveValue('

Custom draft

'); + await expect(heading(page)).toHaveText('Alpha beta'); + await page.getByRole('button', {name: 'Insert', exact: true}).click(); + await confirmation.getByRole('button', {name: 'Replace', exact: true}).click(); + await expect(page.locator('.g-md-yfm-html-constructor__structure-content')).toHaveText( + 'Custom draft', + ); + await expect(blockContent(page)).toHaveCount(0); + }); + + for (const dismissal of ['Escape', 'outside click'] as const) { + test(`keeps the code cursor and saves on ${dismissal}`, async ({mount, page}) => { + const theme = dismissal === 'Escape' ? 'light' : 'dark'; + await mount(); + await addBlock(page, theme); + const input = await openCodeEditor(page); + const original = await input.inputValue(); + const position = original.indexOf('Alpha beta') + 'Alpha'.length; + expect(position).toBeGreaterThan('Alpha'.length); + + await input.focus(); + await input.evaluate((element, caret) => { + (element as HTMLTextAreaElement).setSelectionRange(caret, caret); + }, position); + await input.pressSequentially(' updated'); + + const edited = original.replace('Alpha beta', 'Alpha updated beta'); + await expect(input).toHaveValue(edited); + expect( + await input.evaluate((element) => (element as HTMLTextAreaElement).selectionStart), + ).toBe(position + ' updated'.length); + + if (dismissal === 'Escape') await input.press('Escape'); + else await page.mouse.click(4, 4); + + await expect(input).not.toBeVisible(); + await expect(heading(page)).toHaveText('Alpha updated beta'); + const reopened = await openCodeEditor(page); + expect(await reopened.inputValue()).toContain('Alpha updated beta'); + }); + } + + test('opens inline editing from the keyboard and preserves spaces and IME input', async ({ + mount, + page, + }) => { + await mount(); + await addBlock(page); + + const content = blockContent(page); + await content.focus(); + await content.press('Enter'); + + const popup = page.getByRole('dialog', {name: 'Edit element', exact: true}); + const input = popup.getByRole('textbox', {name: 'Text', exact: true}); + await expect(input).toBeFocused(); + await input.fill('New'); + await input.press('Space'); + await input.pressSequentially('value'); + await expect(input).toHaveValue('New value'); + + await input.dispatchEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 229, + isComposing: true, + bubbles: true, + }); + await expect(popup).toBeVisible(); + await expect(input).toHaveValue('New value'); + await expect(heading(page)).toHaveText('Alpha beta'); + + await input.press('Enter'); + await expect(popup).not.toBeVisible(); + await expect(heading(page)).toHaveText('New value'); + await expect(content).toBeFocused(); + }); + + test('keeps invalid attribute edits recoverable without mutating the preview', async ({ + mount, + page, + }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await mount(); + await addBlock(page); + await heading(page).click(); + + const popup = page.getByRole('dialog', {name: 'Edit element', exact: true}); + const input = popup.getByRole('textbox', {name: 'Text', exact: true}); + await input.fill('Updated heading'); + await popup.getByRole('button', {name: /^Attributes/}).click(); + await popup.getByRole('button', {name: 'Add attribute', exact: true}).click(); + const attribute = popup.getByRole('textbox', {name: 'name', exact: true}).last(); + await attribute.fill('invalid name'); + await popup.getByRole('textbox', {name: 'value: invalid name', exact: true}).fill('note'); + await popup.getByRole('button', {name: 'Save', exact: true}).click(); + + await expect(popup.getByRole('alert')).toContainText('Attribute names'); + await expect(input).toHaveValue('Updated heading'); + await expect(heading(page)).toHaveText('Alpha beta'); + expect(errors).toEqual([]); + + await attribute.fill('data-note'); + await popup.getByRole('button', {name: 'Save', exact: true}).click(); + await expect(popup).not.toBeVisible(); + await expect(heading(page)).toHaveText('Updated heading'); + await expect(heading(page)).toHaveAttribute('data-note', 'note'); + expect(errors).toEqual([]); + }); + + test('keeps the picker inside a narrow viewport and opens variants explicitly', async ({ + mount, + page, + }) => { + await page.setViewportSize({width: 390, height: 844}); + await mount(); + await openBlockPicker(page); + + const picker = page.locator('.g-md-yfm-html-constructor__structures'); + await expect(picker).toBeVisible(); + await expectInsideViewport(page, picker); + await page.getByRole('button', {name: 'Test card', exact: true}).hover(); + const variant = page.getByRole('button', {name: 'Blue variant', exact: true}); + await expect(variant).not.toBeVisible(); + + const variants = page.getByRole('button', {name: '1 variant', exact: true}); + await variants.focus(); + await variants.press('Enter'); + await expect(variant).toBeVisible(); + await expect(variants).toHaveAttribute('aria-expanded', 'true'); + await expectInsideViewport( + page, + page.locator('.g-md-yfm-html-constructor__structure-themes'), + ); + + await variant.click(); + await expect(picker).not.toBeVisible(); + await expect(heading(page)).toHaveText('Alpha beta'); + }); +}); diff --git a/docs/yfm-html-constructor-theming.md b/docs/yfm-html-constructor-theming.md new file mode 100644 index 000000000..d06ff85ea --- /dev/null +++ b/docs/yfm-html-constructor-theming.md @@ -0,0 +1,139 @@ +##### Extensions / YFM HTML Constructor + +# YFM HTML Constructor + +Register the experimental extension with `builder.use(YfmHtmlConstructor, options)`. +Import it from `@gravity-ui/markdown-editor/extensions/additional/YfmHtmlConstructor/index.js`. +The insertion action is `createYfmHtmlConstructor`. + +| Option | Behavior | +| --- | --- | +| `templates.items` | Bundled, read-only templates returned by `parseTemplates(html)`. | +| `templates.showButton` | Show the structure picker. | +| `templates.allowAdd` | Allow users to import and clear saved templates. | +| `scopeStyles` | Scope exported CSS to each constructor; defaults to `false`. Editor previews are always scoped. | + +Template parsing and storage helpers are exported from the extension's +`templates/index.js`. Imports are shared by all constructors on the page and +persist to localStorage when available. If persistence fails, templates remain +available for the current page session. Clearing saved templates leaves +`templates.items` intact. + +HTML/CSS editors keep local drafts while typing and commit on blur or dismissal, +including Escape and outside click. Inline element edits use Save or Enter; +Shift+Enter inserts a newline and Cancel discards the edit. Changes are part of +the editor's undo history. Export produces a regular YFM `::: html` block; +constructor metadata is not restored by importing that exported markup. + +The examples in `demo/src/stories/yfm/YfmHtmlConstructor.tsx` show toolbar +registration; `demo/src/defaults/yfm-html-constructor/gravity-ui-landing.html` +contains a complete template pack. + +## How it works + +Every styleable aspect is backed by four CSS variables: + +| Variable | Who sets it | Purpose | +| -------------------------- | ------------------ | --------------------------------------------------------------- | +| `--g-md-hc-` | block toolbar | The quick-style **override** (inline on the element). | +| `--g-md-hc--light` | theme author | Value for the **light** color theme. | +| `--g-md-hc--dark` | theme author | Value for the **dark** color theme. | +| `--g-md-hc--current` | constructor (auto) | The light/dark value resolved for the active theme. Do not set. | + +The block resolves the final value as a fallback chain: + +``` +--g-md-hc- (toolbar override, highest priority) + └─ --g-md-hc--current (resolved by the container, see below) +``` + +`*-current` is computed automatically by the container (the constructor +contract) from the `*-light` / `*-dark` companions depending on the active +Gravity UI theme (`.g-root_theme_dark` / `.g-root_theme_dark-hc` switch to the +dark companions, everything else uses the light ones), and falls back to the +constructor default when no companion is set. + +### Constructor defaults (live on the container) + +The defaults are baked into the container's `*-current` resolution +(`HTML_CONSTRUCTOR_DEFAULTS` in `cssVariables.ts`), not into individual +templates. They use Gravity UI semantic tokens, which already flip between light +and dark themes: + +| `` | Default | +| --------------- | ---------------------------------------- | +| `background` | `var(--g-color-base-generic-ultralight)` | +| `text-color` | `var(--g-color-text-primary)` | +| `border` | `1px solid var(--g-color-line-generic)` | +| `border-radius` | `var(--g-border-radius-l)` | + +Because the default lives on the container, a template never carries its own +fallback. It simply reads the resolved value: + +```css +& { + background: var(--g-md-hc-background, var(--g-md-hc-background-current)); + color: var(--g-md-hc-text-color, var(--g-md-hc-text-color-current)); +} +``` + +The value (override -> theme companion -> default) comes from the container, so +a bare structure/block already reads as a subtle, adaptive card. Set the +companions only when you want a look different from the default. + +### Available aspects + +| `` | Consumed property | Notes | +| --------------- | ----------------- | --------------------------------------------------------- | +| `background` | `background` | Any CSS color. | +| `text-color` | `color` | Any CSS color. | +| `border-radius` | `border-radius` | Any length, e.g. `12px`, `999px`. | +| `border` | `border` | A full border shorthand, e.g. `1px solid #ccc` or `none`. | + +## Writing a theme + +A theme is a CSS rule scoped to a block (or structure). To stay compatible with +the toolbar **and** support light/dark, set the `*-light` / `*-dark` companions +rather than the final properties: + +```css +& { + --g-md-hc-background-light: #ffffff; + --g-md-hc-background-dark: #1c1c20; + --g-md-hc-text-color-light: #1c1c20; + --g-md-hc-text-color-dark: #f0f0f0; + --g-md-hc-border-radius-light: 16px; + --g-md-hc-border-radius-dark: 16px; + --g-md-hc-border-light: 1px solid #e7e9ec; + --g-md-hc-border-dark: 1px solid #34343a; +} +``` + +You can set only one companion if a value should be the same in both themes — +the dark companion falls back to the light one when it is not provided. + +If you set a property **directly** (e.g. `background: red`) the block can no +longer override it from the toolbar and it will not adapt to light/dark. That is +sometimes intentional (a fixed brand look), but for general-purpose themes prefer +the companion variables above. + +## Where the contract lives + +- Variable names and helpers: `cssVariables.ts`. +- In-editor resolution/consumption (with editor chrome fallbacks): the + `&__item` / `&__structure` rules in `YfmHtmlConstructorNodeView/YfmHtmlConstructor.scss`. +- Output markdown and template previews prepend the generated contract + stylesheet (`HTML_CONSTRUCTOR_VARIABLES_CSS`) so the variables resolve outside + the editor too. + +## Code organization + +Within `YfmHtmlConstructor`, `model.ts` normalizes node data and copies HTML IDs; +`document.ts` assembles, parses, and updates the editable HTML/CSS document. +Template application and preview generation live in `templates/state.ts` and +`templates/preview.ts`. + +The node view composes the canvas, panels, and toolbar. `useConstructorCommands` +reads the current ProseMirror node before dispatching a change. Forms own their +drafts; `useInlineHtmlEditing` handles element targeting and popup positioning. +Toolbar controls and width calculation live in `YfmHtmlConstructorNodeView/toolbar`. diff --git a/packages/editor/package.json b/packages/editor/package.json index 65cd9098a..55fdb84e6 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -189,6 +189,8 @@ "markdown-it-mark": "^3.0.1", "markdown-it-sub": "^1.0.0", "orderedmap": "^2.1.1", + "postcss": "catalog:linters", + "postcss-selector-parser": "^7.1.1", "prosemirror-autocomplete": "^0.4.3", "prosemirror-codemark": "^0.4.2", "prosemirror-commands": "^1.6.2", @@ -253,7 +255,6 @@ "markdown-it-testgen": "^0.1.6", "mermaid": "11.13.0", "npm-run-all": "^4.1.5", - "postcss": "catalog:linters", "prettier": "catalog:linters", "react": "catalog:react", "react-dom": "catalog:react", diff --git a/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss b/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss index 3bb299ca9..a0369b511 100644 --- a/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss +++ b/packages/editor/src/extensions/additional/YfmHtmlBlock/YfmHtmlBlockNodeView/TemplatesPopup.scss @@ -12,15 +12,14 @@ &__editor { display: flex; flex-direction: column; - gap: 8px; + padding: 8px; } &__controls { display: flex; justify-content: end; - gap: 8px; } } diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts new file mode 100644 index 000000000..f1f2ed22f --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructor.test.ts @@ -0,0 +1,103 @@ +import {builders} from 'prosemirror-test-builder'; + +import {ExtensionsManager} from '../../../core'; +import {BaseNode, BaseSchemaSpecs} from '../../specs'; + +import {YfmHtmlConstructorSpecs} from './YfmHtmlConstructorSpecs'; +import {YfmHtmlConstructorAttrs, yfmHtmlConstructorNodeName} from './YfmHtmlConstructorSpecs/const'; +import {HTML_CONSTRUCTOR_VARIABLES_CSS} from './cssVariables'; + +/** Matches the 2-space indentation `buildYfmHtmlConstructorHtml` applies inside `', + '
', + '
First
', + '
', + ':::', + ].join('\n'), + ); + }); +}); diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx new file mode 100644 index 000000000..82892738a --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/BlockTemplatesPanel.tsx @@ -0,0 +1,105 @@ +import {useCallback} from 'react'; +import type {FC} from 'react'; + +import {i18n} from 'src/i18n/yfm-html-constructor'; + +import {buildBlockPreviewParts} from '../templates/preview'; +import type { + HtmlConstructorBlockTemplate, + HtmlConstructorTemplate, + HtmlConstructorThemeTemplate, +} from '../types'; + +import {TemplatePickerPanel} from './TemplatePicker'; +import type {PickerCardModel, PickerGroup} from './TemplatePicker'; +import {buildBlockMenuGroups} from './groupTemplates'; +import type {BlockMenuItem} from './groupTemplates'; + +const getTitle = (template: {id: string; title?: string}) => template.title?.trim() || template.id; + +const blockPreview = buildBlockPreviewParts; + +const variantLabel = ( + base: HtmlConstructorBlockTemplate, + state: HtmlConstructorBlockTemplate, + theme?: HtmlConstructorThemeTemplate, +) => { + if (!theme) return getTitle(state); + const themeTitle = getTitle(theme); + return state.id === base.id ? themeTitle : `${getTitle(state)} · ${themeTitle}`; +}; + +const blockCard = ( + item: BlockMenuItem, + onApply: (block: HtmlConstructorBlockTemplate, theme?: HtmlConstructorThemeTemplate) => void, +): PickerCardModel => { + const {block} = item; + const variants: {state: HtmlConstructorBlockTemplate; theme?: HtmlConstructorThemeTemplate}[] = + []; + item.states.forEach((state, index) => { + // The base state is the card itself; only extra states become variants. + if (index > 0) variants.push({state}); + for (const theme of item.themesByBlockId[state.id] ?? []) { + variants.push({state, theme}); + } + }); + + return { + id: block.id, + title: getTitle(block), + preview: blockPreview(block), + badge: variants.length ? i18n('variants_count', {count: variants.length}) : undefined, + onApply: () => onApply(block), + variants: variants.map(({state, theme}) => ({ + key: `${state.id}:${theme?.id ?? 'state'}`, + label: variantLabel(block, state, theme), + preview: blockPreview(state, theme), + onApply: () => onApply(state, theme), + })), + }; +}; + +interface BlockTemplatesPanelProps { + templates: HtmlConstructorTemplate[]; + activeStructureId?: string; + emptyText: string; + onClose: () => void; + onApplyTemplate: ( + template: HtmlConstructorBlockTemplate, + theme?: HtmlConstructorThemeTemplate, + ) => void; + onApplyHtml: (value: {content: string; css: string}) => void; +} + +export const BlockTemplatesPanel: FC = ({ + templates, + activeStructureId, + emptyText, + onClose, + onApplyTemplate, + onApplyHtml, +}) => { + const buildGroups = useCallback( + (filter: string): PickerGroup[] => + buildBlockMenuGroups(templates, activeStructureId, filter).map((group) => ({ + title: group.title, + cards: group.items.map((item) => blockCard(item, onApplyTemplate)), + })), + [activeStructureId, onApplyTemplate, templates], + ); + + return ( + + ); +}; diff --git a/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/CodeEditorPane.tsx b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/CodeEditorPane.tsx new file mode 100644 index 000000000..28684b62a --- /dev/null +++ b/packages/editor/src/extensions/additional/YfmHtmlConstructor/YfmHtmlConstructorNodeView/CodeEditorPane.tsx @@ -0,0 +1,130 @@ +import {useLayoutEffect, useMemo, useRef, useState} from 'react'; +import type {FC, UIEvent} from 'react'; + +import {cnYfmHtmlConstructor as b, STOP_EVENT_CLASSNAME as stop} from './const'; + +// Kept in sync with `&__code-gutter` line-height and vertical padding in the +// stylesheet; used to count how many line numbers fill the visible body. +const GUTTER_LINE_HEIGHT = 20; +const GUTTER_PADDING_Y = 24; + +/** Non-editable wrapper lines shown around editable HTML (the spec block markup). */ +export interface CodeFrame { + top: string; + bottom: string; +} + +interface CodeEditorPaneProps { + label: string; + value: string; + placeholder: string; + showLabel: boolean; + onUpdate: (value: string) => void; + onCommit: () => void; + /** When set, renders read-only wrapper lines around the editable content. */ + frame?: CodeFrame; +} + +export const CodeEditorPane: FC = ({ + label, + value, + placeholder, + showLabel, + onUpdate, + onCommit, + frame, +}) => { + const gutterRef = useRef(null); + const bodyRef = useRef(null); + const [visibleRows, setVisibleRows] = useState(0); + + useLayoutEffect(() => { + const node = bodyRef.current; + if (!node) return undefined; + + const measure = () => { + const available = node.clientHeight - GUTTER_PADDING_Y; + setVisibleRows(Math.max(0, Math.floor(available / GUTTER_LINE_HEIGHT))); + }; + measure(); + + if (typeof ResizeObserver === 'undefined') return undefined; + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + const contentLines = value ? value.split('\n').length : 1; + const frameTopLines = frame?.top ? frame.top.split('\n').length : 0; + const frameBottomLines = frame?.bottom ? frame.bottom.split('\n').length : 0; + + const lineNumbers = useMemo(() => { + const contentTotal = contentLines + frameTopLines + frameBottomLines; + const total = Math.max(contentTotal, visibleRows); + let result = ''; + for (let line = 1; line <= total; line++) { + result += line === 1 ? '1' : `\n${line}`; + } + return result; + }, [contentLines, frameTopLines, frameBottomLines, visibleRows]); + + const syncScroll = (event: UIEvent) => { + if (gutterRef.current) gutterRef.current.scrollTop = event.currentTarget.scrollTop; + }; + + const control = ( +