From 3ca4361e6e88b220f4e361f1d25702af4e99a019 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 10 Sep 2026 14:26:38 +0200 Subject: [PATCH 1/2] feat(toolbars): support presets for selection and slash menus --- demo/src/stories/presets/Presets.stories.tsx | 22 --- demo/src/stories/presets/presets.ts | 25 +++ .../ContextualToolbars.helpers.tsx | 105 +++++++++++ .../ContextualToolbars.visual.test.tsx | 168 ++++++++++++++++++ docs/how-to-customize-toolbars.md | 65 ++++++- .../editor/src/bundle/MarkdownEditorView.tsx | 29 ++- .../editor/src/bundle/config/action-names.ts | 2 + .../toolbar/utils/toolbarsConfigs.test.ts | 110 ++++++++++++ .../bundle/toolbar/utils/toolbarsConfigs.ts | 49 ++++- packages/editor/src/bundle/wysiwyg-preset.ts | 25 ++- .../behavior/CommandMenu/handler.ts | 34 +++- .../extensions/behavior/CommandMenu/index.ts | 60 ++++--- .../SelectionContext/TextSelectionTooltip.tsx | 2 + .../behavior/SelectionContext/index.ts | 50 +++--- .../behavior/SelectionContext/tooltip.tsx | 5 +- .../editor/src/modules/toolbars/contextual.ts | 23 +++ .../editor/src/modules/toolbars/items.tsx | 6 + .../editor/src/modules/toolbars/presets.ts | 117 ++++++++++++ packages/editor/src/modules/toolbars/types.ts | 3 + 19 files changed, 800 insertions(+), 100 deletions(-) create mode 100644 demo/tests/visual-tests/ContextualToolbars.helpers.tsx create mode 100644 demo/tests/visual-tests/ContextualToolbars.visual.test.tsx create mode 100644 packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.test.ts create mode 100644 packages/editor/src/modules/toolbars/contextual.ts diff --git a/demo/src/stories/presets/Presets.stories.tsx b/demo/src/stories/presets/Presets.stories.tsx index d0333b7d0..57579b569 100644 --- a/demo/src/stories/presets/Presets.stories.tsx +++ b/demo/src/stories/presets/Presets.stories.tsx @@ -1,12 +1,3 @@ -import { - textContextItemData, - wBoldItemData, - wHeading1ItemData, - wHeading2ItemData, - wItalicItemData, - wTextItemData, - wToggleHeadingFoldingItemData, -} from '@gravity-ui/markdown-editor'; import type {StoryObj} from '@storybook/react'; import {Preset as component} from './Preset'; @@ -37,19 +28,6 @@ export const Full: StoryObj = { export const Custom: StoryObj = { args: { toolbarsPreset: custom, - wysiwygConfig: { - extensionOptions: { - commandMenu: { - actions: [wTextItemData, wHeading1ItemData, wHeading2ItemData], - }, - selectionContext: { - config: [ - [wToggleHeadingFoldingItemData, textContextItemData], - [wBoldItemData, wItalicItemData], - ], - }, - }, - }, }, }; diff --git a/demo/src/stories/presets/presets.ts b/demo/src/stories/presets/presets.ts index fcb948ed3..cd2bf3ecd 100644 --- a/demo/src/stories/presets/presets.ts +++ b/demo/src/stories/presets/presets.ts @@ -7,11 +7,22 @@ import { colorifyItemMarkup, colorifyItemView, colorifyItemWysiwyg, + heading1ItemView, + heading1ItemWysiwyg, + heading2ItemView, + heading2ItemWysiwyg, italicItemMarkup, italicItemView, + italicItemWysiwyg, + paragraphItemView, + paragraphItemWisywig, redoItemMarkup, redoItemView, redoItemWysiwyg, + textContextItemView, + textContextItemWisywig, + toggleHeadingFoldingItemView, + toggleHeadingFoldingItemWysiwyg, undoItemMarkup, undoItemView, undoItemWysiwyg, @@ -36,6 +47,7 @@ export const toolbarPresets: Record = { }, [Action.italic]: { view: italicItemView, + wysiwyg: italicItemWysiwyg, markup: italicItemMarkup, }, [Action.colorify]: { @@ -43,10 +55,23 @@ export const toolbarPresets: Record = { wysiwyg: colorifyItemWysiwyg, markup: colorifyItemMarkup, }, + [Action.text]: {view: textContextItemView, wysiwyg: textContextItemWisywig}, + [Action.foldingHeading]: { + view: toggleHeadingFoldingItemView, + wysiwyg: toggleHeadingFoldingItemWysiwyg, + }, + [Action.paragraph]: {view: paragraphItemView, wysiwyg: paragraphItemWisywig}, + [Action.heading1]: {view: heading1ItemView, wysiwyg: heading1ItemWysiwyg}, + [Action.heading2]: {view: heading2ItemView, wysiwyg: heading2ItemWysiwyg}, }, orders: { [Toolbar.wysiwygMain]: [[Action.colorify], [Action.bold], [Action.undo, Action.redo]], [Toolbar.markupMain]: [[Action.colorify], [Action.italic], [Action.undo, Action.redo]], + [Toolbar.wysiwygSelection]: [ + [Action.foldingHeading, Action.text], + [Action.bold, Action.italic], + ], + [Toolbar.wysiwygSlash]: [[Action.paragraph, Action.heading1, Action.heading2]], }, }, }; diff --git a/demo/tests/visual-tests/ContextualToolbars.helpers.tsx b/demo/tests/visual-tests/ContextualToolbars.helpers.tsx new file mode 100644 index 000000000..5964103d2 --- /dev/null +++ b/demo/tests/visual-tests/ContextualToolbars.helpers.tsx @@ -0,0 +1,105 @@ +import {useState} from 'react'; + +import { + type MarkdownEditorPreset, + MarkdownEditorView, + type ToolbarsPreset, + useMarkdownEditor, + wHeading1ItemData, + wItalicItemData, +} from '@gravity-ui/markdown-editor'; +import { + ActionName as Action, + ToolbarName as Toolbar, +} from '@gravity-ui/markdown-editor/_/modules/toolbars/constants.js'; +import {full} from '@gravity-ui/markdown-editor/_/modules/toolbars/presets.js'; + +const custom: ToolbarsPreset = { + items: { + ...full.items, + customHeading: { + ...full.items[Action.heading2], + view: { + ...full.items[Action.heading2].view, + title: 'Custom heading', + aliases: ['topic'], + }, + }, + }, + orders: { + ...full.orders, + [Toolbar.wysiwygSelection]: [[Action.italic, Action.bold]], + [Toolbar.wysiwygSlash]: [['customHeading', Action.paragraph]], + }, +}; +const alternate: ToolbarsPreset = { + items: full.items, + orders: { + ...full.orders, + [Toolbar.wysiwygSelection]: [[Action.strike]], + [Toolbar.wysiwygSlash]: [[Action.heading1]], + }, +}; +const empty: ToolbarsPreset = { + items: full.items, + orders: {...full.orders, [Toolbar.wysiwygSelection]: [], [Toolbar.wysiwygSlash]: []}, +}; +const mainOnly: ToolbarsPreset = { + items: full.items, + orders: {[Toolbar.wysiwygMain]: [[Action.bold]]}, +}; +const zeroCustom: ToolbarsPreset = { + items: {paragraph: full.items[Action.paragraph]}, + orders: { + [Toolbar.wysiwygSelection]: [[Action.paragraph]], + [Toolbar.wysiwygSlash]: [[Action.paragraph]], + }, +}; +const configs = {custom, alternate, empty, mainOnly, zeroCustom, default: undefined}; + +export function ContextualToolbars({ + initialConfig = 'custom', + preset = 'full', + legacy = false, + mobile = false, +}: { + initialConfig?: keyof typeof configs; + preset?: MarkdownEditorPreset; + legacy?: boolean; + mobile?: boolean; +}) { + const [config, setConfig] = useState(initialConfig); + const editor = useMarkdownEditor({ + preset, + mobile, + initial: {markup: 'Select this text', mode: 'wysiwyg'}, + wysiwygConfig: legacy + ? { + extensionOptions: { + selectionContext: {config: [[wItalicItemData]]}, + commandMenu: {actions: [wHeading1ItemData]}, + }, + } + : undefined, + }); + + return ( +
+ {(['custom', 'alternate', 'empty', 'zeroCustom', 'default'] as const).map((name) => ( + + ))} + +
+ ); +} diff --git a/demo/tests/visual-tests/ContextualToolbars.visual.test.tsx b/demo/tests/visual-tests/ContextualToolbars.visual.test.tsx new file mode 100644 index 000000000..8b0d9672e --- /dev/null +++ b/demo/tests/visual-tests/ContextualToolbars.visual.test.tsx @@ -0,0 +1,168 @@ +import {expect, test} from 'playwright/core'; + +import {ContextualToolbars} from './ContextualToolbars.helpers'; + +test.describe('Contextual toolbar configuration', () => { + test.afterEach(async ({page}) => { + await expect(page.getByRole('heading', {name: 'Error in YFM editor'})).toBeHidden(); + }); + + test('uses shared items in the requested selection order and executes an action', async ({ + mount, + editor, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + const toolbar = editor.locators.toolbars.selection; + await expect(toolbar.getByRole('button')).toHaveCount(2); + await expect(toolbar.getByRole('button').nth(0)).toHaveAttribute('aria-label', 'Italic'); + await expect(toolbar.getByRole('button').nth(1)).toHaveAttribute('aria-label', 'Bold'); + await toolbar.getByRole('button', {name: 'Bold', exact: true}).click(); + await expect(editor.locators.contenteditable.locator('strong')).toHaveText( + 'Select this text', + ); + }); + + test('searches a custom slash alias and executes its command', async ({mount, editor}) => { + await mount(); + await editor.fill(''); + await editor.pressSequentially('/topic'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Custom heading'); + await editor.press('Enter'); + await editor.pressSequentially('Heading text'); + await expect(editor.locators.contenteditable.locator('h2')).toHaveText('Heading text'); + await expect(editor.locators.contenteditable).not.toContainText('/topic'); + }); + + test('updates an open selection toolbar and restores the legacy fallback', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + const toolbar = editor.locators.toolbars.selection; + await expect(toolbar.getByRole('button')).toHaveCount(2); + await page.getByRole('button', {name: 'Use alternate toolbar'}).click(); + await expect(toolbar.getByRole('button')).toHaveCount(1); + await expect(toolbar.getByRole('button')).toHaveAttribute('aria-label', 'Strikethrough'); + await page.getByRole('button', {name: 'Use default toolbar'}).click(); + await expect(toolbar.getByRole('button')).toHaveAttribute('aria-label', 'Italic'); + }); + + test('updates an open slash menu without executing the previous configuration', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.fill(''); + await editor.pressSequentially('/'); + const toolbar = editor.locators.toolbars.commandMenu; + await expect(toolbar).toContainText('Custom heading'); + await page.getByRole('button', {name: 'Use alternate toolbar'}).click(); + await expect(toolbar).toContainText('Heading 1'); + await expect(toolbar).not.toContainText('Custom heading'); + await editor.press('Enter'); + await editor.pressSequentially('Updated heading'); + await expect(editor.locators.contenteditable.locator('h1')).toHaveText('Updated heading'); + }); + + test('disables both contextual toolbars with empty orders', async ({mount, editor}) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeHidden(); + await editor.fill(''); + await editor.pressSequentially('/h1'); + await expect(editor.locators.toolbars.commandMenu).toBeHidden(); + await expect(editor.locators.contenteditable).toHaveText('/h1'); + }); + + test('keeps legacy extension options when contextual orders are omitted', async ({ + mount, + editor, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveCount(1); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveAttribute( + 'aria-label', + 'Italic', + ); + await editor.fill(''); + await editor.pressSequentially('/'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Heading 1'); + await expect(editor.locators.toolbars.commandMenu).not.toContainText('Custom heading'); + }); + + test('enables custom contextual actions with the zero editor preset', async ({ + mount, + editor, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeVisible(); + await editor.fill(''); + await editor.pressSequentially('/'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Text'); + await expect(editor.locators.toolbars.commandMenu).not.toContainText('Custom heading'); + }); + + test('keeps contextual toolbars disabled on mobile', async ({mount, editor}) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeHidden(); + await editor.fill(''); + await editor.pressSequentially('/topic'); + await expect(editor.locators.toolbars.commandMenu).toBeHidden(); + }); + + test('closes open contextual toolbars when their orders become empty', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeVisible(); + await page.getByRole('button', {name: 'Use empty toolbar'}).click(); + await expect(editor.locators.toolbars.selection).toBeHidden(); + await page.getByRole('button', {name: 'Use custom toolbar'}).click(); + await editor.fill(''); + await editor.pressSequentially('/'); + await expect(editor.locators.toolbars.commandMenu).toBeVisible(); + await page.getByRole('button', {name: 'Use empty toolbar'}).click(); + await expect(editor.locators.toolbars.commandMenu).toBeHidden(); + await editor.pressSequentially('topic'); + await expect(editor.locators.contenteditable).toHaveText('/topic'); + }); + + test('applies contextual overrides after switching editor modes', async ({mount, editor}) => { + await mount(); + await editor.switchMode('markup'); + await editor.switchMode('wysiwyg'); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveCount(2); + await editor.fill(''); + await editor.pressSequentially('/topic'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Custom heading'); + }); + + test('preserves selection popup controls and slash heading aliases in the full preset', async ({ + mount, + editor, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect( + editor.locators.toolbars.selection.getByTestId('g-md-toolbar-text-select'), + ).toBeVisible(); + await expect(editor.locators.toolbars.selection.getByLabel('Text color')).toBeVisible(); + await editor.fill(''); + await editor.pressSequentially('/h2'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Heading 2'); + await editor.press('Enter'); + await editor.pressSequentially('Default heading'); + await expect(editor.locators.contenteditable.locator('h2')).toHaveText('Default heading'); + }); +}); diff --git a/docs/how-to-customize-toolbars.md b/docs/how-to-customize-toolbars.md index 6c7caaed3..9f24b919e 100644 --- a/docs/how-to-customize-toolbars.md +++ b/docs/how-to-customize-toolbars.md @@ -45,21 +45,23 @@ More details can be found in [issue #508](https://github.com/gravity-ui/markdown ### Toolbar Configuration -Starting from `@gravity-ui/markdown-editor@14.10.2`, all toolbars—except the selection-based and slash-triggered toolbars—are configured using a shared dictionary of items and arrays defining the order of those items. +All six toolbars are configured using a shared dictionary of items and arrays defining the order of those items. Built-in **toolbar presets** are available in the `gravity-ui/markdown-editor` repository: -- [`zero`](https://github.com/gravity-ui/markdown-editor/blob/main/src/modules/toolbars/presets.ts#L109) -- [`commonmark`](https://github.com/gravity-ui/markdown-editor/blob/main/src/modules/toolbars/presets.ts#L128) -- [`default`](https://github.com/gravity-ui/markdown-editor/blob/main/src/modules/toolbars/presets.ts#L303) -- [`yfm`](https://github.com/gravity-ui/markdown-editor/blob/main/src/modules/toolbars/presets.ts#L384) -- [`full`](https://github.com/gravity-ui/markdown-editor/blob/main/src/modules/toolbars/presets.ts#L517) +- `zero` +- `commonmark` +- `default` +- `yfm` +- `full` + +See their [items and orders](../packages/editor/src/modules/toolbars/presets.ts). > **Note:** These toolbar presets have the same names as editor presets for convenience. When you don't specify `toolbarsPreset`, the editor automatically selects the toolbar preset matching your editor preset name. ### Configuration Details -1. The `items` key contains a shared dictionary used across the four main toolbars. +1. The `items` key contains a shared dictionary used across all six toolbars. 2. The `orders` key defines the display order of toolbar items. 3. Every ID listed in `orders` must have a corresponding entry in the `items` dictionary. 4. Each item used in a toolbar must also have its corresponding extension included in the editor's `extensions` section. @@ -80,8 +82,8 @@ The `default` **editor preset** defines a set of extensions, while the `default` The library provides a set of predefined toolbar presets that cannot be overridden directly. If none of the built-in toolbar presets suit your needs, you can define a custom toolbar configuration. Below is an example of how to do that: - [Live demo (custom preset)](https://preview.gravity-ui.com/md-editor/?path=/story/extensions-presets--custom) -- [Presets.stories.tsx#L30](https://github.com/gravity-ui/markdown-editor/blob/main/demo/stories/presets/Presets.stories.tsx#L30) -- [presets.ts#L21](https://github.com/gravity-ui/markdown-editor/blob/main/demo/stories/presets/presets.ts#L21) +- [Presets.stories.tsx](../demo/src/stories/presets/Presets.stories.tsx) +- [presets.ts](../demo/src/stories/presets/presets.ts) #### Step 1: Define your custom toolbar preset @@ -146,6 +148,51 @@ function MyEditor() { > **Key point:** By providing `toolbarsPreset`, you override the default toolbar configuration. Without it, the editor would use the built-in `'default'` toolbar preset (matching the editor preset name). +### Selection and Slash Toolbars + +Use `ToolbarName.wysiwygSelection` for the toolbar shown when selecting text, and `ToolbarName.wysiwygSlash` for the menu opened by `/`. Both use the same `items` dictionary and WYSIWYG actions as the main toolbar. + +```ts +import type {ToolbarsPreset} from '@gravity-ui/markdown-editor'; +import { + ActionName as Action, + ToolbarName as Toolbar, +} from '@gravity-ui/markdown-editor/_/modules/toolbars/constants.js'; +import {full} from '@gravity-ui/markdown-editor/_/modules/toolbars/presets.js'; + +const customToolbarPreset: ToolbarsPreset = { + items: full.items, + orders: { + ...full.orders, + [Toolbar.wysiwygSelection]: [ + [Action.text], + [Action.bold, Action.italic, Action.codeInline], + [Action.colorify, Action.link], + ], + [Toolbar.wysiwygSlash]: [ + [Action.paragraph, Action.heading1, Action.heading2], + [Action.bulletList, Action.orderedList, Action.codeBlock], + ], + }, +}; +``` + +Pass this preset to ``. Include the extensions required by these actions, for example by initializing the editor with `preset: 'full'` for this configuration. + +Selection orders preserve button groups and support the same buttons, lists, and React components as the main toolbar. A WYSIWYG item can use `condition: 'enabled'` to appear only when its action is enabled, or a `condition(state)` callback to control visibility. Embedded React components receive `disablePortal: true` by default to keep their popups inside the selection toolbar; their `wysiwyg.props` can override it. + +Slash orders are flattened into a command list, including the entries of list buttons. Only executable buttons with WYSIWYG actions are included; React components and popup buttons are skipped. Search matches each command's ID, title, and optional `view.aliases`. Built-in headings support aliases from `h1` to `h6`. + +Set a contextual order to `[]` to disable that toolbar. The `zero` toolbar preset disables both contextual toolbars by default. Contextual toolbars remain disabled on mobile. + +#### Migrating existing configurations + +The `wysiwygConfig.extensionOptions.selectionContext.config` and `wysiwygConfig.extensionOptions.commandMenu.actions` options remain supported for compatibility. An explicitly supplied contextual order in `toolbarsPreset` takes priority, including an empty array. If that order is omitted, the editor uses the corresponding extension option, or the built-in toolbar preset matching the editor preset. + +This lets existing custom presets that configure only the main toolbars continue working. To migrate, move each button's presentation into `items[id].view`, its WYSIWYG behavior into `items[id].wysiwyg`, and its position into the appropriate contextual order. Placement, flipping, and ignored node options still belong to the extensions. + +Changes to `toolbarsPreset` update both contextual toolbars without recreating the editor, including while their menus are open. Supply a new preset object when changing the configuration. Removing an override restores the extension or built-in configuration. + ### Conditional Toolbar Items Sometimes you may want to display different sets of toolbar items depending on certain conditions—for example, user permissions. In such cases, you can implement a getter function that returns the appropriate toolbar configuration based on parameters. Example: diff --git a/packages/editor/src/bundle/MarkdownEditorView.tsx b/packages/editor/src/bundle/MarkdownEditorView.tsx index 6d895e206..f56b910be 100644 --- a/packages/editor/src/bundle/MarkdownEditorView.tsx +++ b/packages/editor/src/bundle/MarkdownEditorView.tsx @@ -15,6 +15,7 @@ import {useEnsuredForwardedRef, useKey, useUpdate} from 'react-use'; import type {ClassNameProps} from '../classname'; import {i18n} from '../i18n/bundle'; import {globalLogger} from '../logger'; +import {contextualToolbarsKey} from '../modules/toolbars/contextual'; import type {ToolbarsPreset} from '../modules/toolbars/types'; import {useSticky} from '../react-utils'; import {isMac} from '../utils'; @@ -29,7 +30,7 @@ import {cnEditorComponent} from './editor-classname'; import {EditorSettings, type EditorSettingsProps, type SettingItems} from './settings'; import {stickyCn} from './sticky'; import type {ToolbarConfigs} from './toolbar/types'; -import {getToolbarsConfigs} from './toolbar/utils/toolbarsConfigs'; +import {getContextualToolbarsConfig, getToolbarsConfigs} from './toolbar/utils/toolbarsConfigs'; import type {MarkdownEditorMode} from './types'; import '../styles/styles.scss'; @@ -63,6 +64,32 @@ const EditorWrapper = forwardRef( ref, ) => { const showPreview = editor.previewVisible; + const contextualConfig = useMemo( + () => getContextualToolbarsConfig(toolbarsPreset), + [toolbarsPreset], + ); + + useLayoutEffect(() => { + if (editorMode !== 'wysiwyg' || editor.mobile) return undefined; + const {view} = editor.wysiwygEditor; + return () => { + if (!view.isDestroyed) + view.dispatch(view.state.tr.setMeta(contextualToolbarsKey, {})); + }; + }, [editor, editorMode]); + + useLayoutEffect(() => { + if (editorMode !== 'wysiwyg' || editor.mobile) return; + const {view} = editor.wysiwygEditor; + const current = contextualToolbarsKey.getState(view.state); + if ( + current?.selection !== contextualConfig.selection || + current?.slash !== contextualConfig.slash + ) { + view.dispatch(view.state.tr.setMeta(contextualToolbarsKey, contextualConfig)); + } + }, [editor, editorMode, contextualConfig]); + const { wysiwygToolbarConfig, markupToolbarConfig, diff --git a/packages/editor/src/bundle/config/action-names.ts b/packages/editor/src/bundle/config/action-names.ts index d97a6b7a5..bbd28e28f 100644 --- a/packages/editor/src/bundle/config/action-names.ts +++ b/packages/editor/src/bundle/config/action-names.ts @@ -13,6 +13,7 @@ const names = [ 'emoji', 'file', 'filePopup', + 'foldingHeading', 'gpt', 'heading1', 'heading2', @@ -47,6 +48,7 @@ const names = [ 'strike', 'table', 'tabs', + 'text', 'underline', 'undo', /** @deprecated use block */ diff --git a/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.test.ts b/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.test.ts new file mode 100644 index 000000000..a4147412a --- /dev/null +++ b/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.test.ts @@ -0,0 +1,110 @@ +import {filterActions} from '../../../extensions/behavior/CommandMenu/handler'; +import {ActionName, ToolbarName} from '../../../modules/toolbars/constants'; +import {textContextItemWisywig} from '../../../modules/toolbars/items'; +import {commonmark, defaultPreset, full, yfm, zero} from '../../../modules/toolbars/presets'; +import type {ToolbarsPreset} from '../../../modules/toolbars/types'; +import {wCommandMenuConfigByPreset, wSelectionMenuConfigByPreset} from '../../config/wysiwyg'; +import type {MarkdownEditorPreset} from '../../types'; + +import { + createSelectionToolbarConfig, + createSlashToolbarConfig, + getContextualToolbarsConfig, +} from './toolbarsConfigs'; + +const presets = {zero, commonmark, default: defaultPreset, yfm, full}; +const migratedIds: Record = { + 'folding-heading': ActionName.foldingHeading, + code_inline: ActionName.codeInline, + code_block: ActionName.codeBlock, + horizontalrule: ActionName.horizontalRule, + yfm_note: ActionName.note, + yfm_cut: ActionName.cut, +}; +const migrateId = ({id}: {id: string}) => migratedIds[id] ?? id; + +describe('Contextual toolbar presets', () => { + it.each(Object.keys(presets) as MarkdownEditorPreset[])( + 'preserves the default selection and slash actions for %s', + (preset) => { + const selection = createSelectionToolbarConfig(preset); + expect(selection.map((group) => group.map(({id}) => id))).toEqual( + wSelectionMenuConfigByPreset[preset].map((group) => group.map(migrateId)), + ); + expect(createSlashToolbarConfig(preset).map(({id}) => id)).toEqual( + wCommandMenuConfigByPreset[preset].map(migrateId), + ); + }, + ); + + it('preserves heading aliases and previews in the slash toolbar', () => { + const commands = createSlashToolbarConfig('full'); + for (let level = 1; level <= 6; level++) { + const matches = filterActions(commands, `h${level}`); + expect(matches).toHaveLength(1); + expect(matches[0].id).toBe(`heading${level}`); + expect(matches[0].preview).toBeDefined(); + } + }); + + it('flattens ordered lists and ignores components and markup-only actions in the slash toolbar', () => { + const preset: ToolbarsPreset = { + items: { + ...full.items, + markupOnly: {view: full.items.bold.view, markup: full.items.bold.markup}, + }, + orders: { + [ToolbarName.wysiwygSlash]: [ + [{id: 'heading', items: [ActionName.heading2, ActionName.heading1]}], + [ActionName.colorify, 'markupOnly', ActionName.paragraph], + ], + }, + }; + expect(createSlashToolbarConfig(preset).map(({id}) => id)).toEqual([ + ActionName.heading2, + ActionName.heading1, + ActionName.paragraph, + ]); + }); + + it('distinguishes omitted contextual orders from explicitly empty toolbars', () => { + expect(getContextualToolbarsConfig()).toEqual({selection: undefined, slash: undefined}); + expect(getContextualToolbarsConfig({items: {}, orders: {}})).toEqual({ + selection: undefined, + slash: undefined, + }); + expect( + getContextualToolbarsConfig({ + items: {}, + orders: { + [ToolbarName.wysiwygSelection]: [], + [ToolbarName.wysiwygSlash]: [], + }, + }), + ).toEqual({selection: [], slash: []}); + }); + + it('preserves selection conditions and custom component props', () => { + expect(getContextualToolbarsConfig(full).selection?.[0][1]).toEqual( + expect.objectContaining({props: {disablePortal: true}}), + ); + const preset: ToolbarsPreset = { + items: { + ...full.items, + text: { + ...full.items.text, + wysiwyg: {...textContextItemWisywig, props: {disablePortal: false}}, + }, + }, + orders: full.orders, + }; + const config = getContextualToolbarsConfig(preset).selection; + expect(config?.[0][0].condition).toBe('enabled'); + expect(config?.[0][1]).toEqual( + expect.objectContaining({ + condition: expect.any(Function), + props: {disablePortal: false}, + }), + ); + }); +}); diff --git a/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.ts b/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.ts index a23240add..d5522a7f7 100644 --- a/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.ts +++ b/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.ts @@ -1,4 +1,5 @@ import {ToolbarName} from '../../../modules/toolbars/constants'; +import type {ContextualToolbarsConfig} from '../../../modules/toolbars/contextual'; import {commonmark, defaultPreset, full, yfm, zero} from '../../../modules/toolbars/presets'; import type { ToolbarItem, @@ -6,9 +7,15 @@ import type { ToolbarItemWysiwyg, ToolbarsPreset, } from '../../../modules/toolbars/types'; -import type {MarkdownEditorPreset} from '../../types'; +import type {MarkdownEditorPreset} from '../../preset-base-types'; import {ToolbarDataType} from '../types'; -import type {MToolbarData, ToolbarConfigs, ToolbarIconData, WToolbarData} from '../types'; +import type { + MToolbarData, + ToolbarConfigs, + ToolbarIconData, + WToolbarData, + WToolbarItemData, +} from '../types'; import {flattenPreset} from './flattenPreset'; @@ -23,6 +30,8 @@ const defaultPresets: Record = { interface TransformedItem { type: ToolbarDataType; id: string; + className?: string; + aliases?: string[]; title?: string | (() => string); hint?: string | (() => string); icon?: ToolbarIconData; @@ -53,12 +62,14 @@ const transformItem = ( return { type: item.view.type ?? ToolbarDataType.SingleButton, id, + className: item.view.className, + aliases: item.view.aliases, title: item.view.title, hint: item.view.hint, icon: item.view.icon, hotkey: item.view.hotkey, doNotActivateList: item.view.doNotActivateList, - ...(isSingleButton && {preview: (item.view as any).preview}), + ...((isSingleButton || !item.view.type) && {preview: (item.view as any).preview}), ...(isListButton && { withArrow: (item.view as any).withArrow, replaceActiveIcon: (item.view as any).replaceActiveIcon, @@ -94,6 +105,38 @@ export const createToolbarConfig = ( return toolbarData as T; }; +export const createSelectionToolbarConfig = ( + preset: ToolbarsPreset | MarkdownEditorPreset, +): WToolbarData => + createToolbarConfig('wysiwyg', preset, ToolbarName.wysiwygSelection).map( + (group) => + group.map((item) => + item.type === ToolbarDataType.ReactComponent + ? {...item, props: {disablePortal: true, ...item.props}} + : item, + ), + ); + +export const createSlashToolbarConfig = ( + preset: ToolbarsPreset | MarkdownEditorPreset, +): WToolbarItemData[] => + createToolbarConfig('wysiwyg', preset, ToolbarName.wysiwygSlash) + .flatMap((group) => + group.flatMap((item): WToolbarItemData[] => { + if (item.type === ToolbarDataType.ListButton) return item.data; + if (item.type === ToolbarDataType.SingleButton) return [item]; + return []; + }), + ) + .filter((item) => typeof item.exec === 'function' && typeof item.isEnable === 'function'); + +export const getContextualToolbarsConfig = (preset?: ToolbarsPreset): ContextualToolbarsConfig => ({ + selection: preset?.orders[ToolbarName.wysiwygSelection] + ? createSelectionToolbarConfig(preset) + : undefined, + slash: preset?.orders[ToolbarName.wysiwygSlash] ? createSlashToolbarConfig(preset) : undefined, +}); + interface GetToolbarsConfigsArgs { toolbarsPreset?: ToolbarsPreset; props: ToolbarConfigs; diff --git a/packages/editor/src/bundle/wysiwyg-preset.ts b/packages/editor/src/bundle/wysiwyg-preset.ts index 066077467..eb0cc3f7a 100644 --- a/packages/editor/src/bundle/wysiwyg-preset.ts +++ b/packages/editor/src/bundle/wysiwyg-preset.ts @@ -8,6 +8,7 @@ import { } from '../extensions/behavior/EditorModeKeymap'; import {BaseNode, YfmHeadingAttr, YfmNoteNode} from '../extensions/specs'; import {i18n as i18nPlaceholder} from '../i18n/placeholder'; +import {contextualToolbarsPlugin} from '../modules/toolbars/contextual'; import {CommonMarkPreset, type CommonMarkPresetOptions} from '../presets/commonmark'; import {DefaultPreset, type DefaultPresetOptions} from '../presets/default'; import {FullPreset, type FullPresetOptions} from '../presets/full'; @@ -17,9 +18,12 @@ import {Action as A, formatter as f} from '../shortcuts'; import type {DirectiveSyntaxContext} from '../utils/directive'; import type {FileUploadHandler} from '../utils/upload'; -import {wCommandMenuConfigByPreset, wSelectionMenuConfigByPreset} from './config/wysiwyg'; import {emojiDefs} from './emoji'; import type {MarkdownEditorPreset, WysiwygPlaceholderOptions} from './preset-base-types'; +import { + createSelectionToolbarConfig, + createSlashToolbarConfig, +} from './toolbar/utils/toolbarsConfigs'; const DEFAULT_IGNORED_KEYS = ['Tab', 'Shift-Tab'] as const; @@ -55,6 +59,7 @@ declare global { export const BundlePreset: ExtensionAuto = (builder, opts) => { builder.context.set('directiveSyntax', opts.directiveSyntax); + if (!opts.mobile) builder.addPlugin(contextualToolbarsPlugin); const dropCursor: NonNullable['dropOptions'] = { color: 'var(--g-color-line-brand)', @@ -80,8 +85,11 @@ export const BundlePreset: ExtensionAuto = (builder, opts) } : undefined, clipboard: {pasteFileHandler: opts.fileUploadHandler, ...opts.clipboard}, - selectionContext: {config: wSelectionMenuConfigByPreset.zero, ...opts.selectionContext}, - commandMenu: {actions: wCommandMenuConfigByPreset.zero, ...opts.commandMenu}, + selectionContext: { + config: createSelectionToolbarConfig(opts.preset), + ...opts.selectionContext, + }, + commandMenu: {actions: createSlashToolbarConfig(opts.preset), ...opts.commandMenu}, history: {undoKey: f.toPM(A.Undo), redoKey: f.toPM(A.Redo), ...opts.history}, baseSchema: { paragraphKey: f.toPM(A.Text), @@ -109,11 +117,6 @@ export const BundlePreset: ExtensionAuto = (builder, opts) }; const commonMarkOptions: BehaviorPresetOptions & CommonMarkPresetOptions = { ...zeroOptions, - selectionContext: { - config: wSelectionMenuConfigByPreset.commonmark, - ...opts.selectionContext, - }, - commandMenu: {actions: wCommandMenuConfigByPreset.commonmark, ...opts.commandMenu}, breaks: { preferredBreak: (opts.mdBreaks ? 'soft' : 'hard') as 'soft' | 'hard', ...opts.breaks, @@ -139,15 +142,11 @@ export const BundlePreset: ExtensionAuto = (builder, opts) }; const defaultOptions: BehaviorPresetOptions & DefaultPresetOptions = { ...commonMarkOptions, - selectionContext: {config: wSelectionMenuConfigByPreset.default, ...opts.selectionContext}, - commandMenu: {actions: wCommandMenuConfigByPreset.default, ...opts.commandMenu}, strike: {strikeKey: f.toPM(A.Strike), ...opts.strike}, }; const yfmOptions: BehaviorPresetOptions & YfmPresetOptions = { ...defaultOptions, yfmConfigs: {disableAttrs: opts.disableMdAttrs, ...opts.yfmConfigs}, - selectionContext: {config: wSelectionMenuConfigByPreset.yfm, ...opts.selectionContext}, - commandMenu: {actions: wCommandMenuConfigByPreset.yfm, ...opts.commandMenu}, underline: {underlineKey: f.toPM(A.Underline), ...opts.underline}, imgSize: { imageUploadHandler: opts.fileUploadHandler, @@ -198,8 +197,6 @@ export const BundlePreset: ExtensionAuto = (builder, opts) }; const fullOptions: BehaviorPresetOptions & FullPresetOptions = { ...yfmOptions, - selectionContext: {config: wSelectionMenuConfigByPreset.full, ...opts.selectionContext}, - commandMenu: {actions: wCommandMenuConfigByPreset.full, ...opts.commandMenu}, emoji: {defs: emojiDefs, ...opts.emoji}, }; diff --git a/packages/editor/src/extensions/behavior/CommandMenu/handler.ts b/packages/editor/src/extensions/behavior/CommandMenu/handler.ts index 45be9c060..afaa3e2f7 100644 --- a/packages/editor/src/extensions/behavior/CommandMenu/handler.ts +++ b/packages/editor/src/extensions/behavior/CommandMenu/handler.ts @@ -1,8 +1,10 @@ +import type {EditorState} from 'prosemirror-state'; import type {EditorView} from 'prosemirror-view'; import type {ActionStorage} from '../../../core'; import {isFunction} from '../../../lodash'; import {type Logger2, globalLogger} from '../../../logger'; +import {contextualToolbarsKey} from '../../../modules/toolbars/contextual'; import {AutocompletePopupCloser} from '../../../utils/autocomplete-popup'; import {ArrayCarousel} from '../../../utils/carousel'; import { @@ -54,14 +56,14 @@ export class CommandHandler implements AutocompleteHandler { } onOpen(action: AutocompleteAction): boolean { + this.updateState(action); this.findAnchor(); - if (!this.#anchor || this.shouldIgnore(action)) { + if (!this.#anchor || this.shouldIgnore(action) || !this.actions.length) { this.closeAutocomplete(action.view); return true; } this.#popupCloser = new AutocompletePopupCloser(action.view); - this.updateState(action); this.filterActions(); this.render(); @@ -140,9 +142,33 @@ export class CommandHandler implements AutocompleteHandler { this.clear(); } + update(view: EditorView, prevState: EditorState): void { + if ( + this.#view && + contextualToolbarsKey.getState(view.state)?.slash !== + contextualToolbarsKey.getState(prevState)?.slash + ) { + this.#view = view; + this.filterActions(); + if (this.actions.length) { + this.render(); + } else { + this.#menuRenderItem?.remove(); + this.#menuRenderItem = undefined; + this.closeAutocomplete(view); + } + } + } + + private get actions(): readonly CommandAction[] { + return ( + (this.#view && contextualToolbarsKey.getState(this.#view.state)?.slash) ?? this.#actions + ); + } + private closeAutocomplete(view: EditorView) { setTimeout(() => { - closeAutocomplete(view); + if (!view.isDestroyed) closeAutocomplete(view); }); } @@ -177,7 +203,7 @@ export class CommandHandler implements AutocompleteHandler { const currentItem = this.#filteredActionsCarousel?.currentItem; const inputText = this.#filterText; - const enabledActions = this.#actions.filter((action) => + const enabledActions = this.actions.filter((action) => action.isEnable(this.#actionStorage), ); diff --git a/packages/editor/src/extensions/behavior/CommandMenu/index.ts b/packages/editor/src/extensions/behavior/CommandMenu/index.ts index e66b3f503..241c2c08e 100644 --- a/packages/editor/src/extensions/behavior/CommandMenu/index.ts +++ b/packages/editor/src/extensions/behavior/CommandMenu/index.ts @@ -1,7 +1,9 @@ +import {Plugin} from 'prosemirror-state'; + import type {ExtensionAuto} from '../../../core'; import {DeflistNode, TableNode} from '../../../extensions/markdown'; import {CheckboxNode, CutNode, TabsNode, YfmNoteNode} from '../../../extensions/yfm'; -import {type Logger2, globalLogger} from '../../../logger'; +import type {Logger2} from '../../../logger'; import {Autocomplete, type AutocompleteItemFn} from '../Autocomplete'; import {DecoClassName} from './const'; @@ -9,21 +11,19 @@ import {CommandHandler} from './handler'; import type {Config} from './types'; export type CommandMenuOptions = { + /** @deprecated Use `toolbarsPreset.orders.wysiwygSlash` on MarkdownEditorView. */ actions: Config; nodesIgnoreList?: readonly string[]; }; const getCommandMenuAutocompleteItem = - (opts: CommandMenuOptions, logger: Logger2.ILogger): AutocompleteItemFn => - ({actions}) => ({ - trigger: { - name: 'command', - trigger: /(?:^|\s)(\/)$/, - allArrowKeys: false, - cancelOnFirstSpace: true, - decorationAttrs: {class: DecoClassName}, - }, - handler: new CommandHandler({ + ( + opts: CommandMenuOptions, + logger: Logger2.ILogger, + onCreate: (handler: CommandHandler) => void, + ): AutocompleteItemFn => + ({actions}) => { + const handler = new CommandHandler({ logger, storage: actions, actions: opts.actions, @@ -37,21 +37,35 @@ const getCommandMenuAutocompleteItem = CutNode.CutTitle, TabsNode.Tab, ]), - }), - }); + }); + onCreate(handler); + return { + trigger: { + name: 'command', + trigger: /(?:^|\s)(\/)$/, + allArrowKeys: false, + cancelOnFirstSpace: true, + decorationAttrs: {class: DecoClassName}, + }, + handler, + }; + }; export const CommandMenu: ExtensionAuto = (builder, opts) => { - if (!Array.isArray(opts.actions) || opts.actions.length === 0) { - globalLogger.log( - "[CommandMenu extension]: Skip because 'actions' is not an array or is empty", - ); - builder.logger.log( - "[CommandMenu extension]: Skip because 'actions' is not an array or is empty", - ); - return; - } + // Keep the trigger available for toolbars supplied later by the editor view. if (!builder.context.has('autocomplete')) { builder.use(Autocomplete); } - builder.context.get('autocomplete')!.add(getCommandMenuAutocompleteItem(opts, builder.logger)); + let handler: CommandHandler | undefined; + builder.context.get('autocomplete')!.add( + getCommandMenuAutocompleteItem(opts, builder.logger, (created) => { + handler = created; + }), + ); + builder.addPlugin( + () => + new Plugin({ + view: () => ({update: (view, prevState) => handler?.update(view, prevState)}), + }), + ); }; diff --git a/packages/editor/src/extensions/behavior/SelectionContext/TextSelectionTooltip.tsx b/packages/editor/src/extensions/behavior/SelectionContext/TextSelectionTooltip.tsx index c0e710377..849b6d667 100644 --- a/packages/editor/src/extensions/behavior/SelectionContext/TextSelectionTooltip.tsx +++ b/packages/editor/src/extensions/behavior/SelectionContext/TextSelectionTooltip.tsx @@ -54,6 +54,8 @@ export const TextSelectionTooltip: React.FC = .filter((groupData) => Boolean(groupData.length)); }, [config, conditionKey]); + if (!toolbarData.length) return null; + return ( = (builder, opts) => { - const {config} = opts; - if (Array.isArray(config) && config.length > 0) { - builder.addPlugin( - ({actions}) => new Plugin(new SelectionTooltip(actions, config, builder.logger, opts)), - ); - } + // The editor view can supply a toolbar even when the initial config is empty. + builder.addPlugin( + ({actions}) => new Plugin(new SelectionTooltip(actions, builder.logger, opts)), + ); }; const HideMetaKey = 'hide-selection-menu'; @@ -60,8 +60,6 @@ type PluginState = { disabled: boolean; }; -type TinyState = Pick; - class SelectionTooltip implements PluginSpec { private destroyed = false; @@ -70,14 +68,11 @@ class SelectionTooltip implements PluginSpec { private hideTimeoutRef: ReturnType | null = null; private _isMousePressed = false; + private readonly config: ContextConfig; - constructor( - actions: ActionStorage, - menuConfig: ContextConfig, - logger: Logger2.ILogger, - options: SelectionContextOptions, - ) { - this.tooltip = new TooltipView(actions, menuConfig, logger, { + constructor(actions: ActionStorage, logger: Logger2.ILogger, options: SelectionContextOptions) { + this.config = options.config ?? []; + this.tooltip = new TooltipView(actions, this.config, logger, { ...options, onPopupOpenChange: (_open, _event, reason) => { if (reason !== 'escape-key' && this.editorView) @@ -105,10 +100,7 @@ class SelectionTooltip implements PluginSpec { }), handleDOMEvents: { mousedown: (view) => { - const startState: TinyState = { - doc: view.state.doc, - selection: view.state.selection, - }; + const startState = view.state; this._isMousePressed = true; this.cancelTooltipHiding(); this.tooltip.hide(view); @@ -146,7 +138,7 @@ class SelectionTooltip implements PluginSpec { }; } - private update(view: EditorView, prevState?: TinyState) { + private update(view: EditorView, prevState?: EditorState) { this.editorView = view; if (this._isMousePressed) return; @@ -162,8 +154,18 @@ class SelectionTooltip implements PluginSpec { } const {state} = view; + const config = this.getConfig(state); + if (!config.some((group) => group.length)) { + this.tooltip.hide(view); + return; + } // Don't do anything if the document/selection didn't change - if (prevState && prevState.doc.eq(state.doc) && prevState.selection.eq(state.selection)) { + if ( + prevState && + prevState.doc.eq(state.doc) && + prevState.selection.eq(state.selection) && + this.getConfig(prevState) === config + ) { return; } @@ -194,7 +196,11 @@ class SelectionTooltip implements PluginSpec { return; } - this.tooltip.show(view); + this.tooltip.show(view, config); + } + + private getConfig(state: EditorState): ContextConfig { + return contextualToolbarsKey.getState(state)?.selection ?? this.config; } private scheduleTooltipHiding(view: EditorView) { diff --git a/packages/editor/src/extensions/behavior/SelectionContext/tooltip.tsx b/packages/editor/src/extensions/behavior/SelectionContext/tooltip.tsx index e146cd1fa..c570333d7 100644 --- a/packages/editor/src/extensions/behavior/SelectionContext/tooltip.tsx +++ b/packages/editor/src/extensions/behavior/SelectionContext/tooltip.tsx @@ -25,7 +25,7 @@ export class TooltipView { private readonly logger: Logger2.ILogger; private readonly actions: ActionStorage; - private readonly menuConfig: ContextConfig; + private menuConfig: ContextConfig; private readonly placement: PopupPlacement; private readonly onPopupOpenChange: PopupProps['onOpenChange']; @@ -53,8 +53,9 @@ export class TooltipView { return this.#isTooltipOpen; } - show(view: EditorView) { + show(view: EditorView, config: ContextConfig) { this.view = view; + this.menuConfig = config; this.#isTooltipOpen = true; this.visible = true; this.anchor ??= this.createVirtualElement(view); diff --git a/packages/editor/src/modules/toolbars/contextual.ts b/packages/editor/src/modules/toolbars/contextual.ts new file mode 100644 index 000000000..3d4918125 --- /dev/null +++ b/packages/editor/src/modules/toolbars/contextual.ts @@ -0,0 +1,23 @@ +import {Plugin, PluginKey} from 'prosemirror-state'; + +import type {ActionStorage} from '../../core'; +import type {ContextConfig} from '../../extensions/behavior/SelectionContext/types'; +import type {ToolbarItemData} from '../../toolbar'; + +export interface ContextualToolbarsConfig { + selection?: ContextConfig; + slash?: ToolbarItemData[]; +} + +/** Toolbar overrides supplied by the editor view. Extension options remain the fallback. */ +export const contextualToolbarsKey = new PluginKey('contextual-toolbars'); + +export function contextualToolbarsPlugin() { + return new Plugin({ + key: contextualToolbarsKey, + state: { + init: () => ({}), + apply: (tr, config) => tr.getMeta(contextualToolbarsKey) ?? config, + }, + }); +} diff --git a/packages/editor/src/modules/toolbars/items.tsx b/packages/editor/src/modules/toolbars/items.tsx index d5997c59a..61d383963 100644 --- a/packages/editor/src/modules/toolbars/items.tsx +++ b/packages/editor/src/modules/toolbars/items.tsx @@ -546,6 +546,7 @@ export const heading1ItemView: ToolbarItemView = { title: i18n.bind(null, 'heading1'), icon: icons.h1, hotkey: f.toView(A.Heading1), + aliases: ['h1'], preview: , }; export const heading1ItemWysiwyg: ToolbarItemWysiwyg = { @@ -565,6 +566,7 @@ export const heading2ItemView: ToolbarItemView = { title: i18n.bind(null, 'heading2'), icon: icons.h2, hotkey: f.toView(A.Heading2), + aliases: ['h2'], preview: , }; export const heading2ItemWysiwyg: ToolbarItemWysiwyg = { @@ -584,6 +586,7 @@ export const heading3ItemView: ToolbarItemView = { title: i18n.bind(null, 'heading3'), icon: icons.h3, hotkey: f.toView(A.Heading3), + aliases: ['h3'], preview: , }; export const heading3ItemWysiwyg: ToolbarItemWysiwyg = { @@ -603,6 +606,7 @@ export const heading4ItemView: ToolbarItemView = { title: i18n.bind(null, 'heading4'), icon: icons.h4, hotkey: f.toView(A.Heading4), + aliases: ['h4'], preview: , }; export const heading4ItemWysiwyg: ToolbarItemWysiwyg = { @@ -622,6 +626,7 @@ export const heading5ItemView: ToolbarItemView = { title: i18n.bind(null, 'heading5'), icon: icons.h5, hotkey: f.toView(A.Heading5), + aliases: ['h5'], preview: , }; export const heading5ItemWysiwyg: ToolbarItemWysiwyg = { @@ -641,6 +646,7 @@ export const heading6ItemView: ToolbarItemView = { title: i18n.bind(null, 'heading6'), icon: icons.h6, hotkey: f.toView(A.Heading6), + aliases: ['h6'], preview: , }; export const heading6ItemWysiwyg: ToolbarItemWysiwyg = { diff --git a/packages/editor/src/modules/toolbars/presets.ts b/packages/editor/src/modules/toolbars/presets.ts index 6b800eb06..3ed8e016a 100644 --- a/packages/editor/src/modules/toolbars/presets.ts +++ b/packages/editor/src/modules/toolbars/presets.ts @@ -100,6 +100,10 @@ import { tabsItemMarkup, tabsItemView, tabsItemWysiwyg, + textContextItemView, + textContextItemWisywig, + toggleHeadingFoldingItemView, + toggleHeadingFoldingItemWysiwyg, underlineItemMarkup, underlineItemView, underlineItemWysiwyg, @@ -126,12 +130,18 @@ export const zero: ToolbarsPreset = { orders: { [Toolbar.wysiwygMain]: [[Action.undo, Action.redo]], [Toolbar.markupMain]: [[Action.undo, Action.redo]], + [Toolbar.wysiwygSelection]: [], + [Toolbar.wysiwygSlash]: [], }, }; export const commonmark: ToolbarsPreset = { items: { ...zero.items, + [Action.text]: { + view: textContextItemView, + wysiwyg: textContextItemWisywig, + }, [Action.bold]: { view: boldItemView, wysiwyg: boldItemWysiwyg, @@ -301,6 +311,30 @@ export const commonmark: ToolbarsPreset = { ], [Toolbar.wysiwygHidden]: [[Action.horizontalRule]], [Toolbar.markupHidden]: [[Action.horizontalRule]], + [Toolbar.wysiwygSelection]: [ + [Action.text], + [Action.bold, Action.italic, Action.codeInline], + [Action.link], + ], + [Toolbar.wysiwygSlash]: [ + [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + Action.link, + Action.quote, + Action.codeBlock, + Action.horizontalRule, + ], + ], }, }; @@ -314,6 +348,12 @@ export const defaultPreset: ToolbarsPreset = { }, }, orders: { + ...commonmark.orders, + [Toolbar.wysiwygSelection]: [ + [Action.text], + [Action.bold, Action.italic, Action.strike, Action.codeInline], + [Action.link], + ], [Toolbar.wysiwygMain]: [ [Action.undo, Action.redo], [Action.bold, Action.italic, Action.strike], @@ -441,6 +481,38 @@ export const yfm: ToolbarsPreset = { }, }, orders: { + ...defaultPreset.orders, + [Toolbar.wysiwygSelection]: [ + [Action.text], + [Action.bold, Action.italic, Action.strike, Action.mono, Action.codeInline], + [Action.link], + ], + [Toolbar.wysiwygSlash]: [ + [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + Action.link, + Action.quote, + Action.note, + Action.cut, + Action.codeBlock, + Action.checkbox, + Action.table, + Action.image, + Action.horizontalRule, + Action.file, + Action.tabs, + ], + ], [Toolbar.wysiwygMain]: [ [Action.undo, Action.redo], [Action.bold, Action.italic, Action.underline, Action.strike, Action.mono], @@ -521,6 +593,10 @@ export const yfm: ToolbarsPreset = { export const full: ToolbarsPreset = { items: { ...yfm.items, + [Action.foldingHeading]: { + view: toggleHeadingFoldingItemView, + wysiwyg: toggleHeadingFoldingItemWysiwyg, + }, [Action.mark]: { view: markedItemView, wysiwyg: markedItemWysiwyg, @@ -538,6 +614,47 @@ export const full: ToolbarsPreset = { }, }, orders: { + ...yfm.orders, + [Toolbar.wysiwygSelection]: [ + [Action.foldingHeading, Action.text], + [ + Action.bold, + Action.italic, + Action.underline, + Action.strike, + Action.mono, + Action.mark, + Action.codeInline, + ], + [Action.colorify, Action.link], + ], + [Toolbar.wysiwygSlash]: [ + [ + Action.paragraph, + Action.heading1, + Action.heading2, + Action.heading3, + Action.heading4, + Action.heading5, + Action.heading6, + Action.bulletList, + Action.orderedList, + Action.sinkListItem, + Action.liftListItem, + Action.link, + Action.quote, + Action.note, + Action.cut, + Action.codeBlock, + Action.checkbox, + Action.table, + Action.image, + Action.horizontalRule, + Action.emoji, + Action.file, + Action.tabs, + ], + ], [Toolbar.wysiwygMain]: [ [Action.undo, Action.redo], [Action.bold, Action.italic, Action.underline, Action.strike, Action.mono, Action.mark], diff --git a/packages/editor/src/modules/toolbars/types.ts b/packages/editor/src/modules/toolbars/types.ts index 4c894cca3..b73c1c059 100644 --- a/packages/editor/src/modules/toolbars/types.ts +++ b/packages/editor/src/modules/toolbars/types.ts @@ -23,6 +23,8 @@ export type ToolbarItemView = Partial> & { width: number; noRerenderOnUpdate?: boolean; component: React.ComponentType>; + props?: object; } : {}); From cfa5b3ce85350c9955705f231cc4e7fa12d1f127 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 10 Sep 2026 20:42:48 +0200 Subject: [PATCH 2/2] fix(toolbars): harden contextual preset updates --- .../ContextualToolbars.helpers.tsx | 46 +++++- .../ContextualToolbars.visual.test.tsx | 138 ++++++++++++++++++ docs/how-to-customize-toolbars.md | 2 +- .../toolbar/utils/toolbarsConfigs.test.ts | 35 +++++ .../bundle/toolbar/utils/toolbarsConfigs.ts | 75 ++-------- .../behavior/CommandMenu/handler.ts | 10 +- .../extensions/behavior/CommandMenu/index.ts | 36 ++--- .../SelectionContext/TextSelectionTooltip.tsx | 11 +- 8 files changed, 248 insertions(+), 105 deletions(-) diff --git a/demo/tests/visual-tests/ContextualToolbars.helpers.tsx b/demo/tests/visual-tests/ContextualToolbars.helpers.tsx index 5964103d2..2250aeae9 100644 --- a/demo/tests/visual-tests/ContextualToolbars.helpers.tsx +++ b/demo/tests/visual-tests/ContextualToolbars.helpers.tsx @@ -55,29 +55,53 @@ const zeroCustom: ToolbarsPreset = { [Toolbar.wysiwygSlash]: [[Action.paragraph]], }, }; -const configs = {custom, alternate, empty, mainOnly, zeroCustom, default: undefined}; +const refreshed: ToolbarsPreset = {...custom}; +const conditional: ToolbarsPreset = { + items: { + hidden: {...full.items.bold, wysiwyg: {...full.items.bold.wysiwyg, condition: () => false}}, + disabled: { + ...full.items.italic, + wysiwyg: {...full.items.italic.wysiwyg, condition: 'enabled', isEnable: () => false}, + }, + }, + orders: {[Toolbar.wysiwygSelection]: [['hidden'], ['disabled']]}, +}; +const configs = { + custom, + alternate, + empty, + mainOnly, + zeroCustom, + refreshed, + conditional, + default: undefined, +}; export function ContextualToolbars({ initialConfig = 'custom', preset = 'full', legacy = false, mobile = false, + initialMode = 'wysiwyg', + initialMarkup = 'Select this text', }: { initialConfig?: keyof typeof configs; preset?: MarkdownEditorPreset; - legacy?: boolean; + legacy?: boolean | 'empty'; mobile?: boolean; + initialMode?: 'wysiwyg' | 'markup'; + initialMarkup?: string; }) { const [config, setConfig] = useState(initialConfig); const editor = useMarkdownEditor({ preset, mobile, - initial: {markup: 'Select this text', mode: 'wysiwyg'}, + initial: {markup: initialMarkup, mode: initialMode}, wysiwygConfig: legacy ? { extensionOptions: { - selectionContext: {config: [[wItalicItemData]]}, - commandMenu: {actions: [wHeading1ItemData]}, + selectionContext: {config: legacy === 'empty' ? [] : [[wItalicItemData]]}, + commandMenu: {actions: legacy === 'empty' ? [] : [wHeading1ItemData]}, }, } : undefined, @@ -85,7 +109,17 @@ export function ContextualToolbars({ return (
- {(['custom', 'alternate', 'empty', 'zeroCustom', 'default'] as const).map((name) => ( + {( + [ + 'custom', + 'alternate', + 'empty', + 'zeroCustom', + 'refreshed', + 'conditional', + 'default', + ] as const + ).map((name) => (