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..2250aeae9 --- /dev/null +++ b/demo/tests/visual-tests/ContextualToolbars.helpers.tsx @@ -0,0 +1,139 @@ +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 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 | 'empty'; + mobile?: boolean; + initialMode?: 'wysiwyg' | 'markup'; + initialMarkup?: string; +}) { + const [config, setConfig] = useState(initialConfig); + const editor = useMarkdownEditor({ + preset, + mobile, + initial: {markup: initialMarkup, mode: initialMode}, + wysiwygConfig: legacy + ? { + extensionOptions: { + selectionContext: {config: legacy === 'empty' ? [] : [[wItalicItemData]]}, + commandMenu: {actions: legacy === 'empty' ? [] : [wHeading1ItemData]}, + }, + } + : undefined, + }); + + return ( +
+ {( + [ + 'custom', + 'alternate', + 'empty', + 'zeroCustom', + 'refreshed', + 'conditional', + '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..cbe7c0257 --- /dev/null +++ b/demo/tests/visual-tests/ContextualToolbars.visual.test.tsx @@ -0,0 +1,306 @@ +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('preserves the highlighted slash command when the preset object is replaced', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.fill(''); + await editor.pressSequentially('/'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Custom heading'); + await editor.press('ArrowDown'); + await page.getByRole('button', {name: 'Use refreshed toolbar'}).click(); + await editor.press('Enter'); + await editor.pressSequentially('Still a paragraph'); + await expect(editor.locators.contenteditable.locator('p')).toHaveText('Still a paragraph'); + await expect(editor.locators.contenteditable.locator('h2')).toHaveCount(0); + }); + + test('enables initially empty legacy menus and restores their disabled state', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeHidden(); + await page.getByRole('button', {name: 'Use custom toolbar'}).click(); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveCount(2); + await editor.fill(''); + await editor.pressSequentially('/'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Custom heading'); + await page.getByRole('button', {name: 'Use default toolbar'}).click(); + await expect(editor.locators.toolbars.commandMenu).toBeHidden(); + await editor.pressSequentially('topic'); + await expect(editor.locators.contenteditable).toHaveText('/topic'); + }); + + test('closes a filtered slash menu when the replacement preset no longer matches', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.fill(''); + await editor.pressSequentially('/topic'); + await expect(editor.locators.toolbars.commandMenu).toContainText('Custom heading'); + await page.getByRole('button', {name: 'Use alternate toolbar'}).click(); + await expect(editor.locators.toolbars.commandMenu).toBeHidden(); + await expect(editor.locators.contenteditable).toHaveText('/topic'); + }); + + test('hides an empty selection popup after evaluating visibility conditions', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeVisible(); + await page.getByRole('button', {name: 'Use conditional toolbar'}).click(); + await expect(editor.locators.toolbars.selection).toBeHidden(); + await page.getByRole('button', {name: 'Use custom toolbar'}).click(); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveCount(2); + }); + + test('preserves formatting undo and redo across toolbar configuration updates', async ({ + mount, + editor, + page, + }) => { + await mount(); + await editor.press('ControlOrMeta+a'); + await editor.locators.toolbars.selection + .getByRole('button', {name: 'Bold', exact: true}) + .click(); + await page.getByRole('button', {name: 'Use alternate toolbar'}).click(); + await editor.press('ControlOrMeta+z'); + await expect(editor.locators.contenteditable.locator('strong')).toHaveCount(0); + await expect(editor.locators.contenteditable).toHaveText('Select this text'); + await editor.press('ControlOrMeta+Shift+z'); + await expect(editor.locators.contenteditable.locator('strong')).toHaveText( + 'Select this text', + ); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveAttribute( + 'aria-label', + 'Strikethrough', + ); + }); + + test('uses the latest contextual preset when first entering WYSIWYG mode', async ({ + mount, + editor, + page, + }) => { + await mount(); + await page.getByRole('button', {name: 'Use alternate toolbar'}).click(); + await editor.switchMode('wysiwyg'); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection.getByRole('button')).toHaveAttribute( + 'aria-label', + 'Strikethrough', + ); + 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('keeps selection conditions at block boundaries and hides menus in code blocks', async ({ + mount, + editor, + }) => { + await mount( + , + ); + await editor.press('ControlOrMeta+a'); + await expect( + editor.locators.toolbars.selection.getByTestId('g-md-toolbar-text-select'), + ).toBeVisible(); + await editor.press('ControlOrMeta+a'); + await expect(editor.locators.toolbars.selection).toBeVisible(); + await expect( + editor.locators.toolbars.selection.getByTestId('g-md-toolbar-text-select'), + ).toBeHidden(); + await editor.fill(''); + await editor.pressSequentially('/code'); + await editor.press('Enter'); + await editor.pressSequentially('code'); + await editor.press('Home'); + await editor.press('Shift+End'); + await expect(editor.locators.toolbars.selection).toBeHidden(); + await editor.press('End'); + await editor.pressSequentially(' /h1'); + await expect(editor.locators.toolbars.commandMenu).toBeHidden(); + }); + + 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..403be4a16 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 are deprecated and 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..b6e8e2b66 --- /dev/null +++ b/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.test.ts @@ -0,0 +1,145 @@ +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 {ToolbarDataType} 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('skips popup buttons and components nested in slash lists', () => { + const preset: ToolbarsPreset = { + items: { + ...full.items, + popup: { + view: {...full.items.bold.view, type: ToolbarDataType.ButtonPopup}, + wysiwyg: {...full.items.bold.wysiwyg, renderPopup: () => null}, + }, + component: { + view: {type: ToolbarDataType.ReactComponent}, + wysiwyg: { + ...full.items.bold.wysiwyg, + component: () => null, + width: 20, + }, + }, + }, + orders: { + [ToolbarName.wysiwygSlash]: [ + [{id: 'heading', items: ['popup', 'component', ActionName.heading1]}], + ], + }, + }; + expect(createSlashToolbarConfig(preset).map(({id}) => id)).toEqual([ActionName.heading1]); + }); + + it('ignores slash list buttons without an explicit item order', () => { + const preset: ToolbarsPreset = { + items: full.items, + orders: {[ToolbarName.wysiwygSlash]: [['heading', ActionName.paragraph]]}, + }; + expect(createSlashToolbarConfig(preset).map(({id}) => id)).toEqual([ActionName.paragraph]); + }); + + 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..8f665c994 100644 --- a/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.ts +++ b/packages/editor/src/bundle/toolbar/utils/toolbarsConfigs.ts @@ -1,14 +1,10 @@ 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, - ToolbarItemMarkup, - ToolbarItemWysiwyg, - ToolbarsPreset, -} from '../../../modules/toolbars/types'; -import type {MarkdownEditorPreset} from '../../types'; +import type {ToolbarItem, ToolbarsPreset} from '../../../modules/toolbars/types'; +import type {MarkdownEditorPreset} from '../../preset-base-types'; import {ToolbarDataType} from '../types'; -import type {MToolbarData, ToolbarConfigs, ToolbarIconData, WToolbarData} from '../types'; +import type {MToolbarData, ToolbarConfigs, WToolbarData, WToolbarItemData} from '../types'; import {flattenPreset} from './flattenPreset'; @@ -20,51 +16,23 @@ const defaultPresets: Record = { full, }; -interface TransformedItem { - type: ToolbarDataType; - id: string; - title?: string | (() => string); - hint?: string | (() => string); - icon?: ToolbarIconData; - hotkey?: string; - withArrow?: boolean; - replaceActiveIcon?: true; - doNotActivateList?: boolean; - preview?: React.ReactNode; - wysiwyg?: ToolbarItemWysiwyg; - markup?: ToolbarItemMarkup; -} - const transformItem = ( type: 'wysiwyg' | 'markup', - item?: ToolbarItem, + item?: ToolbarItem, id = 'unknown', -): TransformedItem => { +) => { if (!item) { console.warn( `Toolbar item "${id}" not found, it might not have been added to the items dictionary.`, ); - return {} as TransformedItem; + return {}; } - const isListButton = item.view.type === ToolbarDataType.ListButton; - const isSingleButton = item.view.type === ToolbarDataType.SingleButton; - return { + ...item.view, type: item.view.type ?? ToolbarDataType.SingleButton, id, - 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}), - ...(isListButton && { - withArrow: (item.view as any).withArrow, - replaceActiveIcon: (item.view as any).replaceActiveIcon, - }), - ...(type === 'wysiwyg' && item.wysiwyg && {...item.wysiwyg}), - ...(type === 'markup' && item.markup && {...item.markup}), + ...item[type], }; }; @@ -94,6 +62,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[] => + flattenPreset( + createToolbarConfig('wysiwyg', preset, ToolbarName.wysiwygSlash), + ).filter( + (item) => + 'type' in item && + item.type === ToolbarDataType.SingleButton && + 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..fed84064f 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; + const needToClose = this.filterActions(); + if (!this.actions.length || needToClose) { + this.#menuRenderItem?.remove(); + this.#menuRenderItem = undefined; + this.closeAutocomplete(view); + } else { + this.render(); + } + } + } + + 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), ); @@ -193,7 +219,7 @@ export class CommandHandler implements AutocompleteHandler { if (currentItem) { const newIndex = this.#filteredActionsCarousel.array.findIndex( - (item) => item === currentItem, + (item) => item.id === currentItem.id, ); if (newIndex !== -1) { this.#filteredActionsCarousel.currentIndex = newIndex; diff --git a/packages/editor/src/extensions/behavior/CommandMenu/index.ts b/packages/editor/src/extensions/behavior/CommandMenu/index.ts index e66b3f503..17e0b6e6b 100644 --- a/packages/editor/src/extensions/behavior/CommandMenu/index.ts +++ b/packages/editor/src/extensions/behavior/CommandMenu/index.ts @@ -1,30 +1,29 @@ +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 {Autocomplete, type AutocompleteItemFn} from '../Autocomplete'; +import {Autocomplete} from '../Autocomplete'; import {DecoClassName} from './const'; 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({ - logger, +export const CommandMenu: ExtensionAuto = (builder, opts) => { + // Keep the trigger available for toolbars supplied later by the editor view. + if (!builder.context.has('autocomplete')) { + builder.use(Autocomplete); + } + let handler: CommandHandler | undefined; + builder.context.get('autocomplete')!.add(({actions}) => { + handler = new CommandHandler({ + logger: builder.logger, storage: actions, actions: opts.actions, // TODO: add commandMenu=false flag to specs: @@ -37,21 +36,22 @@ const getCommandMenuAutocompleteItem = CutNode.CutTitle, TabsNode.Tab, ]), - }), + }); + 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; - } - if (!builder.context.has('autocomplete')) { - builder.use(Autocomplete); - } - builder.context.get('autocomplete')!.add(getCommandMenuAutocompleteItem(opts, builder.logger)); + 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..4116aa6b0 100644 --- a/packages/editor/src/extensions/behavior/SelectionContext/TextSelectionTooltip.tsx +++ b/packages/editor/src/extensions/behavior/SelectionContext/TextSelectionTooltip.tsx @@ -1,4 +1,4 @@ -import {useEffect, useMemo, useState} from 'react'; +import {useMemo} from 'react'; import {Popup, type PopupPlacement, type PopupProps, sp} from '@gravity-ui/uikit'; @@ -37,14 +37,7 @@ export const TextSelectionTooltip: React.FC = onClick, editorView, }) { - const [conditionKey, setConditionKey] = useState(() => - calcConditionKey(config, editor, editorView), - ); - - useEffect(() => { - const newKey = calcConditionKey(config, editor, editorView); - if (conditionKey !== newKey) setConditionKey(newKey); - }); + const conditionKey = calcConditionKey(config, editor, editorView); const toolbarData = useMemo>(() => { const results = conditionKey.split(KEY_SEP); @@ -54,6 +47,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; } : {});